50 Commits

Author SHA1 Message Date
viper151
b24f5e421f Merge pull request #142 from siteboon/feature/mcp-project
feat: Local/Project MCPs and Import from JSON
2025-08-11 14:22:21 +03:00
simos
6d17e6db81 feat: update version to 1.6.0 and enhance ToolsSettings component with loading from json and adding project MCP servers 2025-08-11 14:20:54 +03:00
simos
99b204f5bf feat: add JSON import support for MCP server configuration in ToolsSettings 2025-08-11 14:05:31 +03:00
simos
21d9242d50 feat: enhance MCP server management with config file support and improved CLI interactions 2025-08-11 13:51:11 +03:00
viper151
5e4be4d113 Merge pull request #141 from dorage/style/settings-bg-in-mobile
style: remove revert rule causing unexpected transparency in modal
2025-08-11 12:15:53 +03:00
kanghyunlee
46d119685d style: remove revert rule causing unexpected transparency in modal overlays 2025-08-10 15:53:11 +09:00
viper151
12dccbf224 Merge pull request #112 from Difocd/main
Remove executable permissions from non-script files
2025-08-06 19:57:35 +02:00
viper151
2a0e4c58c2 Merge branch 'main' into main 2025-08-06 19:57:24 +02:00
viper151
a01d6c91e0 Merge pull request #117 from GiGiDKR/feature/windows-support
feat(platform): Improve cross-platform compatibility with Windows support
2025-08-06 18:30:45 +02:00
viper151
8774aa4289 Update index.js 2025-08-06 19:30:05 +03:00
viper151
5ba62a2b7b Merge branch 'main' into feature/windows-support 2025-08-06 18:20:36 +02:00
viper151
23c50f8fef Merge pull request #119 from ismaslov/fix/websocket-proxy-config
Update vite.config.js for websocket proxy configuration
2025-08-06 18:20:26 +02:00
viper151
8b40f9f177 Merge branch 'main' into fix/websocket-proxy-config 2025-08-06 18:17:54 +02:00
GiGiDKR
51f935f6d8 Merge branch 'main' into feature/windows-support 2025-08-02 02:31:54 +02:00
viper151
f6408c5106 Merge pull request #97 from mkdir3/fix/sidebar-folder-name-display
fix(sidebar): display project name instead of full path
2025-08-01 22:31:16 +02:00
viper151
41be9a4f63 Merge branch 'main' into fix/sidebar-folder-name-display 2025-08-01 22:31:03 +02:00
viper151
0a9c2484d6 Merge pull request #133 from WolCarlos/main
Fix command escaping in claude-cli.js
2025-08-01 22:29:15 +02:00
WolCarlos
2d912eeb64 Update claude-cli.js
add "``"
2025-07-31 22:19:55 +02:00
WolCarlos
952aeab70a Fix bug - ‘truncated messages’ - Update claude-cli.js
I fixed the ‘truncated messages’ bug in chat (Windows 11/Chrome).
2025-07-31 16:35:09 +02:00
Igor Maslov
95644fd457 Update vite.config.js for websocket proxy configuration 2025-07-23 21:11:58 +00:00
OhMyApps
42a80748af fix(server): change default PORT from 3000 to 3001 2025-07-23 19:17:05 +02:00
aëldrin_sagë
7031d9437c Merge branch 'main' into fix/sidebar-folder-name-display 2025-07-23 18:59:43 +02:00
OhMyApps
2ff59bd28c feat(platform): improve cross-platform compatibility with Windows support
Enhance application to work seamlessly across operating systems by implementing platform-specific adaptations:

- Update node-pty to version 1.1.0-beta34 for Windows support
- Implement PowerShell terminal support for Windows environments
- Integrate cross-spawn library for reliable process execution on all platforms
- Standardize development ports to industry defaults (3001/5173)
- Add dynamic shell detection based on operating system

