Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24f5e421f | ||
|
|
6d17e6db81 | ||
|
|
99b204f5bf | ||
|
|
21d9242d50 | ||
|
|
5e4be4d113 | ||
|
|
46d119685d | ||
|
|
12dccbf224 | ||
|
|
2a0e4c58c2 | ||
|
|
a01d6c91e0 | ||
|
|
8774aa4289 | ||
|
|
5ba62a2b7b | ||
|
|
23c50f8fef | ||
|
|
8b40f9f177 | ||
|
|
51f935f6d8 | ||
|
|
f6408c5106 | ||
|
|
41be9a4f63 | ||
|
|
0a9c2484d6 | ||
|
|
2d912eeb64 | ||
|
|
952aeab70a | ||
|
|
95644fd457 | ||
|
|
42a80748af | ||
|
|
7031d9437c | ||
|
|
2ff59bd28c | ||
|
|
7fd63d83ac | ||
|
|
6db8be5f54 | ||
|
|
32481356cb | ||
|
|
3e0c23b7e2 | ||
|
|
22fa724503 | ||
|
|
67339b0e4b | ||
|
|
4fcf27bd13 | ||
|
|
4de2f5026e | ||
|
|
9cfccc04f3 | ||
|
|
7f4feb182e | ||
|
|
d36890be52 | ||
|
|
c925742df1 | ||
|
|
33aea3f7e8 | ||
|
|
f28dc0140e | ||
|
|
2ca929e5e5 | ||
|
|
36d0add224 | ||
|
|
7db22fae29 | ||
|
|
b808ca1b68 | ||
|
|
62ad40ad71 | ||
|
|
a8f212bff8 | ||
|
|
ba077fdf62 | ||
|
|
6170f97216 | ||
|
|
9cf0173bc9 | ||
|
|
6c64ea7552 | ||
|
|
23f5fc357b | ||
|
|
c0d8241f3d | ||
|
|
23e5f7ac2d |
@@ -7,6 +7,6 @@
|
||||
|
||||
# Backend server port (Express API + WebSocket server)
|
||||
#API server
|
||||
PORT=3008
|
||||
PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=3009
|
||||
VITE_PORT=5173
|
||||
0
index.html
Executable file → Normal file
1624
package-lock.json
generated
Executable file → Normal file
8
package.json
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.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
0
public/favicon.png
Executable file → Normal file
|
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 281 B |
0
public/favicon.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 504 B |
0
public/icons/claude-ai-icon.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
0
public/icons/generate-icons.md
Executable file → Normal file
0
public/icons/icon-128x128.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-128x128.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-144x144.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-144x144.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-152x152.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-152x152.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-192x192.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-192x192.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-384x384.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-384x384.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-512x512.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-512x512.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-72x72.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-72x72.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-96x96.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
0
public/icons/icon-96x96.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/icons/icon-template.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 578 B |
0
public/logo.svg
Executable file → Normal file
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 422 B |
0
public/manifest.json
Executable file → Normal file
0
public/screenshots/desktop-main.png
Executable file → Normal file
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 385 KiB |
0
public/screenshots/mobile-chat.png
Executable file → Normal file
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
0
public/screenshots/tools-modal.png
Executable file → Normal file
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 295 KiB |
@@ -1,8 +1,12 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
@@ -23,7 +27,11 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
|
||||
// Add print flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
args.push('--print', command);
|
||||
|
||||
// Separate arguments for better cross-platform compatibility
|
||||
// This prevents issues with spaces and quotes on Windows
|
||||
args.push('--print');
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
@@ -63,9 +71,9 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
const modifiedCommand = command + imageNote;
|
||||
|
||||
// Update the command in args
|
||||
// Update the command in args - now that --print and command are separate
|
||||
const printIndex = args.indexOf('--print');
|
||||
if (printIndex !== -1 && args[printIndex + 1] === command) {
|
||||
if (printIndex !== -1 && printIndex + 1 < args.length && args[printIndex + 1] === command) {
|
||||
args[printIndex + 1] = modifiedCommand;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
1624
server/index.js
@@ -53,13 +53,8 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
|
||||
// If it starts with /, it's an absolute path
|
||||
if (projectPath.startsWith('/')) {
|
||||
const parts = projectPath.split('/').filter(Boolean);
|
||||
if (parts.length > 3) {
|
||||
// Show last 2 folders with ellipsis: "...projects/myapp"
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
} else {
|
||||
// Show full path if short: "/home/user"
|
||||
return projectPath;
|
||||
}
|
||||
// Return only the last folder name
|
||||
return parts[parts.length - 1] || projectPath;
|
||||
}
|
||||
|
||||
return projectPath;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { userDb, db } from '../database/db.js';
|
||||
import { generateToken, authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -33,31 +33,41 @@ router.post('/register', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
|
||||
}
|
||||
|
||||
// Check if users already exist (only allow one user)
|
||||
const hasUsers = userDb.hasUsers();
|
||||
if (hasUsers) {
|
||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||
// Use a transaction to prevent race conditions
|
||||
db.prepare('BEGIN').run();
|
||||
try {
|
||||
// Check if users already exist (only allow one user)
|
||||
const hasUsers = userDb.hasUsers();
|
||||
if (hasUsers) {
|
||||
db.prepare('ROLLBACK').run();
|
||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = userDb.createUser(username, passwordHash);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
db.prepare('ROLLBACK').run();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = userDb.createUser(username, passwordHash);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
token
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
|
||||
@@ -444,10 +444,25 @@ router.get('/remote-status', async (req, res) => {
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
// No upstream branch configured
|
||||
// No upstream branch configured - but check if we have remotes
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
}
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
hasRemote: false,
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName,
|
||||
message: 'No remote tracking branch configured'
|
||||
});
|
||||
}
|
||||
@@ -462,6 +477,7 @@ router.get('/remote-status', async (req, res) => {
|
||||
|
||||
res.json({
|
||||
hasRemote: true,
|
||||
hasUpstream: true,
|
||||
branch,
|
||||
remoteBranch: trackingBranch,
|
||||
remoteName,
|
||||
@@ -653,6 +669,82 @@ router.post('/push', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Publish branch to remote (set upstream and push)
|
||||
router.post('/publish', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch to verify it matches the requested branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if remote exists
|
||||
let remoteName = 'origin';
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the branch (set upstream and push)
|
||||
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Branch published successfully',
|
||||
remoteName,
|
||||
branch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git publish error:', error);
|
||||
|
||||
// Enhanced error handling for common publish scenarios
|
||||
let errorMessage = 'Publish failed';
|
||||
let details = error.message;
|
||||
|
||||
if (error.message.includes('rejected')) {
|
||||
errorMessage = 'Publish rejected';
|
||||
details = 'The remote branch already exists and has different commits. Use push instead.';
|
||||
} else if (error.message.includes('Could not resolve hostname')) {
|
||||
errorMessage = 'Network error';
|
||||
details = 'Unable to connect to remote repository. Check your internet connection.';
|
||||
} else if (error.message.includes('Permission denied')) {
|
||||
errorMessage = 'Authentication failed';
|
||||
details = 'Permission denied. Check your credentials or SSH keys.';
|
||||
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
|
||||
errorMessage = 'Remote not configured';
|
||||
details = 'Remote repository not properly configured. Check your remote URL.';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: errorMessage,
|
||||
details: details
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Discard changes for a specific file
|
||||
router.post('/discard', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
@@ -692,4 +784,39 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete untracked file
|
||||
router.post('/delete-untracked', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if file is actually untracked
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
}
|
||||
|
||||
const status = statusOutput.substring(0, 2);
|
||||
|
||||
if (status !== '??') {
|
||||
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
||||
}
|
||||
|
||||
// Delete the untracked file
|
||||
await fs.unlink(path.join(projectPath, file));
|
||||
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
} catch (error) {
|
||||
console.error('Git delete untracked error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
552
server/routes/mcp.js
Normal 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
@@ -64,6 +64,10 @@ function AppContent() {
|
||||
const saved = localStorage.getItem('autoScrollToBottom');
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
|
||||
const saved = localStorage.getItem('sendByCtrlEnter');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
// Session Protection System: Track sessions with active conversations to prevent
|
||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||
// a message, the session is marked as "active" and project updates are paused
|
||||
@@ -586,6 +590,7 @@ function AppContent() {
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -617,6 +622,11 @@ function AppContent() {
|
||||
setAutoScrollToBottom(value);
|
||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
||||
}}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onSendByCtrlEnterChange={(value) => {
|
||||
setSendByCtrlEnter(value);
|
||||
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
@@ -625,6 +635,7 @@ function AppContent() {
|
||||
<ToolsSettings
|
||||
isOpen={showToolsSettings}
|
||||
onClose={() => setShowToolsSettings(false)}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{/* Version Upgrade Modal */}
|
||||
|
||||
284
src/components/ChatInterface.jsx
Executable file → Normal file
@@ -26,6 +26,88 @@ import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// Safe localStorage utility to handle quota exceeded errors
|
||||
const safeLocalStorage = {
|
||||
setItem: (key, value) => {
|
||||
try {
|
||||
// For chat messages, implement compression and size limits
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
// Limit to last 50 messages to prevent storage bloat
|
||||
if (Array.isArray(parsed) && parsed.length > 50) {
|
||||
console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`);
|
||||
const truncated = parsed.slice(-50);
|
||||
value = JSON.stringify(truncated);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse chat messages for truncation:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
// Clear old chat messages to free up space
|
||||
const keys = Object.keys(localStorage);
|
||||
const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort();
|
||||
|
||||
// Remove oldest chat data first, keeping only the 3 most recent projects
|
||||
if (chatKeys.length > 3) {
|
||||
chatKeys.slice(0, chatKeys.length - 3).forEach(k => {
|
||||
localStorage.removeItem(k);
|
||||
console.log(`Removed old chat data: ${k}`);
|
||||
});
|
||||
}
|
||||
|
||||
// If still failing, clear draft inputs too
|
||||
const draftKeys = keys.filter(k => k.startsWith('draft_input_'));
|
||||
draftKeys.forEach(k => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
// Try again with reduced data
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||||
// Last resort: Try to save just the last 10 messages
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 10) {
|
||||
const minimal = parsed.slice(-10);
|
||||
localStorage.setItem(key, JSON.stringify(minimal));
|
||||
console.warn('Saved only last 10 messages due to quota constraints');
|
||||
}
|
||||
} catch (finalError) {
|
||||
console.error('Final save attempt failed:', finalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('localStorage error:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
getItem: (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage getItem error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
removeItem: (key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage removeItem error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
@@ -118,7 +200,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
{message.isToolUse ? (
|
||||
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -423,41 +505,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
// Extract filename
|
||||
const filename = input.file_path.split('/').pop();
|
||||
const pathParts = input.file_path.split('/');
|
||||
const directoryPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
// Simple heuristic to show only relevant path parts
|
||||
// Show the last 2-3 directory parts before the filename
|
||||
const relevantParts = pathParts.slice(-4, -1); // Get up to 3 directories before filename
|
||||
const relativePath = relevantParts.length > 0 ? relevantParts.join('/') + '/' : '';
|
||||
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-gray-600 dark:text-gray-400 font-mono text-xs">{relativePath}</span>
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-300 font-mono">{filename}</span>
|
||||
</summary>
|
||||
{showRawParameters && (
|
||||
<div className="mt-3">
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -882,6 +941,61 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : message.isToolUse && message.toolName === 'Read' ? (
|
||||
// Simple Read tool indicator
|
||||
(() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
const filename = input.file_path.split('/').pop();
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read file
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
|
||||
// Simple TodoWrite tool indicator with tasks
|
||||
(() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.todos && Array.isArray(input.todos)) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||||
📝 Update todo list
|
||||
</div>
|
||||
<TodoList todos={input.todos} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📝 Update todo list
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : message.isToolUse && message.toolName === 'TodoRead' ? (
|
||||
// Simple TodoRead tool indicator
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📋 Read todo list
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{message.type === 'assistant' ? (
|
||||
@@ -984,16 +1098,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [chatMessages, setChatMessages] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
const saved = localStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||||
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
}
|
||||
return [];
|
||||
@@ -1278,16 +1392,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Persist input draft to localStorage
|
||||
useEffect(() => {
|
||||
if (selectedProject && input !== '') {
|
||||
localStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
} else if (selectedProject && input === '') {
|
||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
}, [input, selectedProject]);
|
||||
|
||||
// Persist chat messages to localStorage
|
||||
useEffect(() => {
|
||||
if (selectedProject && chatMessages.length > 0) {
|
||||
localStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||||
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||||
}
|
||||
}, [chatMessages, selectedProject]);
|
||||
|
||||
@@ -1295,7 +1409,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
// Always load saved input draft for the project
|
||||
const savedInput = localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
if (savedInput !== input) {
|
||||
setInput(savedInput);
|
||||
}
|
||||
@@ -1400,19 +1514,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
toolResult: null // Will be updated when result comes in
|
||||
}]);
|
||||
} else if (part.type === 'text' && part.text?.trim()) {
|
||||
// Check for usage limit message and format it user-friendly
|
||||
let content = part.text;
|
||||
if (content.includes('Claude AI usage limit reached|')) {
|
||||
const parts = content.split('|');
|
||||
if (parts.length === 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
if (!isNaN(timestamp)) {
|
||||
const resetTime = new Date(timestamp * 1000);
|
||||
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: part.text,
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
}
|
||||
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
||||
// Check for usage limit message and format it user-friendly
|
||||
let content = messageData.content;
|
||||
if (content.includes('Claude AI usage limit reached|')) {
|
||||
const parts = content.split('|');
|
||||
if (parts.length === 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
if (!isNaN(timestamp)) {
|
||||
const resetTime = new Date(timestamp * 1000);
|
||||
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: messageData.content,
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
@@ -1488,7 +1628,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Clear persisted chat messages after successful completion
|
||||
if (selectedProject && latestMessage.exitCode === 0) {
|
||||
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1745,14 +1885,33 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Handle image files from drag & drop or file picker
|
||||
const handleImageFiles = useCallback((files) => {
|
||||
const validFiles = files.filter(file => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
try {
|
||||
// Validate file object and properties
|
||||
if (!file || typeof file !== 'object') {
|
||||
console.warn('Invalid file object:', file);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.type || !file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.size || file.size > 5 * 1024 * 1024) {
|
||||
// Safely get file name with fallback
|
||||
const fileName = file.name || 'Unknown file';
|
||||
setImageErrors(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(fileName, 'File too large (max 5MB)');
|
||||
return newMap;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error validating file:', error, file);
|
||||
return false;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
@@ -1808,7 +1967,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
});
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const token = safeLocalStorage.getItem('auth-token');
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
@@ -1871,7 +2030,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Get tools settings from localStorage
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||
const savedSettings = safeLocalStorage.getItem('claude-tools-settings');
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
}
|
||||
@@ -1917,7 +2076,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Clear the saved draft since message was sent
|
||||
if (selectedProject) {
|
||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1966,14 +2125,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
||||
if (e.key === 'Enter') {
|
||||
// If we're in composition, don't send message
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return; // Let IME handle the Enter key
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
// Ctrl+Enter or Cmd+Enter: Send message
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
// Plain Enter: Also send message (keeping original behavior)
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
// Plain Enter: Send message only if not in IME composition
|
||||
if (!sendByCtrlEnter) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}
|
||||
// Shift+Enter: Allow default behavior (new line)
|
||||
}
|
||||
@@ -2404,12 +2570,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
{/* Hint text */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||||
Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
|
||||
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
|
||||
</div>
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
Enter to send • Tab for modes • @ for files
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
|
||||
: "Enter to send • Tab for modes • @ for files"}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
0
src/components/ClaudeLogo.jsx
Executable file → Normal file
0
src/components/ClaudeStatus.jsx
Executable file → Normal file
0
src/components/CodeEditor.jsx
Executable file → Normal file
0
src/components/DarkModeToggle.jsx
Executable file → Normal file
73
src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log the error details
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// You can also log the error to an error reporting service here
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Fallback UI
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="ml-3 text-sm font-medium text-red-800">
|
||||
Something went wrong
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="mb-2">An error occurred while loading the chat interface.</p>
|
||||
{this.props.showDetails && this.state.error && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
|
||||
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
if (this.props.onRetry) this.props.onRetry();
|
||||
}}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
0
src/components/FileTree.jsx
Executable file → Normal file
197
src/components/GitPanel.jsx
Executable file → Normal file
@@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
|
||||
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
|
||||
const textareaRef = useRef(null);
|
||||
@@ -266,6 +267,34 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
branch: currentBranch
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Refresh status after successful publish
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Publish failed:', data.error);
|
||||
// TODO: Show user-friendly error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error publishing branch:', error);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const discardChanges = async (filePath) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/discard', {
|
||||
@@ -294,6 +323,34 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUntrackedFile = async (filePath) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/delete-untracked', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
file: filePath
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Remove from selected files and refresh status
|
||||
setSelectedFiles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(filePath);
|
||||
return newSet;
|
||||
});
|
||||
fetchGitStatus();
|
||||
} else {
|
||||
console.error('Delete failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting untracked file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAndExecute = async () => {
|
||||
if (!confirmAction) return;
|
||||
|
||||
@@ -305,6 +362,9 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
case 'discard':
|
||||
await discardChanges(file);
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteUntrackedFile(file);
|
||||
break;
|
||||
case 'commit':
|
||||
await handleCommit();
|
||||
break;
|
||||
@@ -314,6 +374,9 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
case 'push':
|
||||
await handlePush();
|
||||
break;
|
||||
case 'publish':
|
||||
await handlePublish();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${type}:`, error);
|
||||
@@ -578,6 +641,23 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
{isMobile && <span>Discard</span>}
|
||||
</button>
|
||||
)}
|
||||
{status === 'U' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
file: filePath,
|
||||
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
|
||||
});
|
||||
}}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
|
||||
title="Delete untracked file"
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
|
||||
{isMobile && <span>Delete</span>}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||
@@ -716,51 +796,72 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
{/* Remote action buttons - smart logic based on ahead/behind status */}
|
||||
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
|
||||
{remoteStatus?.hasRemote && (
|
||||
<>
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
{/* Publish button - show when branch doesn't exist on remote */}
|
||||
{!remoteStatus?.hasUpstream && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'pull',
|
||||
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||
type: 'publish',
|
||||
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPulling}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
|
||||
disabled={isPublishing}
|
||||
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPushing}
|
||||
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={isFetching}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||
</button>
|
||||
{/* Show normal push/pull buttons only if branch has upstream */}
|
||||
{remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
|
||||
<>
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'pull',
|
||||
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPulling}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPushing}
|
||||
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={isFetching}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1120,16 +1221,18 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`p-2 rounded-full mr-3 ${
|
||||
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-5 h-5 ${
|
||||
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{confirmAction.type === 'discard' ? 'Discard Changes' :
|
||||
confirmAction.type === 'delete' ? 'Delete File' :
|
||||
confirmAction.type === 'commit' ? 'Confirm Commit' :
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' :
|
||||
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1147,12 +1250,14 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<button
|
||||
onClick={confirmAndExecute}
|
||||
className={`px-4 py-2 text-sm text-white rounded-md ${
|
||||
confirmAction.type === 'discard'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: confirmAction.type === 'commit'
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: confirmAction.type === 'pull'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: confirmAction.type === 'publish'
|
||||
? 'bg-purple-600 hover:bg-purple-700'
|
||||
: 'bg-orange-600 hover:bg-orange-700'
|
||||
} flex items-center space-x-2`}
|
||||
>
|
||||
@@ -1161,6 +1266,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Discard</span>
|
||||
</>
|
||||
) : confirmAction.type === 'delete' ? (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</>
|
||||
) : confirmAction.type === 'commit' ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
@@ -1171,6 +1281,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Pull</span>
|
||||
</>
|
||||
) : confirmAction.type === 'publish' ? (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Publish</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
|
||||
0
src/components/ImageViewer.jsx
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -37,7 +37,9 @@ const LoginForm = () => {
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
||||
41
src/components/MainContent.jsx
Executable file → Normal file
@@ -17,6 +17,7 @@ import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import Shell from './Shell';
|
||||
import GitPanel from './GitPanel';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
@@ -39,7 +40,8 @@ function MainContent({
|
||||
onShowSettings, // Show tools settings panel
|
||||
autoExpandTools, // Auto-expand tool accordions
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
|
||||
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
|
||||
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
|
||||
@@ -269,23 +271,26 @@ function MainContent({
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
/>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
|
||||
0
src/components/MicButton.jsx
Executable file → Normal file
0
src/components/MobileNav.jsx
Executable file → Normal file
@@ -2,13 +2,15 @@ import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
|
||||
26
src/components/QuickSettingsPanel.jsx
Executable file → Normal file
@@ -11,7 +11,8 @@ import {
|
||||
Mic,
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText
|
||||
FileText,
|
||||
Languages
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
|
||||
onShowRawParametersChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
sendByCtrlEnter,
|
||||
onSendByCtrlEnterChange,
|
||||
isMobile
|
||||
}) => {
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Input Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Send by Ctrl+Enter
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendByCtrlEnter}
|
||||
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
|
||||
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper Dictation Settings - HIDDEN */}
|
||||
<div className="space-y-2" style={{ display: 'none' }}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
|
||||
|
||||
0
src/components/Shell.jsx
Executable file → Normal file
4
src/components/Sidebar.jsx
Executable file → Normal file
@@ -374,9 +374,7 @@ function Sidebar({
|
||||
|
||||
try {
|
||||
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
|
||||
const response = await fetch(
|
||||
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
|
||||
);
|
||||
const response = await api.sessions(project.name, 5, currentSessionCount);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
0
src/components/TodoList.jsx
Executable file → Normal file
1104
src/components/ToolsSettings.jsx
Executable file → Normal file
0
src/components/ui/badge.jsx
Executable file → Normal file
0
src/components/ui/button.jsx
Executable file → Normal file
0
src/components/ui/input.jsx
Executable file → Normal file
0
src/components/ui/scroll-area.jsx
Executable file → Normal file
0
src/contexts/ThemeContext.jsx
Executable file → Normal file
12
src/index.css
Executable file → Normal file
@@ -559,16 +559,6 @@
|
||||
transform: inherit !important;
|
||||
}
|
||||
|
||||
/* Preserve backgrounds for containers and modals */
|
||||
.fixed:hover,
|
||||
.modal:hover,
|
||||
.bg-white:hover,
|
||||
.bg-gray-800:hover,
|
||||
.bg-gray-900:hover,
|
||||
[class*="bg-"]:hover {
|
||||
background-color: revert !important;
|
||||
}
|
||||
|
||||
/* Force buttons to be immediately clickable */
|
||||
button, [role="button"], .cursor-pointer {
|
||||
cursor: pointer !important;
|
||||
@@ -746,4 +736,4 @@
|
||||
background-color: rgb(31 41 55) !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
0
src/main.jsx
Executable file → Normal file
@@ -9,10 +9,14 @@ export default defineConfig(({ command, mode }) => {
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: parseInt(env.VITE_PORT) || 3001,
|
||||
port: parseInt(env.VITE_PORT) || 5173,
|
||||
proxy: {
|
||||
'/api': `http://localhost:${env.PORT || 3002}`,
|
||||
'/api': `http://localhost:${env.PORT || 3001}`,
|
||||
'/ws': {
|
||||
target: `ws://localhost:${env.PORT || 3001}`,
|
||||
ws: true
|
||||
},
|
||||
'/shell': {
|
||||
target: `ws://localhost:${env.PORT || 3002}`,
|
||||
ws: true
|
||||
}
|
||||
|
||||