mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 21:49:37 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
046f270a11 | ||
|
|
7feeebc2ae | ||
|
|
ad0bcba117 | ||
|
|
a56e06385d | ||
|
|
5ec51dacc3 | ||
|
|
a79028a124 | ||
|
|
2435d12a0b | ||
|
|
54d5583bc2 | ||
|
|
ce1e6c73b3 | ||
|
|
c6c11c236c | ||
|
|
211a3c4557 | ||
|
|
02a296739d | ||
|
|
1f3fe2df3d | ||
|
|
122b757fa2 | ||
|
|
45b3e54d04 | ||
|
|
4762a2d719 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -90,6 +90,7 @@ jspm_packages/
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
.tmp/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
|
||||
@@ -44,7 +44,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) v16 or higher
|
||||
- [Node.js](https://nodejs.org/) v20 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||
|
||||
### Installation
|
||||
@@ -234,4 +234,4 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
|
||||
<div align="center">
|
||||
<strong>Made with care for the Claude Code community</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.24",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -44,10 +44,12 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnClaude(command, options = {}, ws) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode } = options;
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
|
||||
@@ -14,7 +17,7 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
|
||||
// Build Claude CLI command - start with print/resume flags first
|
||||
const args = [];
|
||||
|
||||
@@ -23,6 +26,56 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
args.push('--print', command);
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
const workingDir = cwd || process.cwd();
|
||||
|
||||
// Handle images by saving them to temporary files and passing paths to Claude
|
||||
const tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
if (images && images.length > 0) {
|
||||
try {
|
||||
// Create temp directory in the project directory so Claude can access it
|
||||
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Save each image to a temp file
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Extract base64 data and mime type
|
||||
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
console.error('Invalid image data format');
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = matches;
|
||||
const extension = mimeType.split('/')[1] || 'png';
|
||||
const filename = `image_${index}.${extension}`;
|
||||
const filepath = path.join(tempDir, filename);
|
||||
|
||||
// Write base64 data to file
|
||||
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
||||
tempImagePaths.push(filepath);
|
||||
}
|
||||
|
||||
// Include the full image paths in the prompt for Claude to reference
|
||||
// Only modify the command if we actually have images and a command
|
||||
if (tempImagePaths.length > 0 && command && command.trim()) {
|
||||
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
|
||||
const printIndex = args.indexOf('--print');
|
||||
if (printIndex !== -1 && args[printIndex + 1] === command) {
|
||||
args[printIndex + 1] = modifiedCommand;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing images for Claude:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add resume flag if resuming
|
||||
if (resume && sessionId) {
|
||||
args.push('--resume', sessionId);
|
||||
@@ -87,15 +140,14 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
const workingDir = cwd || process.cwd();
|
||||
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
||||
}).join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||
console.log('🔍 Full command args:', args);
|
||||
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, {
|
||||
cwd: workingDir,
|
||||
@@ -103,6 +155,10 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
// Attach temp file info to process for cleanup later
|
||||
claudeProcess.tempImagePaths = tempImagePaths;
|
||||
claudeProcess.tempDir = tempDir;
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||
activeClaudeProcesses.set(processKey, claudeProcess);
|
||||
@@ -166,7 +222,7 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
claudeProcess.on('close', (code) => {
|
||||
claudeProcess.on('close', async (code) => {
|
||||
console.log(`Claude CLI process exited with code ${code}`);
|
||||
|
||||
// Clean up process reference
|
||||
@@ -179,6 +235,20 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
}));
|
||||
|
||||
// Clean up temporary image files if any
|
||||
if (claudeProcess.tempImagePaths && claudeProcess.tempImagePaths.length > 0) {
|
||||
for (const imagePath of claudeProcess.tempImagePaths) {
|
||||
await fs.unlink(imagePath).catch(err =>
|
||||
console.error(`Failed to delete temp image ${imagePath}:`, err)
|
||||
);
|
||||
}
|
||||
if (claudeProcess.tempDir) {
|
||||
await fs.rm(claudeProcess.tempDir, { recursive: true, force: true }).catch(err =>
|
||||
console.error(`Failed to delete temp directory ${claudeProcess.tempDir}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
|
||||
116
server/index.js
116
server/index.js
@@ -814,12 +814,104 @@ Agent instructions:`;
|
||||
}
|
||||
});
|
||||
|
||||
// Image upload endpoint
|
||||
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const multer = (await import('multer')).default;
|
||||
const path = (await import('path')).default;
|
||||
const fs = (await import('fs')).promises;
|
||||
const os = (await import('os')).default;
|
||||
|
||||
// Configure multer for image uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, uniqueSuffix + '-' + sanitizedName);
|
||||
}
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
if (allowedMimes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
files: 5
|
||||
}
|
||||
});
|
||||
|
||||
// Handle multipart form data
|
||||
upload.array('images', 5)(req, res, async (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No image files provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Process uploaded images
|
||||
const processedImages = await Promise.all(
|
||||
req.files.map(async (file) => {
|
||||
// Read file and convert to base64
|
||||
const buffer = await fs.readFile(file.path);
|
||||
const base64 = buffer.toString('base64');
|
||||
const mimeType = file.mimetype;
|
||||
|
||||
// Clean up temp file immediately
|
||||
await fs.unlink(file.path);
|
||||
|
||||
return {
|
||||
name: file.originalname,
|
||||
data: `data:${mimeType};base64,${base64}`,
|
||||
size: file.size,
|
||||
mimeType: mimeType
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ images: processedImages });
|
||||
} catch (error) {
|
||||
console.error('Error processing images:', error);
|
||||
// Clean up any remaining files
|
||||
await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => {})));
|
||||
res.status(500).json({ error: 'Failed to process images' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in image upload endpoint:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve React app for all other routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
});
|
||||
|
||||
// Helper function to convert permissions to rwx format
|
||||
function permToRwx(perm) {
|
||||
const r = perm & 4 ? 'r' : '-';
|
||||
const w = perm & 2 ? 'w' : '-';
|
||||
const x = perm & 1 ? 'x' : '-';
|
||||
return r + w + x;
|
||||
}
|
||||
|
||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||
// Using fsPromises from import
|
||||
const items = [];
|
||||
@@ -836,12 +928,34 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
entry.name === 'dist' ||
|
||||
entry.name === 'build') continue;
|
||||
|
||||
const itemPath = path.join(dirPath, entry.name);
|
||||
const item = {
|
||||
name: entry.name,
|
||||
path: path.join(dirPath, entry.name),
|
||||
path: itemPath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
// Get file stats for additional metadata
|
||||
try {
|
||||
const stats = await fsPromises.stat(itemPath);
|
||||
item.size = stats.size;
|
||||
item.modified = stats.mtime.toISOString();
|
||||
|
||||
// Convert permissions to rwx format
|
||||
const mode = stats.mode;
|
||||
const ownerPerm = (mode >> 6) & 7;
|
||||
const groupPerm = (mode >> 3) & 7;
|
||||
const otherPerm = mode & 7;
|
||||
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
||||
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
||||
} catch (statError) {
|
||||
// If stat fails, provide default values
|
||||
item.size = 0;
|
||||
item.modified = null;
|
||||
item.permissions = '000';
|
||||
item.permissionsRwx = '---------';
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||
// Recursively get subdirectories but limit depth
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import TodoList from './TodoList';
|
||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||
|
||||
@@ -72,6 +73,19 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{message.images.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img.data}
|
||||
alt={img.name}
|
||||
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => window.open(img.data, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-blue-100 mt-1 text-right">
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
@@ -875,6 +889,43 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
);
|
||||
});
|
||||
|
||||
// ImageAttachment component for displaying image previews
|
||||
const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
const [preview, setPreview] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
|
||||
{uploadProgress !== undefined && uploadProgress < 100 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="text-white text-xs">{uploadProgress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ChatInterface: Main chat component with Session Protection System integration
|
||||
//
|
||||
// Session Protection System prevents automatic project updates from interrupting active conversations:
|
||||
@@ -904,6 +955,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||
const [permissionMode, setPermissionMode] = useState('default');
|
||||
const [attachedImages, setAttachedImages] = useState([]);
|
||||
const [uploadingImages, setUploadingImages] = useState(new Map());
|
||||
const [imageErrors, setImageErrors] = useState(new Map());
|
||||
const messagesEndRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
@@ -1380,6 +1434,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
setCurrentSessionId(pendingSessionId);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
|
||||
// Clear persisted chat messages after successful completion
|
||||
if (selectedProject && latestMessage.exitCode === 0) {
|
||||
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'session-aborted':
|
||||
@@ -1597,6 +1656,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Reset textarea height when input is cleared programmatically
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && !input.trim()) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
setIsTextareaExpanded(false);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleTranscript = useCallback((text) => {
|
||||
if (text.trim()) {
|
||||
setInput(prevInput => {
|
||||
@@ -1620,13 +1687,105 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
// Handle image files from drag & drop or file picker
|
||||
const handleImageFiles = useCallback((files) => {
|
||||
const validFiles = files.filter(file => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
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) {
|
||||
setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle clipboard paste for images
|
||||
const handlePaste = useCallback(async (e) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
handleImageFiles([file]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for some browsers/platforms
|
||||
if (items.length === 0 && e.clipboardData.files.length > 0) {
|
||||
const files = Array.from(e.clipboardData.files);
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0) {
|
||||
handleImageFiles(imageFiles);
|
||||
}
|
||||
}
|
||||
}, [handleImageFiles]);
|
||||
|
||||
// Setup dropzone
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']
|
||||
},
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 5,
|
||||
onDrop: handleImageFiles,
|
||||
noClick: true, // We'll use our own button
|
||||
noKeyboard: true
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading || !selectedProject) return;
|
||||
|
||||
// Upload images first if any
|
||||
let uploadedImages = [];
|
||||
if (attachedImages.length > 0) {
|
||||
const formData = new FormData();
|
||||
attachedImages.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload images');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedImages = result.images;
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
content: `Failed to upload images: ${error.message}`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
type: 'user',
|
||||
content: input,
|
||||
images: uploadedImages,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
@@ -1673,7 +1832,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
|
||||
// Send command to Claude CLI via WebSocket
|
||||
// Send command to Claude CLI via WebSocket with images
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
command: input,
|
||||
@@ -1683,12 +1842,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
sessionId: currentSessionId,
|
||||
resume: !!currentSessionId,
|
||||
toolsSettings: toolsSettings,
|
||||
permissionMode: permissionMode
|
||||
permissionMode: permissionMode,
|
||||
images: uploadedImages // Pass images to backend
|
||||
}
|
||||
});
|
||||
|
||||
setInput('');
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
setIsTextareaExpanded(false);
|
||||
|
||||
// Reset textarea height
|
||||
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
// Clear the saved draft since message was sent
|
||||
if (selectedProject) {
|
||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
@@ -1776,8 +1947,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setInput(e.target.value);
|
||||
const newValue = e.target.value;
|
||||
setInput(newValue);
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
|
||||
// Handle height reset when input becomes empty
|
||||
if (!newValue.trim()) {
|
||||
e.target.style.height = 'auto';
|
||||
setIsTextareaExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextareaClick = (e) => {
|
||||
@@ -1971,13 +2149,46 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
||||
<div className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||
{/* Drag overlay */}
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
||||
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium">Drop images here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image attachments preview */}
|
||||
{attachedImages.length > 0 && (
|
||||
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedImages.map((file, index) => (
|
||||
<ImageAttachment
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => {
|
||||
setAttachedImages(prev => prev.filter((_, i) => i !== index));
|
||||
}}
|
||||
uploadProgress={uploadingImages.get(file.name)}
|
||||
error={imageErrors.get(file.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||
<input {...getInputProps()} />
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onClick={handleTextareaClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onInput={(e) => {
|
||||
@@ -1994,7 +2205,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="chat-input-placeholder w-full px-4 sm:px-6 py-3 sm:py-4 pr-28 sm:pr-40 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
||||
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
{/* Clear button - shown when there's text */}
|
||||
@@ -2039,6 +2250,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Image upload button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
className="absolute left-2 bottom-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Attach images"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mic button - HIDDEN */}
|
||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||
<MicButton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
@@ -13,6 +13,7 @@ function FileTree({ selectedProject }) {
|
||||
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
@@ -20,6 +21,14 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
// Load view mode preference from localStorage
|
||||
useEffect(() => {
|
||||
const savedViewMode = localStorage.getItem('file-tree-view-mode');
|
||||
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
|
||||
setViewMode(savedViewMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -52,6 +61,35 @@ function FileTree({ selectedProject }) {
|
||||
setExpandedDirs(newExpanded);
|
||||
};
|
||||
|
||||
// Change view mode and save preference
|
||||
const changeViewMode = (mode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('file-tree-view-mode', mode);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Format date as relative time
|
||||
const formatRelativeTime = (date) => {
|
||||
if (!date) return '-';
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInSeconds = Math.floor((now - past) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
|
||||
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
|
||||
return past.toLocaleDateString();
|
||||
};
|
||||
|
||||
const renderFileTree = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
@@ -135,6 +173,129 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Render detailed view with table-like layout
|
||||
const renderDetailedView = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-12 gap-2 p-2 hover:bg-accent cursor-pointer items-center",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-2 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : '-'}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(item.modified)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">
|
||||
{item.permissionsRwx || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
renderDetailedView(item.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// Render compact view with inline details
|
||||
const renderCompactView = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 hover:bg-accent cursor-pointer",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
renderCompactView(item.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
@@ -147,6 +308,51 @@ function FileTree({ selectedProject }) {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers for Detailed View */}
|
||||
{viewMode === 'detailed' && files.length > 0 && (
|
||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-5">Name</div>
|
||||
<div className="col-span-2">Size</div>
|
||||
<div className="col-span-3">Modified</div>
|
||||
<div className="col-span-2">Permissions</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
{files.length === 0 ? (
|
||||
@@ -160,8 +366,10 @@ function FileTree({ selectedProject }) {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{renderFileTree(files)}
|
||||
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||
{viewMode === 'simple' && renderFileTree(files)}
|
||||
{viewMode === 'compact' && renderCompactView(files)}
|
||||
{viewMode === 'detailed' && renderDetailedView(files)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2 } from 'lucide-react';
|
||||
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { api } from '../utils/api';
|
||||
@@ -61,10 +62,24 @@ function Sidebar({
|
||||
const [additionalSessions, setAdditionalSessions] = useState({});
|
||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState(null);
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
|
||||
// Starred projects state - persisted in localStorage
|
||||
const [starredProjects, setStarredProjects] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('starredProjects');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch (error) {
|
||||
console.error('Error loading starred projects:', error);
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas)
|
||||
const handleTouchClick = (callback) => {
|
||||
@@ -114,6 +129,45 @@ function Sidebar({
|
||||
}
|
||||
}, [projects, isLoading]);
|
||||
|
||||
// Load project sort order from settings
|
||||
useEffect(() => {
|
||||
const loadSortOrder = () => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||
if (savedSettings) {
|
||||
const settings = JSON.parse(savedSettings);
|
||||
setProjectSortOrder(settings.projectSortOrder || 'name');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading sort order:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Load initially
|
||||
loadSortOrder();
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'claude-tools-settings') {
|
||||
loadSortOrder();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Also check periodically when component is focused (for same-tab changes)
|
||||
const checkInterval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
loadSortOrder();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(checkInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleProject = (projectName) => {
|
||||
const newExpanded = new Set(expandedProjects);
|
||||
if (newExpanded.has(projectName)) {
|
||||
@@ -124,6 +178,71 @@ function Sidebar({
|
||||
setExpandedProjects(newExpanded);
|
||||
};
|
||||
|
||||
// Starred projects utility functions
|
||||
const toggleStarProject = (projectName) => {
|
||||
const newStarred = new Set(starredProjects);
|
||||
if (newStarred.has(projectName)) {
|
||||
newStarred.delete(projectName);
|
||||
} else {
|
||||
newStarred.add(projectName);
|
||||
}
|
||||
setStarredProjects(newStarred);
|
||||
|
||||
// Persist to localStorage
|
||||
try {
|
||||
localStorage.setItem('starredProjects', JSON.stringify([...newStarred]));
|
||||
} catch (error) {
|
||||
console.error('Error saving starred projects:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isProjectStarred = (projectName) => {
|
||||
return starredProjects.has(projectName);
|
||||
};
|
||||
|
||||
// Helper function to get all sessions for a project (initial + additional)
|
||||
const getAllSessions = (project) => {
|
||||
const initialSessions = project.sessions || [];
|
||||
const additional = additionalSessions[project.name] || [];
|
||||
return [...initialSessions, ...additional];
|
||||
};
|
||||
|
||||
// Helper function to get the last activity date for a project
|
||||
const getProjectLastActivity = (project) => {
|
||||
const allSessions = getAllSessions(project);
|
||||
if (allSessions.length === 0) {
|
||||
return new Date(0); // Return epoch date for projects with no sessions
|
||||
}
|
||||
|
||||
// Find the most recent session activity
|
||||
const mostRecentDate = allSessions.reduce((latest, session) => {
|
||||
const sessionDate = new Date(session.lastActivity);
|
||||
return sessionDate > latest ? sessionDate : latest;
|
||||
}, new Date(0));
|
||||
|
||||
return mostRecentDate;
|
||||
};
|
||||
|
||||
// Combined sorting: starred projects first, then by selected order
|
||||
const sortedProjects = [...projects].sort((a, b) => {
|
||||
const aStarred = isProjectStarred(a.name);
|
||||
const bStarred = isProjectStarred(b.name);
|
||||
|
||||
// First, sort by starred status
|
||||
if (aStarred && !bStarred) return -1;
|
||||
if (!aStarred && bStarred) return 1;
|
||||
|
||||
// For projects with same starred status, sort by selected order
|
||||
if (projectSortOrder === 'date') {
|
||||
// Sort by most recent activity (descending)
|
||||
return getProjectLastActivity(b) - getProjectLastActivity(a);
|
||||
} else {
|
||||
// Sort by display name (user-defined) or fallback to name (ascending)
|
||||
const nameA = a.displayName || a.name;
|
||||
const nameB = b.displayName || b.name;
|
||||
return nameA.localeCompare(nameB);
|
||||
}
|
||||
});
|
||||
|
||||
const startEditing = (project) => {
|
||||
setEditingProject(project.name);
|
||||
@@ -284,12 +403,17 @@ function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get all sessions for a project (initial + additional)
|
||||
const getAllSessions = (project) => {
|
||||
const initialSessions = project.sessions || [];
|
||||
const additional = additionalSessions[project.name] || [];
|
||||
return [...initialSessions, ...additional];
|
||||
};
|
||||
// Filter projects based on search input
|
||||
const filteredProjects = sortedProjects.filter(project => {
|
||||
if (!searchFilter.trim()) return true;
|
||||
|
||||
const searchLower = searchFilter.toLowerCase();
|
||||
const displayName = (project.displayName || project.name).toLowerCase();
|
||||
const projectName = project.name.toLowerCase();
|
||||
|
||||
// Search in both display name and actual project name/path
|
||||
return displayName.includes(searchLower) || projectName.includes(searchLower);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card md:select-none">
|
||||
@@ -475,6 +599,30 @@ function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Filter */}
|
||||
{projects.length > 0 && !isLoading && (
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={() => setSearchFilter('')}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects List */}
|
||||
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
|
||||
<div className="md:space-y-1 pb-safe-area-inset-bottom">
|
||||
@@ -498,10 +646,21 @@ function Sidebar({
|
||||
Run Claude CLI in a project directory to get started
|
||||
</p>
|
||||
</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No matching projects</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try adjusting your search term
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
filteredProjects.map((project) => {
|
||||
const isExpanded = expandedProjects.has(project.name);
|
||||
const isSelected = selectedProject?.name === project.name;
|
||||
const isStarred = isProjectStarred(project.name);
|
||||
|
||||
return (
|
||||
<div key={project.name} className="md:space-y-1">
|
||||
@@ -512,7 +671,8 @@ function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
|
||||
isSelected && "bg-primary/5 border-primary/20"
|
||||
isSelected && "bg-primary/5 border-primary/20",
|
||||
isStarred && !isSelected && "bg-yellow-50/50 dark:bg-yellow-900/5 border-yellow-200/30 dark:border-yellow-800/30"
|
||||
)}
|
||||
onClick={() => {
|
||||
// On mobile, just toggle the folder - don't select the project
|
||||
@@ -594,6 +754,28 @@ function Sidebar({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Star button */}
|
||||
<button
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border",
|
||||
isStarred
|
||||
? "bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800"
|
||||
: "bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStarProject(project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => toggleStarProject(project.name))}
|
||||
title={isStarred ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Star className={cn(
|
||||
"w-4 h-4 transition-colors",
|
||||
isStarred
|
||||
? "text-yellow-600 dark:text-yellow-400 fill-current"
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
)} />
|
||||
</button>
|
||||
{getAllSessions(project).length === 0 && (
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
|
||||
@@ -635,7 +817,8 @@ function Sidebar({
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50",
|
||||
isSelected && "bg-accent text-accent-foreground"
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
isStarred && !isSelected && "bg-yellow-50/50 dark:bg-yellow-900/10 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/20"
|
||||
)}
|
||||
onClick={() => {
|
||||
// Desktop behavior: select project and toggle
|
||||
@@ -722,6 +905,27 @@ function Sidebar({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Star button */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100",
|
||||
isStarred
|
||||
? "hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStarProject(project.name);
|
||||
}}
|
||||
title={isStarred ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<Star className={cn(
|
||||
"w-3 h-3 transition-colors",
|
||||
isStarred
|
||||
? "text-yellow-600 dark:text-yellow-400 fill-current"
|
||||
: "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div
|
||||
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-accent flex items-center justify-center rounded cursor-pointer touch:opacity-100"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
const [skipPermissions, setSkipPermissions] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
||||
|
||||
// Common tool patterns
|
||||
const commonTools = [
|
||||
@@ -51,11 +52,13 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
setAllowedTools(settings.allowedTools || []);
|
||||
setDisallowedTools(settings.disallowedTools || []);
|
||||
setSkipPermissions(settings.skipPermissions || false);
|
||||
setProjectSortOrder(settings.projectSortOrder || 'name');
|
||||
} else {
|
||||
// Set defaults
|
||||
setAllowedTools([]);
|
||||
setDisallowedTools([]);
|
||||
setSkipPermissions(false);
|
||||
setProjectSortOrder('name');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tool settings:', error);
|
||||
@@ -63,6 +66,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
setAllowedTools([]);
|
||||
setDisallowedTools([]);
|
||||
setSkipPermissions(false);
|
||||
setProjectSortOrder('name');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,6 +79,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
allowedTools,
|
||||
disallowedTools,
|
||||
skipPermissions,
|
||||
projectSortOrder,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -181,6 +186,28 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Project Sorting - Moved under Appearance */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">
|
||||
Project Sorting
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
How projects are ordered in the sidebar
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={projectSortOrder}
|
||||
onChange={(e) => setProjectSortOrder(e.target.value)}
|
||||
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32"
|
||||
>
|
||||
<option value="name">Alphabetical</option>
|
||||
<option value="date">Recent Activity</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,4 +445,4 @@ function ToolsSettings({ isOpen, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolsSettings;
|
||||
export default ToolsSettings;
|
||||
|
||||
Reference in New Issue
Block a user