BREAKING CHANGE: Development server ports have been changed from 3008/3009 to 3001/5173 to align with standard Vite/Express defaults
2025-07-23 18:36:29 +02:00
Aëldrin Sagë
7fd63d83ac fix(sidebar): display only folder name instead of full path in project list 2025-07-23 17:52:48 +02:00
simos
6db8be5f54 Remove deprecated dependency "@anthropic-ai/claude-code" from package.json 2025-07-23 13:39:19 +00:00
simos
32481356cb Redirect to Vite dev server in development mode for better local testing 2025-07-23 13:37:28 +00:00
viper151
3e0c23b7e2 Merge pull request #55 from krzemienski/fix/react-errors-and-localStorage-quota
fix: resolve React errors and localStorage quota issues
2025-07-23 14:33:59 +02:00
viper151
22fa724503 Delete CLAUDE.md 2025-07-23 14:29:34 +02:00
viper151
67339b0e4b Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-23 14:22:33 +02:00
viper151
4fcf27bd13 Merge pull request #51 from Mirza-Samad-Ahmed-Baig/fix/registration-race-condition
Fix: Prevent race condition in user registration
2025-07-23 14:21:35 +02:00
viper151
4de2f5026e Merge branch 'main' into fix/registration-race-condition 2025-07-23 14:20:07 +02:00
Difocd
9cfccc04f3 Remove executable permissions from non-script files 2025-07-23 11:42:12 +08:00
Natsuki YOKOTA
7f4feb182e feat: add ctrl+enter send option & fix IME problen (#62) 2025-07-21 17:30:53 +02:00
simos
d36890be52 Fixes on Claude limit usage reached message 2025-07-15 14:04:15 +00:00
viper151
c925742df1 Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-14 17:51:56 +02:00
viper151
33aea3f7e8 feat: Publish branch functionality (#66) 2025-07-14 17:46:11 +02:00
viper151
f28dc0140e Add delete functionality for untracked files (#65)
- Add delete button for untracked files in GitPanel
- Implement deleteUntrackedFile function with confirmation dialog
- Add /delete-untracked API endpoint to safely delete untracked files
- Update confirmation modal to handle delete actions
- Maintain consistent UI patterns with existing discard functionality
2025-07-14 17:27:02 +02:00
Nick Krzemienski
2ca929e5e5 Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-13 19:53:23 -04:00
simos
36d0add224 Changing logo to the proper one 2025-07-13 20:51:05 +00:00
simos
7db22fae29 Enhance ChatInterface 2025-07-13 20:43:15 +00:00
simos
b808ca1b68 Update ChatInterface 2025-07-13 20:34:04 +00:00
viper151
62ad40ad71 Merge branch 'main' into fix/registration-race-condition 2025-07-13 19:18:48 +02:00
viper151
a8f212bff8 Merge branch 'main' into fix/react-errors-and-localStorage-quota 2025-07-13 17:31:45 +02:00
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
Nick Krzemienski
9cf0173bc9 fix: resolve React errors and localStorage quota issues
- Fix DOMException in ChatInterface handleImageFiles function with proper file validation
- Add ErrorBoundary component to catch React errors gracefully
- Implement safeLocalStorage utility to handle QuotaExceededError
- Add intelligent quota management with automatic cleanup
- Limit chat history to 50 messages per project to prevent storage bloat
- Add comprehensive error handling for all localStorage operations
- Create comprehensive CLAUDE.md documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 11:29:20 -04: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
c0d8241f3d Merge branch 'main' into fix/registration-race-condition 2025-07-13 11:37:21 +02:00
Mirza-Samad-Ahmed-Baig
23e5f7ac2d Fix: Prevent race condition in user registration 2025-07-13 14:30:09 +05:00
66 changed files with 4157 additions and 1721 deletions

View File

@@ -7,6 +7,6 @@
# Backend server port (Express API + WebSocket server)
#API server
PORT=3008
PORT=3001
#Frontend port
VITE_PORT=3009
VITE_PORT=5173

0
README.md Executable file → Normal file
View File

0
index.html Executable file → Normal file
View File

1624
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

8
package.json Executable file → Normal file
View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-ui",
"version": "1.3.0",
"version": "1.6.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -22,7 +22,6 @@
"author": "Claude Code UI Contributors",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.24",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -40,13 +39,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.0.0",
"node-pty": "^1.1.0-beta34",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
@@ -68,4 +68,4 @@
"tailwindcss": "^3.4.0",
"vite": "^7.0.4"
}
}
}

0
public/convert-icons.md Executable file → Normal file
View File

0
public/favicon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 281 B

0
public/favicon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

0
public/icons/claude-ai-icon.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

0
public/icons/generate-icons.md Executable file → Normal file
View File

0
public/icons/icon-128x128.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-128x128.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-144x144.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-144x144.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-152x152.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-152x152.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-192x192.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-192x192.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-384x384.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-384x384.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-512x512.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-512x512.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-72x72.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-72x72.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-96x96.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

0
public/icons/icon-96x96.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/icons/icon-template.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 578 B

0
public/logo.svg Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 422 B

0
public/manifest.json Executable file → Normal file
View File

0
public/screenshots/desktop-main.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 385 KiB

0
public/screenshots/mobile-chat.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

0
public/screenshots/tools-modal.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 295 KiB

View File

