18 Commits

Author SHA1 Message Date
viper151
046f270a11 Update package.json 2025-07-12 21:34:56 +02:00
lvalics
7feeebc2ae feat: Add image upload functionality with drag & drop, clipboard paste, and file picker (#46)
- Add drag & drop support for images with visual feedback
- Implement clipboard paste for images (Ctrl+V/Cmd+V)
- Add image upload button in chat interface
- Support multiple formats: PNG, JPG, JPEG, GIF, WebP, SVG
- Max 5 images per message, 5MB per image
- Add image preview with remove functionality
- Display images in chat message bubbles

Technical implementation:
- Frontend: Add react-dropzone for drag & drop
- Frontend: Create ImageAttachment component for previews
- Backend: Add multer for file upload handling
- Backend: Create /api/projects/:projectName/upload-images endpoint
- Backend: Convert images to base64 and save to temp files
- Claude CLI: Pass image paths as arguments
- Add automatic cleanup of temporary files
- Fix JWT auth to use correct token name
- Fix UI overlap with proper padding adjustments
- Remove non-essential console.logs for production

Security:
- Validate file types and sizes
- Sanitize filenames
- User-specific temp directories
- Proper JWT authentication

Infrastructure:
- Add .tmp/ to .gitignore
- Create comprehensive CHANGELOG.md
- Update package.json with new dependencies

Co-authored-by: viper151 <simosmik@gmail.com>
2025-07-12 21:30:55 +02:00
simos
ad0bcba117 fix: Enhance project sorting in sidebar
- Fixed combined sorting for projects: starred projects first, followed by sorting by last activity date or display name.
- Added helper functions to retrieve all sessions for a project and determine the last activity date.
- Improved sorting logic to accommodate user-defined display names and recent activity.
2025-07-12 19:13:26 +00:00
lvalics
a56e06385d feat: Add project search filter to sidebar
- Added search input below header to filter projects by name
- Search works on both display name and project path
- Clear button (X) appears when search is active
- Shows "No matching projects" message when filter returns no results
- Search box only visible when projects exist
- Case-insensitive search implementation
2025-07-12 20:57:09 +02:00
viper151
5ec51dacc3 Added stared project and ux enahncements
Added stared project and ux enahncements
2025-07-12 20:41:46 +02:00
viper151
a79028a124 Merge branch 'main' into feature/project-starring 2025-07-12 20:41:08 +02:00
viper151
2435d12a0b Merge pull request #37 from lvalics/feature/file-permissions 2025-07-12 20:25:11 +02:00
viper151
54d5583bc2 Merge branch 'main' into feature/file-permissions 2025-07-12 13:35:32 +02:00
viper151
ce1e6c73b3 feat: Add project sorting by date option
feat: Add project sorting by date option
2025-07-12 13:35:04 +02:00
viper151
c6c11c236c Update ToolsSettings.jsx 2025-07-12 13:33:26 +02:00
viper151
211a3c4557 Delete file-metadata-issue.md 2025-07-12 13:28:41 +02:00
Valics Lehel
02a296739d fix: Address project sorting feedback
- Sort by user-defined displayName instead of path/folder name
- Move project sorting under Appearance section
- Replace large toggle buttons with compact dropdown
- Use clearer labels: "Alphabetical" and "Recent Activity"
- Projects now sort by custom names when available
2025-07-12 14:17:56 +03:00
Valics Lehel
1f3fe2df3d feat: Add file metadata display with view modes
- Added file size, permissions (rwx format), and modified date display
- Implemented three view modes: simple, compact, and detailed
- Added server-side file stats collection in getFileTree
- View mode preference persisted in localStorage
- Detailed view shows table-like layout with column headers
- Compact view shows inline metadata
- Simple view maintains original basic tree structure
2025-07-11 23:14:07 +03:00
Valics Lehel
122b757fa2 feat: Add project sorting by date option
- Added ability to sort projects by name or most recent session activity
- Added projectSortOrder state and localStorage persistence
- Added UI controls in ToolsSettings to switch between sort modes
- Projects with no sessions sort last when sorting by date
- Real-time updates when sort preference changes
2025-07-11 22:42:22 +03:00
simos
45b3e54d04 Added stared project and ux enahncements 2025-07-11 12:35:27 +00:00
viper151
4762a2d719 Update README.md 2025-07-11 12:47:11 +02:00
viper151
634e00264e Merge pull request #34 from siteboon/update-vite
Plan mode and upgrading to Vite 7
2025-07-11 12:43:10 +02:00
simos
fc2a94a2e5 - Upgrading to Vite 7
- Refactor to use es modules
- Added permission mode
- Switched to better sqlite3
- several UX enhancements
2025-07-11 10:29:36 +00:00
20 changed files with 1457 additions and 1492 deletions

1
.gitignore vendored
View File

@@ -90,6 +90,7 @@ jspm_packages/
# Temporary folders
tmp/
temp/
.tmp/
# Vite
.vite/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v20.19.3

View File

@@ -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>

1634
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "claude-code-ui",
"version": "1.1.4",
"version": "1.3.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
"scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
@@ -34,6 +35,7 @@
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-webgl": "^0.18.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -42,13 +44,14 @@
"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",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"xterm": "^5.3.0",
@@ -57,12 +60,12 @@
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"postcss": "^8.4.32",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
"vite": "^7.0.4"
}
}

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -1,10 +1,13 @@
const { spawn } = require('child_process');
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 } = 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);
@@ -36,15 +89,38 @@ async function spawnClaude(command, options = {}, ws) {
args.push('--model', 'sonnet');
}
// Add permission mode if specified (works for both new and resumed sessions)
if (permissionMode && permissionMode !== 'default') {
args.push('--permission-mode', permissionMode);
console.log('🔒 Using permission mode:', permissionMode);
}
// Add tools settings flags
if (settings.skipPermissions) {
// Don't use --dangerously-skip-permissions when in plan mode
if (settings.skipPermissions && permissionMode !== 'plan') {
args.push('--dangerously-skip-permissions');
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
} else {
// Only add allowed/disallowed tools if not skipping permissions
// Collect all allowed tools, including plan mode defaults
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode specific tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
// Add plan mode tools that aren't already in the allowed list
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}
console.log('📝 Plan mode: Added default allowed tools:', planModeTools);
}
// Add allowed tools
if (settings.allowedTools && settings.allowedTools.length > 0) {
for (const tool of settings.allowedTools) {
if (allowedTools.length > 0) {
for (const tool of allowedTools) {
args.push('--allowedTools', tool);
console.log('✅ Allowing tool:', tool);
}
@@ -57,17 +133,21 @@ async function spawnClaude(command, options = {}, ws) {
console.log('❌ Disallowing tool:', tool);
}
}
// Log when skip permissions is disabled due to plan mode
if (settings.skipPermissions && permissionMode === 'plan') {
console.log('📝 Skip permissions disabled due to plan mode');
}
}
// 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,
@@ -75,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);
@@ -138,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
@@ -151,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 {
@@ -201,7 +299,7 @@ function abortClaudeSession(sessionId) {
return false;
}
module.exports = {
export {
spawnClaude,
abortClaudeSession
};

View File

@@ -1,99 +1,85 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DB_PATH = path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Create database connection
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database');
}
});
const db = new Database(DB_PATH);
console.log('Connected to SQLite database');
// Initialize database with schema
const initializeDatabase = async () => {
return new Promise((resolve, reject) => {
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL, (err) => {
if (err) {
console.error('Error initializing database:', err.message);
reject(err);
} else {
console.log('Database initialized successfully');
resolve();
}
});
} catch (error) {
console.error('Error reading init SQL file:', error);
reject(error);
}
});
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL);
console.log('Database initialized successfully');
} catch (error) {
console.error('Error initializing database:', error.message);
throw error;
}
};
// User database operations
const userDb = {
// Check if any users exist
hasUsers: () => {
return new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM users', (err, row) => {
if (err) reject(err);
else resolve(row.count > 0);
});
});
try {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
return row.count > 0;
} catch (err) {
throw err;
}
},
// Create a new user
createUser: (username, passwordHash) => {
return new Promise((resolve, reject) => {
try {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
stmt.run(username, passwordHash, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, username });
}
});
stmt.finalize();
});
const result = stmt.run(username, passwordHash);
return { id: result.lastInsertRowid, username };
} catch (err) {
throw err;
}
},
// Get user by username
getUserByUsername: (username) => {
return new Promise((resolve, reject) => {
db.get('SELECT * FROM users WHERE username = ? AND is_active = 1', [username], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
try {
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
return row;
} catch (err) {
throw err;
}
},
// Update last login time
updateLastLogin: (userId) => {
return new Promise((resolve, reject) => {
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [userId], (err) => {
if (err) reject(err);
else resolve();
});
});
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
throw err;
}
},
// Get user by ID
getUserById: (userId) => {
return new Promise((resolve, reject) => {
db.get('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1', [userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
return row;
} catch (err) {
throw err;
}
}
};
module.exports = {
export {
db,
initializeDatabase,
userDb

View File

@@ -1,7 +1,13 @@
// Load environment variables from .env file
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
try {
const fs = require('fs');
const path = require('path');
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
@@ -19,31 +25,31 @@ try {
console.log('PORT from env:', process.env.PORT);
const express = require('express');
const { WebSocketServer } = require('ws');
const http = require('http');
const path = require('path');
const cors = require('cors');
const fs = require('fs').promises;
const { spawn } = require('child_process');
const os = require('os');
const pty = require('node-pty');
const fetch = require('node-fetch');
import express from 'express';
import { WebSocketServer } from 'ws';
import http from 'http';
import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import os from 'os';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git');
const authRoutes = require('./routes/auth');
const { initializeDatabase } = require('./database/db');
const { validateApiKey, authenticateToken, authenticateWebSocket } = require('./middleware/auth');
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
// File system watcher for projects folder
let projectsWatcher = null;
const connectedClients = new Set();
// Setup file system watcher for Claude projects folder using chokidar
function setupProjectsWatcher() {
const chokidar = require('chokidar');
async function setupProjectsWatcher() {
const chokidar = (await import('chokidar')).default;
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
if (projectsWatcher) {
@@ -283,14 +289,14 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
console.log('📄 File read request:', projectName, filePath);
const fs = require('fs').promises;
// Using fsPromises from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const content = await fs.readFile(filePath, 'utf8');
const content = await fsPromises.readFile(filePath, 'utf8');
res.json({ content, path: filePath });
} catch (error) {
console.error('Error reading file:', error);
@@ -312,8 +318,8 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
console.log('🖼️ Binary file serve request:', projectName, filePath);
const fs = require('fs');
const mime = require('mime-types');
// Using fs from import
// Using mime from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
@@ -322,7 +328,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
// Check if file exists
try {
await fs.promises.access(filePath);
await fsPromises.access(filePath);
} catch (error) {
return res.status(404).json({ error: 'File not found' });
}
@@ -358,7 +364,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
console.log('💾 File save request:', projectName, filePath);
const fs = require('fs').promises;
// Using fsPromises from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
@@ -372,14 +378,14 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
// Create backup of original file
try {
const backupPath = filePath + '.backup.' + Date.now();
await fs.copyFile(filePath, backupPath);
await fsPromises.copyFile(filePath, backupPath);
console.log('📋 Created backup:', backupPath);
} catch (backupError) {
console.warn('Could not create backup:', backupError.message);
}
// Write the new content
await fs.writeFile(filePath, content, 'utf8');
await fsPromises.writeFile(filePath, content, 'utf8');
res.json({
success: true,
@@ -401,7 +407,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const fs = require('fs').promises;
// Using fsPromises from import
// Use extractProjectDirectory to get the actual project path
let actualPath;
@@ -415,7 +421,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
// Check if path exists
try {
await fs.access(actualPath);
await fsPromises.access(actualPath);
} catch (e) {
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
}
@@ -662,7 +668,7 @@ function handleShellConnection(ws) {
// Audio transcription endpoint
app.post('/api/transcribe', authenticateToken, async (req, res) => {
try {
const multer = require('multer');
const multer = (await import('multer')).default;
const upload = multer({ storage: multer.memoryStorage() });
// Handle multipart form data
@@ -682,7 +688,7 @@ app.post('/api/transcribe', authenticateToken, async (req, res) => {
try {
// Create form data for OpenAI
const FormData = require('form-data');
const FormData = (await import('form-data')).default;
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
@@ -725,7 +731,7 @@ app.post('/api/transcribe', authenticateToken, async (req, res) => {
// Handle different enhancement modes
try {
const OpenAI = require('openai');
const OpenAI = (await import('openai')).default;
const openai = new OpenAI({ apiKey });
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
@@ -808,18 +814,110 @@ 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) {
const fs = require('fs').promises;
// Using fsPromises from import
const items = [];
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Debug: log all entries including hidden files
@@ -830,17 +928,39 @@ 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 {
// Check if we can access the directory before trying to read it
await fs.access(item.path, fs.constants.R_OK);
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
@@ -872,13 +992,13 @@ async function startServer() {
try {
// Initialize authentication database
await initializeDatabase();
console.log('✅ Database initialized successfully');
console.log('✅ Database initialization skipped (testing)');
server.listen(PORT, '0.0.0.0', () => {
server.listen(PORT, '0.0.0.0', async () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
await setupProjectsWatcher(); // Re-enabled with better-sqlite3
});
} catch (error) {
console.error('❌ Failed to start server:', error);

View File

@@ -1,5 +1,5 @@
const jwt = require('jsonwebtoken');
const { userDb } = require('../database/db');
import jwt from 'jsonwebtoken';
import { userDb } from '../database/db.js';
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
@@ -31,7 +31,7 @@ const authenticateToken = async (req, res, next) => {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user still exists and is active
const user = await userDb.getUserById(decoded.userId);
const user = userDb.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
@@ -71,7 +71,7 @@ const authenticateWebSocket = (token) => {
}
};
module.exports = {
export {
validateApiKey,
authenticateToken,
generateToken,

View File

@@ -1,6 +1,7 @@
const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
// Cache for extracted project directories
const projectDirectoryCache = new Map();
@@ -91,7 +92,7 @@ async function extractProjectDirectory(projectName) {
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
@@ -325,7 +326,7 @@ async function parseJsonlSessions(filePath) {
const sessions = new Map();
try {
const fileStream = require('fs').createReadStream(filePath);
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
@@ -409,7 +410,7 @@ async function getSessionMessages(projectName, sessionId) {
// Process all JSONL files to find messages for this session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
@@ -601,7 +602,7 @@ async function addProjectManually(projectPath, displayName = null) {
}
module.exports = {
export {
getProjects,
getSessions,
getSessionMessages,

View File

@@ -1,7 +1,7 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { userDb } = require('../database/db');
const { generateToken, authenticateToken } = require('../middleware/auth');
import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../database/db.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
const router = express.Router();
@@ -34,7 +34,7 @@ router.post('/register', async (req, res) => {
}
// Check if users already exist (only allow one user)
const hasUsers = await userDb.hasUsers();
const hasUsers = userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
@@ -44,13 +44,13 @@ router.post('/register', async (req, res) => {
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = await userDb.createUser(username, passwordHash);
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
await userDb.updateLastLogin(user.id);
userDb.updateLastLogin(user.id);
res.json({
success: true,
@@ -79,7 +79,7 @@ router.post('/login', async (req, res) => {
}
// Get user from database
const user = await userDb.getUserByUsername(username);
const user = userDb.getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
@@ -94,7 +94,7 @@ router.post('/login', async (req, res) => {
const token = generateToken(user);
// Update last login
await userDb.updateLastLogin(user.id);
userDb.updateLastLogin(user.id);
res.json({
success: true,
@@ -122,4 +122,4 @@ router.post('/logout', authenticateToken, (req, res) => {
res.json({ success: true, message: 'Logged out successfully' });
});
module.exports = router;
export default router;

View File

@@ -1,9 +1,9 @@
const express = require('express');
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs').promises;
const { extractProjectDirectory } = require('../projects');
import express from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js';
const router = express.Router();
const execAsync = promisify(exec);
@@ -420,4 +420,4 @@ function generateSimpleCommitMessage(files, diff) {
}
}
module.exports = router;
export default router;

View File

@@ -585,6 +585,7 @@ function AppContent() {
onShowSettings={() => setShowToolsSettings(true)}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/>
</div>

View File

@@ -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:
@@ -903,6 +954,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [sessionMessages, setSessionMessages] = useState([]);
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);
@@ -1379,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':
@@ -1596,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 => {
@@ -1619,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()
};
@@ -1672,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,
@@ -1681,12 +1841,25 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
cwd: selectedProject.fullPath,
sessionId: currentSessionId,
resume: !!currentSessionId,
toolsSettings: toolsSettings
toolsSettings: toolsSettings,
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}`);
@@ -1726,6 +1899,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
// Handle Tab key for mode switching (only when file dropdown is not showing)
if (e.key === 'Tab' && !showFileDropdown) {
e.preventDefault();
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
setPermissionMode(modes[nextIndex]);
return;
}
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
if (e.key === 'Enter') {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
@@ -1764,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) => {
@@ -1790,6 +1980,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
};
const handleModeSwitch = () => {
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
setPermissionMode(modes[nextIndex]);
};
// Don't render if no project is selected
if (!selectedProject) {
return (
@@ -1888,18 +2085,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div ref={messagesEndRef} />
</div>
{/* Floating scroll to bottom button - positioned outside scrollable container */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="fixed bottom-20 sm:bottom-24 right-4 sm:right-6 w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-50"
title="Scroll to bottom"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
{/* Input Area - Fixed Bottom */}
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
@@ -1912,14 +2097,98 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onAbort={handleAbortSession}
/>
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
<div className="max-w-4xl mx-auto mb-3">
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={handleModeSwitch}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
: permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-100 dark:hover:bg-green-900/30'
: permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30'
}`}
title="Click to change permission mode (or press Tab in input)"
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
permissionMode === 'default'
? 'bg-gray-500'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-blue-500'
}`} />
<span>
{permissionMode === 'default' && 'Default Mode'}
{permissionMode === 'acceptEdits' && 'Accept Edits'}
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'}
{permissionMode === 'plan' && 'Plan Mode'}
</span>
</div>
</button>
{/* Scroll to bottom button - positioned next to mode indicator */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
title="Scroll to bottom"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
</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) => {
@@ -1936,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 */}
@@ -1981,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
@@ -2041,12 +2322,12 @@ 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 @ 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 @ for files
Enter to send Tab for modes @ for files
</div>
</form>
</div>

View File

@@ -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>

View File

@@ -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,14 +62,32 @@ 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('');
// Touch handler to prevent double-tap issues on iPad
// 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) => {
return (e) => {
// Only prevent default for buttons/clickable elements, not scrollable areas
if (e.target.closest('.overflow-y-auto') || e.target.closest('[data-scroll-container]')) {
return;
}
e.preventDefault();
e.stopPropagation();
callback();
@@ -110,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)) {
@@ -120,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);
@@ -280,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">
@@ -471,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">
@@ -494,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">
@@ -508,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
@@ -590,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"
@@ -631,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
@@ -718,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) => {

View File

@@ -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;

View File

@@ -7,7 +7,13 @@ const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) =>
className={cn("relative overflow-hidden", className)}
{...props}
>
<div className="h-full w-full rounded-[inherit] overflow-auto">
<div
className="h-full w-full rounded-[inherit] overflow-auto"
style={{
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
>
{children}
</div>
</div>

View File

@@ -436,6 +436,12 @@
-webkit-tap-highlight-color: transparent;
}
/* Allow vertical scrolling in scroll containers */
.overflow-y-auto, [data-scroll-container] {
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
/* Preserve checkbox visibility */
input[type="checkbox"] {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);