@@ -1,8 +1,12 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeClaudeProcesses = new Map(); // Track active processes by session ID
async function spawnClaude(command, options = {}, ws) {
@@ -23,7 +27,11 @@ async function spawnClaude(command, options = {}, ws) {
// Add print flag with command if we have a command
if (command && command.trim()) {
args.push('--print', command);
// Separate arguments for better cross-platform compatibility
// This prevents issues with spaces and quotes on Windows
args.push('--print');
args.push(command);
}
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
@@ -63,9 +71,9 @@ async function spawnClaude(command, options = {}, ws) {
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
// Update the command in args - now that --print and command are separate
const printIndex = args.indexOf('--print');
if (printIndex !== -1 && args[printIndex + 1] === command) {
if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) {
args[printIndex + 1] = modifiedCommand;
}
}
@@ -84,6 +92,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');
@@ -149,7 +235,7 @@ async function spawnClaude(command, options = {}, ws) {
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
const claudeProcess = spawn('claude', args, {
const claudeProcess = spawnFunction('claude', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
@@ -302,4 +388,4 @@ function abortClaudeSession(sessionId) {
export {
spawnClaude,
abortClaudeSession
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -53,13 +53,8 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
// If it starts with /, it's an absolute path
if (projectPath.startsWith('/')) {
const parts = projectPath.split('/').filter(Boolean);
if (parts.length > 3) {
// Show last 2 folders with ellipsis: "...projects/myapp"
return `.../${parts.slice(-2).join('/')}`;
} else {
// Show full path if short: "/home/user"
return projectPath;
}
// Return only the last folder name
return parts[parts.length - 1] || projectPath;
}
return projectPath;

View File

@@ -1,6 +1,6 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../database/db.js';
import { userDb, db } from '../database/db.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
const router = express.Router();
@@ -33,31 +33,41 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
}
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
// Use a transaction to prevent race conditions
db.prepare('BEGIN').run();
try {
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
db.prepare('ROLLBACK').run();
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run();
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
db.prepare('ROLLBACK').run();
throw error;
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Registration error:', error);
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {

View File

@@ -444,10 +444,25 @@ router.get('/remote-status', async (req, res) => {
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
// No upstream branch configured
// No upstream branch configured - but check if we have remotes
let hasRemote = false;
let remoteName = null;
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length > 0) {
hasRemote = true;
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
}
} catch (remoteError) {
// No remotes configured
}
return res.json({
hasRemote: false,
hasRemote,
hasUpstream: false,
branch,
remoteName,
message: 'No remote tracking branch configured'
});
}
@@ -462,6 +477,7 @@ router.get('/remote-status', async (req, res) => {
res.json({
hasRemote: true,
hasUpstream: true,
branch,
remoteBranch: trackingBranch,
remoteName,
@@ -653,6 +669,82 @@ router.post('/push', async (req, res) => {
}
});
// Publish branch to remote (set upstream and push)
router.post('/publish', async (req, res) => {
const { project, branch } = req.body;
if (!project || !branch) {
return res.status(400).json({ error: 'Project name and branch are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Get current branch to verify it matches the requested branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
const currentBranchName = currentBranch.trim();
if (currentBranchName !== branch) {
return res.status(400).json({
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
});
}
// Check if remote exists
let remoteName = 'origin';
try {
const { stdout } = await execAsync('git remote', { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length === 0) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
} catch (error) {
return res.status(400).json({
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
});
}
// Publish the branch (set upstream and push)
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
res.json({
success: true,
output: stdout || 'Branch published successfully',
remoteName,
branch
});
} catch (error) {
console.error('Git publish error:', error);
// Enhanced error handling for common publish scenarios
let errorMessage = 'Publish failed';
let details = error.message;
if (error.message.includes('rejected')) {
errorMessage = 'Publish rejected';
details = 'The remote branch already exists and has different commits. Use push instead.';
} else if (error.message.includes('Could not resolve hostname')) {
errorMessage = 'Network error';
details = 'Unable to connect to remote repository. Check your internet connection.';
} else if (error.message.includes('Permission denied')) {
errorMessage = 'Authentication failed';
details = 'Permission denied. Check your credentials or SSH keys.';
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
errorMessage = 'Remote not configured';
details = 'Remote repository not properly configured. Check your remote URL.';
}
res.status(500).json({
error: errorMessage,
details: details
});
}
});
// Discard changes for a specific file
router.post('/discard', async (req, res) => {
const { project, file } = req.body;
@@ -692,4 +784,39 @@ router.post('/discard', async (req, res) => {
}
});
// Delete untracked file
router.post('/delete-untracked', async (req, res) => {
const { project, file } = req.body;
if (!project || !file) {
return res.status(400).json({ error: 'Project name and file path are required' });
}
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
// Check if file is actually untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });
}
const status = statusOutput.substring(0, 2);
if (status !== '??') {
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
}
// Delete the untracked file
await fs.unlink(path.join(projectPath, file));
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
} catch (error) {
console.error('Git delete untracked error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

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

@@ -0,0 +1,552 @@
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'], {
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 = {}, scope = 'user', projectPath } = req.body;
console.log(` Adding MCP server using Claude CLI (${scope} scope):`, name);
const { spawn } = await import('child_process');
let cliArgs = ['mcp', 'add'];
// Add scope flag
cliArgs.push('--scope', scope);
if (type === 'http') {
cliArgs.push('--transport', 'http', name, 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, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else {
// stdio (default): claude mcp add --scope user <name> <command> [args...]
cliArgs.push(name);
// Add environment variables
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push(command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
}
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} 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 });
}
});
// POST /api/mcp/cli/add-json - Add MCP server using JSON format
router.post('/cli/add-json', async (req, res) => {
try {
const { name, jsonConfig, scope = 'user', projectPath } = req.body;
console.log(' Adding MCP server using JSON format:', name);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Validate required fields
if (!parsedConfig.type) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'Missing required field: type'
});
}
if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'stdio type requires a command field'
});
}
if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
return res.status(400).json({
error: 'Invalid configuration',
details: `${parsedConfig.type} type requires a url field`
});
}
const { spawn } = await import('child_process');
// Build the command: claude mcp add-json --scope <scope> <name> '<json>'
const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
// Add the JSON config as a properly formatted string
const jsonString = JSON.stringify(parsedConfig);
cliArgs.push(jsonString);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error adding MCP server via JSON:', error);
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
router.delete('/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const { scope } = req.query; // Get scope from query params
// Handle the ID format (remove scope prefix if present)
let actualName = name;
let actualScope = scope;
// If the name includes a scope prefix like "local:test", extract it
if (name.includes(':')) {
const [prefix, serverName] = name.split(':');
actualName = serverName;
actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
}
console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
const { spawn } = await import('child_process');
// Build command args based on scope
let cliArgs = ['mcp', 'remove'];
// Add scope flag if it's local scope
if (actualScope === 'local') {
cliArgs.push('--scope', 'local');
} else if (actualScope === 'user' || !actualScope) {
// User scope is default, but we can be explicit
cliArgs.push('--scope', 'user');
}
cliArgs.push(actualName);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
const process = spawn('claude', cliArgs, {
stdio: ['pipe', 'pipe', 'pipe']
});
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', 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 });
}
});
// GET /api/mcp/config/read - Read MCP servers directly from Claude config files
router.get('/config/read', async (req, res) => {
try {
console.log('📖 Reading MCP servers from Claude config files');
const homeDir = os.homedir();
const configPaths = [
path.join(homeDir, '.claude.json'),
path.join(homeDir, '.claude', 'settings.json')
];
let configData = null;
let configPath = null;
// Try to read from either config file
for (const filepath of configPaths) {
try {
const fileContent = await fs.readFile(filepath, 'utf8');
configData = JSON.parse(fileContent);
configPath = filepath;
console.log(`✅ Found Claude config at: ${filepath}`);
break;
} catch (error) {
// File doesn't exist or is not valid JSON, try next
console.log(` Config not found or invalid at: ${filepath}`);
}
}
if (!configData) {
return res.json({
success: false,
message: 'No Claude configuration file found',
servers: []
});
}
// Extract MCP servers from the config
const servers = [];
// Check for user-scoped MCP servers (at root level)
if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
for (const [name, config] of Object.entries(configData.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio', // Default type
scope: 'user', // User scope - available across all projects
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
// Check for local-scoped MCP servers (project-specific)
const currentProjectPath = process.cwd();
// Check under 'projects' key
if (configData.projects && configData.projects[currentProjectPath]) {
const projectConfig = configData.projects[currentProjectPath];
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
const server = {
id: `local:${name}`, // Prefix with scope for uniqueness
name: name, // Keep original name
type: 'stdio', // Default type
scope: 'local', // Local scope - only for this project
projectPath: currentProjectPath,
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
}
console.log(`📋 Found ${servers.length} MCP servers in config`);
res.json({
success: true,
configPath: configPath,
servers: servers
});
} catch (error) {
console.error('Error reading Claude config:', error);
res.status(500).json({
error: 'Failed to read Claude configuration',
details: error.message
});
}
});
// Helper functions to parse Claude CLI output
function parseClaudeListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
// Skip the header line
if (line.includes('Checking MCP server health')) continue;
// Parse lines like "test: test test - ✗ Failed to connect"
// or "server-name: command or description - ✓ Connected"
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
// Skip empty names
if (!name) continue;
// Extract the rest after the name
const rest = line.substring(colonIndex + 1).trim();
// Try to extract description and status
let description = rest;
let status = 'unknown';
let type = 'stdio'; // default type
// Check for status indicators
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
// Try to determine type from description
if (description.startsWith('http://') || description.startsWith('https://')) {
type = 'http';
}
servers.push({
name,
type,
status: status || 'active',
description
});
}
}
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;

11
src/App.jsx Executable file → Normal file
View File

@@ -64,6 +64,10 @@ function AppContent() {
const saved = localStorage.getItem('autoScrollToBottom');
return saved !== null ? JSON.parse(saved) : true;
});
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
const saved = localStorage.getItem('sendByCtrlEnter');
return saved !== null ? JSON.parse(saved) : false;
});
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
@@ -586,6 +590,7 @@ function AppContent() {
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
</div>
@@ -617,6 +622,11 @@ function AppContent() {
setAutoScrollToBottom(value);
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
}}
sendByCtrlEnter={sendByCtrlEnter}
onSendByCtrlEnterChange={(value) => {
setSendByCtrlEnter(value);
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
}}
isMobile={isMobile}
/>
)}
@@ -625,6 +635,7 @@ function AppContent() {
<ToolsSettings
isOpen={showToolsSettings}
onClose={() => setShowToolsSettings(false)}
projects={projects}
/>
{/* Version Upgrade Modal */}

284
src/components/ChatInterface.jsx Executable file → Normal file
View File

@@ -26,6 +26,88 @@ import ClaudeStatus from './ClaudeStatus';
import { MicButton } from './MicButton.jsx';
import { api } from '../utils/api';
// Safe localStorage utility to handle quota exceeded errors
const safeLocalStorage = {
setItem: (key, value) => {
try {
// For chat messages, implement compression and size limits
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
// Limit to last 50 messages to prevent storage bloat
if (Array.isArray(parsed) && parsed.length > 50) {
console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`);
const truncated = parsed.slice(-50);
value = JSON.stringify(truncated);
}
} catch (parseError) {
console.warn('Could not parse chat messages for truncation:', parseError);
}
}
localStorage.setItem(key, value);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, clearing old data');
// Clear old chat messages to free up space
const keys = Object.keys(localStorage);
const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort();
// Remove oldest chat data first, keeping only the 3 most recent projects
if (chatKeys.length > 3) {
chatKeys.slice(0, chatKeys.length - 3).forEach(k => {
localStorage.removeItem(k);
console.log(`Removed old chat data: ${k}`);
});
}
// If still failing, clear draft inputs too
const draftKeys = keys.filter(k => k.startsWith('draft_input_'));
draftKeys.forEach(k => {
localStorage.removeItem(k);
});
// Try again with reduced data
try {
localStorage.setItem(key, value);
} catch (retryError) {
console.error('Failed to save to localStorage even after cleanup:', retryError);
// Last resort: Try to save just the last 10 messages
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length > 10) {
const minimal = parsed.slice(-10);
localStorage.setItem(key, JSON.stringify(minimal));
console.warn('Saved only last 10 messages due to quota constraints');
}
} catch (finalError) {
console.error('Final save attempt failed:', finalError);
}
}
}
} else {
console.error('localStorage error:', error);
}
}
},
getItem: (key) => {
try {
return localStorage.getItem(key);
} catch (error) {
console.error('localStorage getItem error:', error);
return null;
}
},
removeItem: (key) => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error('localStorage removeItem error:', error);
}
}
};
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
const isGrouped = prevMessage && prevMessage.type === message.type &&
@@ -118,7 +200,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="w-full">
{message.isToolUse ? (
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
@@ -423,41 +505,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
try {
const input = JSON.parse(message.toolInput);
if (input.file_path) {
// Extract filename
const filename = input.file_path.split('/').pop();
const pathParts = input.file_path.split('/');
const directoryPath = pathParts.slice(0, -1).join('/');
// Simple heuristic to show only relevant path parts
// Show the last 2-3 directory parts before the filename
const relevantParts = pathParts.slice(-4, -1); // Get up to 3 directories before filename
const relativePath = relevantParts.length > 0 ? relevantParts.join('/') + '/' : '';
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1">
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-gray-600 dark:text-gray-400 font-mono text-xs">{relativePath}</span>
<span className="font-semibold text-blue-700 dark:text-blue-300 font-mono">{filename}</span>
</summary>
{showRawParameters && (
<div className="mt-3">
<details className="mt-2">
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
View raw parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
{message.toolInput}
</pre>
</details>
</div>
)}
</details>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
Read{' '}
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
{filename}
</button>
</div>
);
}
} catch (e) {
@@ -882,6 +941,61 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
</div>
</div>
) : message.isToolUse && message.toolName === 'Read' ? (
// Simple Read tool indicator
(() => {
try {
const input = JSON.parse(message.toolInput);
if (input.file_path) {
const filename = input.file_path.split('/').pop();
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read{' '}
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
{filename}
</button>
</div>
);
}
} catch (e) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read file
</div>
);
}
})()
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
// Simple TodoWrite tool indicator with tasks
(() => {
try {
const input = JSON.parse(message.toolInput);
if (input.todos && Array.isArray(input.todos)) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
📝 Update todo list
</div>
<TodoList todos={input.todos} />
</div>
);
}
} catch (e) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📝 Update todo list
</div>
);
}
})()
) : message.isToolUse && message.toolName === 'TodoRead' ? (
// Simple TodoRead tool indicator
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📋 Read todo list
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{message.type === 'assistant' ? (
@@ -984,16 +1098,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
//
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) {
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
}
return '';
});
const [chatMessages, setChatMessages] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
const saved = localStorage.getItem(`chat_messages_${selectedProject.name}`);
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
return saved ? JSON.parse(saved) : [];
}
return [];
@@ -1278,16 +1392,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Persist input draft to localStorage
useEffect(() => {
if (selectedProject && input !== '') {
localStorage.setItem(`draft_input_${selectedProject.name}`, input);
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
} else if (selectedProject && input === '') {
localStorage.removeItem(`draft_input_${selectedProject.name}`);
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
}, [input, selectedProject]);
// Persist chat messages to localStorage
useEffect(() => {
if (selectedProject && chatMessages.length > 0) {
localStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
}
}, [chatMessages, selectedProject]);
@@ -1295,7 +1409,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
useEffect(() => {
if (selectedProject) {
// Always load saved input draft for the project
const savedInput = localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
if (savedInput !== input) {
setInput(savedInput);
}
@@ -1400,19 +1514,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
toolResult: null // Will be updated when result comes in
}]);
} else if (part.type === 'text' && part.text?.trim()) {
// Check for usage limit message and format it user-friendly
let content = part.text;
if (content.includes('Claude AI usage limit reached|')) {
const parts = content.split('|');
if (parts.length === 2) {
const timestamp = parseInt(parts[1]);
if (!isNaN(timestamp)) {
const resetTime = new Date(timestamp * 1000);
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
}
}
}
// Add regular text message
setChatMessages(prev => [...prev, {
type: 'assistant',
content: part.text,
content: content,
timestamp: new Date()
}]);
}
}
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
// Check for usage limit message and format it user-friendly
let content = messageData.content;
if (content.includes('Claude AI usage limit reached|')) {
const parts = content.split('|');
if (parts.length === 2) {
const timestamp = parseInt(parts[1]);
if (!isNaN(timestamp)) {
const resetTime = new Date(timestamp * 1000);
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
}
}
}
// Add regular text message
setChatMessages(prev => [...prev, {
type: 'assistant',
content: messageData.content,
content: content,
timestamp: new Date()
}]);
}
@@ -1488,7 +1628,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Clear persisted chat messages after successful completion
if (selectedProject && latestMessage.exitCode === 0) {
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
@@ -1745,14 +1885,33 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle image files from drag & drop or file picker
const handleImageFiles = useCallback((files) => {
const validFiles = files.filter(file => {
if (!file.type.startsWith('image/')) {
try {
// Validate file object and properties
if (!file || typeof file !== 'object') {
console.warn('Invalid file object:', file);
return false;
}
if (!file.type || !file.type.startsWith('image/')) {
return false;
}
if (!file.size || file.size > 5 * 1024 * 1024) {
// Safely get file name with fallback
const fileName = file.name || 'Unknown file';
setImageErrors(prev => {
const newMap = new Map(prev);
newMap.set(fileName, 'File too large (max 5MB)');
return newMap;
});
return false;
}
return true;
} catch (error) {
console.error('Error validating file:', error, file);
return false;
}
if (file.size > 5 * 1024 * 1024) {
setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)'));
return false;
}
return true;
});
if (validFiles.length > 0) {
@@ -1808,7 +1967,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
});
try {
const token = localStorage.getItem('auth-token');
const token = safeLocalStorage.getItem('auth-token');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
@@ -1871,7 +2030,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Get tools settings from localStorage
const getToolsSettings = () => {
try {
const savedSettings = localStorage.getItem('claude-tools-settings');
const savedSettings = safeLocalStorage.getItem('claude-tools-settings');
if (savedSettings) {
return JSON.parse(savedSettings);
}
@@ -1917,7 +2076,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Clear the saved draft since message was sent
if (selectedProject) {
localStorage.removeItem(`draft_input_${selectedProject.name}`);
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
};
@@ -1966,14 +2125,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
if (e.key === 'Enter') {
// If we're in composition, don't send message
if (e.nativeEvent.isComposing) {
return; // Let IME handle the Enter key
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
// Ctrl+Enter or Cmd+Enter: Send message
e.preventDefault();
handleSubmit(e);
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
// Plain Enter: Also send message (keeping original behavior)
e.preventDefault();
handleSubmit(e);
// Plain Enter: Send message only if not in IME composition
if (!sendByCtrlEnter) {
e.preventDefault();
handleSubmit(e);
}
}
// Shift+Enter: Allow default behavior (new line)
}
@@ -2404,12 +2570,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
{/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
Press Enter to send Shift+Enter for new line Tab to change modes @ to reference files
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
</div>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
}`}>
Enter to send Tab for modes @ for files
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
: "Enter to send • Tab for modes • @ for files"}
</div>
</form>
</div>

0
src/components/ClaudeLogo.jsx Executable file → Normal file
View File

0
src/components/ClaudeStatus.jsx Executable file → Normal file
View File

0
src/components/CodeEditor.jsx Executable file → Normal file
View File

0
src/components/DarkModeToggle.jsx Executable file → Normal file
View File

View File

@@ -0,0 +1,73 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error details
console.error('ErrorBoundary caught an error:', error, errorInfo);
// You can also log the error to an error reporting service here
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{this.props.showDetails && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onRetry) this.props.onRetry();
}}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

0
src/components/FileTree.jsx Executable file → Normal file
View File

197
src/components/GitPanel.jsx Executable file → Normal file
View File

@@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) {
const [isFetching, setIsFetching] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
const textareaRef = useRef(null);
@@ -266,6 +267,34 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const handlePublish = async () => {
setIsPublishing(true);
try {
const response = await authenticatedFetch('/api/git/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
branch: currentBranch
})
});
const data = await response.json();
if (data.success) {
// Refresh status after successful publish
fetchGitStatus();
fetchRemoteStatus();
} else {
console.error('Publish failed:', data.error);
// TODO: Show user-friendly error message
}
} catch (error) {
console.error('Error publishing branch:', error);
} finally {
setIsPublishing(false);
}
};
const discardChanges = async (filePath) => {
try {
const response = await authenticatedFetch('/api/git/discard', {
@@ -294,6 +323,34 @@ function GitPanel({ selectedProject, isMobile }) {
}
};
const deleteUntrackedFile = async (filePath) => {
try {
const response = await authenticatedFetch('/api/git/delete-untracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
file: filePath
})
});
const data = await response.json();
if (data.success) {
// Remove from selected files and refresh status
setSelectedFiles(prev => {
const newSet = new Set(prev);
newSet.delete(filePath);
return newSet;
});
fetchGitStatus();
} else {
console.error('Delete failed:', data.error);
}
} catch (error) {
console.error('Error deleting untracked file:', error);
}
};
const confirmAndExecute = async () => {
if (!confirmAction) return;
@@ -305,6 +362,9 @@ function GitPanel({ selectedProject, isMobile }) {
case 'discard':
await discardChanges(file);
break;
case 'delete':
await deleteUntrackedFile(file);
break;
case 'commit':
await handleCommit();
break;
@@ -314,6 +374,9 @@ function GitPanel({ selectedProject, isMobile }) {
case 'push':
await handlePush();
break;
case 'publish':
await handlePublish();
break;
}
} catch (error) {
console.error(`Error executing ${type}:`, error);
@@ -578,6 +641,23 @@ function GitPanel({ selectedProject, isMobile }) {
{isMobile && <span>Discard</span>}
</button>
)}
{status === 'U' && (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'delete',
file: filePath,
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
});
}}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
title="Delete untracked file"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
{isMobile && <span>Delete</span>}
</button>
)}
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
@@ -716,51 +796,72 @@ function GitPanel({ selectedProject, isMobile }) {
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
{/* Remote action buttons - smart logic based on ahead/behind status */}
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
{remoteStatus?.hasRemote && (
<>
{/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && (
{/* Publish button - show when branch doesn't exist on remote */}
{!remoteStatus?.hasUpstream && (
<button
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
type: 'publish',
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
})}
disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
disabled={isPublishing}
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
</button>
)}
{/* Push button - show when ahead (primary action when ahead only) */}
{remoteStatus.ahead > 0 && (
<button
onClick={() => setConfirmAction({
type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
})}
disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button>
)}
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button
onClick={handleFetch}
disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
title={`Fetch from ${remoteStatus.remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
{/* Show normal push/pull buttons only if branch has upstream */}
{remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
<>
{/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && (
<button
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
})}
disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
</button>
)}
{/* Push button - show when ahead (primary action when ahead only) */}
{remoteStatus.ahead > 0 && (
<button
onClick={() => setConfirmAction({
type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
})}
disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button>
)}
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button
onClick={handleFetch}
disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
title={`Fetch from ${remoteStatus.remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
)}
</>
)}
</>
)}
@@ -1120,16 +1221,18 @@ function GitPanel({ selectedProject, isMobile }) {
<div className="p-6">
<div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
}`}>
<AlertTriangle className={`w-5 h-5 ${
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
}`} />
</div>
<h3 className="text-lg font-semibold">
{confirmAction.type === 'discard' ? 'Discard Changes' :
confirmAction.type === 'delete' ? 'Delete File' :
confirmAction.type === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
confirmAction.type === 'pull' ? 'Confirm Pull' :
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
</h3>
</div>
@@ -1147,12 +1250,14 @@ function GitPanel({ selectedProject, isMobile }) {
<button
onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${
confirmAction.type === 'discard'
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
? 'bg-red-600 hover:bg-red-700'
: confirmAction.type === 'commit'
? 'bg-blue-600 hover:bg-blue-700'
: confirmAction.type === 'pull'
? 'bg-green-600 hover:bg-green-700'
: confirmAction.type === 'publish'
? 'bg-purple-600 hover:bg-purple-700'
: 'bg-orange-600 hover:bg-orange-700'
} flex items-center space-x-2`}
>
@@ -1161,6 +1266,11 @@ function GitPanel({ selectedProject, isMobile }) {
<Trash2 className="w-4 h-4" />
<span>Discard</span>
</>
) : confirmAction.type === 'delete' ? (
<>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</>
) : confirmAction.type === 'commit' ? (
<>
<Check className="w-4 h-4" />
@@ -1171,6 +1281,11 @@ function GitPanel({ selectedProject, isMobile }) {
<Download className="w-4 h-4" />
<span>Pull</span>
</>
) : confirmAction.type === 'publish' ? (
<>
<Upload className="w-4 h-4" />
<span>Publish</span>
</>
) : (
<>
<Upload className="w-4 h-4" />

0
src/components/ImageViewer.jsx Executable file → Normal file
View File

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
import { MessageSquare } from 'lucide-react';
const LoginForm = () => {
const [username, setUsername] = useState('');
@@ -37,7 +37,9 @@ const LoginForm = () => {
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
<p className="text-muted-foreground mt-2">

41
src/components/MainContent.jsx Executable file → Normal file
View File

@@ -17,6 +17,7 @@ import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
import Shell from './Shell';
import GitPanel from './GitPanel';
import ErrorBoundary from './ErrorBoundary';
function MainContent({
selectedProject,
@@ -39,7 +40,8 @@ function MainContent({
onShowSettings, // Show tools settings panel
autoExpandTools, // Auto-expand tool accordions
showRawParameters, // Show raw parameters in tool accordions
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
}) {
const [editingFile, setEditingFile] = useState(null);
@@ -269,23 +271,26 @@ function MainContent({
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
</ErrorBoundary>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
<FileTree selectedProject={selectedProject} />

0
src/components/MicButton.jsx Executable file → Normal file
View File

0
src/components/MobileNav.jsx Executable file → Normal file
View File

View File

@@ -2,13 +2,15 @@ import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import ClaudeLogo from './ClaudeLogo';
import { MessageSquare } from 'lucide-react';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">

26
src/components/QuickSettingsPanel.jsx Executable file → Normal file
View File

@@ -11,7 +11,8 @@ import {
Mic,
Brain,
Sparkles,
FileText
FileText,
Languages
} from 'lucide-react';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
onShowRawParametersChange,
autoScrollToBottom,
onAutoScrollChange,
sendByCtrlEnter,
onSendByCtrlEnterChange,
isMobile
}) => {
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
</label>
</div>
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>

0
src/components/Shell.jsx Executable file → Normal file
View File

4
src/components/Sidebar.jsx Executable file → Normal file
View File

@@ -374,9 +374,7 @@ function Sidebar({
try {
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
const response = await fetch(
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
);
const response = await api.sessions(project.name, 5, currentSessionCount);
if (response.ok) {
const result = await response.json();

0
src/components/TodoList.jsx Executable file → Normal file
View File

1104
src/components/ToolsSettings.jsx Executable file → Normal file

File diff suppressed because it is too large Load Diff

0
src/components/ui/badge.jsx Executable file → Normal file
View File

0
src/components/ui/button.jsx Executable file → Normal file
View File

0
src/components/ui/input.jsx Executable file → Normal file
View File

0
src/components/ui/scroll-area.jsx Executable file → Normal file
View File

0
src/contexts/ThemeContext.jsx Executable file → Normal file
View File

12
src/index.css Executable file → Normal file
View File

@@ -559,16 +559,6 @@
transform: inherit !important;
}
/* Preserve backgrounds for containers and modals */
.fixed:hover,
.modal:hover,
.bg-white:hover,
.bg-gray-800:hover,
.bg-gray-900:hover,
[class*="bg-"]:hover {
background-color: revert !important;
}
/* Force buttons to be immediately clickable */
button, [role="button"], .cursor-pointer {
cursor: pointer !important;
@@ -746,4 +736,4 @@
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
}
}
}

0
src/main.jsx Executable file → Normal file
View File

View File

@@ -9,10 +9,14 @@ export default defineConfig(({ command, mode }) => {
return {
plugins: [react()],
server: {
port: parseInt(env.VITE_PORT) || 3001,
port: parseInt(env.VITE_PORT) || 5173,
proxy: {
'/api': `http://localhost:${env.PORT || 3002}`,
'/api': `http://localhost:${env.PORT || 3001}`,
'/ws': {
target: `ws://localhost:${env.PORT || 3001}`,
ws: true
},
'/shell': {
target: `ws://localhost:${env.PORT || 3002}`,
ws: true
}