- Upgrading to Vite 7

- Refactor to use es modules
- Added permission mode
- Switched to better sqlite3
- several UX enhancements
This commit is contained in:
simos
2025-07-11 10:29:36 +00:00
parent d8bc6348d5
commit fc2a94a2e5
16 changed files with 581 additions and 1465 deletions

1
.nvmrc Normal file
View File

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

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

View File

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

View File

@@ -1,10 +1,10 @@
const { spawn } = require('child_process'); import { spawn } from 'child_process';
let activeClaudeProcesses = new Map(); // Track active processes by session ID let activeClaudeProcesses = new Map(); // Track active processes by session ID
async function spawnClaude(command, options = {}, ws) { async function spawnClaude(command, options = {}, ws) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings } = options; const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event let sessionCreatedSent = false; // Track if we've already sent session-created event
@@ -36,15 +36,38 @@ async function spawnClaude(command, options = {}, ws) {
args.push('--model', 'sonnet'); 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 // 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'); args.push('--dangerously-skip-permissions');
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)'); console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
} else { } else {
// Only add allowed/disallowed tools if not skipping permissions // 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 // Add allowed tools
if (settings.allowedTools && settings.allowedTools.length > 0) { if (allowedTools.length > 0) {
for (const tool of settings.allowedTools) { for (const tool of allowedTools) {
args.push('--allowedTools', tool); args.push('--allowedTools', tool);
console.log('✅ Allowing tool:', tool); console.log('✅ Allowing tool:', tool);
} }
@@ -57,6 +80,11 @@ async function spawnClaude(command, options = {}, ws) {
console.log('❌ Disallowing tool:', tool); 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) // Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
@@ -201,7 +229,7 @@ function abortClaudeSession(sessionId) {
return false; return false;
} }
module.exports = { export {
spawnClaude, spawnClaude,
abortClaudeSession abortClaudeSession
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
const express = require('express'); import express from 'express';
const { exec } = require('child_process'); import { exec } from 'child_process';
const { promisify } = require('util'); import { promisify } from 'util';
const path = require('path'); import path from 'path';
const fs = require('fs').promises; import { promises as fs } from 'fs';
const { extractProjectDirectory } = require('../projects'); import { extractProjectDirectory } from '../projects.js';
const router = express.Router(); const router = express.Router();
const execAsync = promisify(exec); 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)} onShowSettings={() => setShowToolsSettings(true)}
autoExpandTools={autoExpandTools} autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/> />
</div> </div>

View File

@@ -903,6 +903,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [sessionMessages, setSessionMessages] = useState([]); const [sessionMessages, setSessionMessages] = useState([]);
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default');
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const textareaRef = useRef(null); const textareaRef = useRef(null);
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
@@ -1681,7 +1682,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
cwd: selectedProject.fullPath, cwd: selectedProject.fullPath,
sessionId: currentSessionId, sessionId: currentSessionId,
resume: !!currentSessionId, resume: !!currentSessionId,
toolsSettings: toolsSettings toolsSettings: toolsSettings,
permissionMode: permissionMode
} }
}); });
@@ -1726,6 +1728,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 // Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
if (e.key === 'Enter') { if (e.key === 'Enter') {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) { if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
@@ -1790,6 +1802,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 // Don't render if no project is selected
if (!selectedProject) { if (!selectedProject) {
return ( return (
@@ -1888,18 +1907,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </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 */} {/* Input Area - Fixed Bottom */}
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${ <div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
@@ -1912,6 +1919,57 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onAbort={handleAbortSession} 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"> <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' : ''}`}> <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' : ''}`}>
<textarea <textarea
@@ -2041,12 +2099,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div> </div>
{/* Hint text */} {/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block"> <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>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${ <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' isInputFocused ? 'opacity-100' : 'opacity-0'
}`}> }`}>
Enter to send @ for files Enter to send Tab for modes @ for files
</div> </div>
</form> </form>
</div> </div>

View File

@@ -66,9 +66,13 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState(''); const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({}); const [generatingSummary, setGeneratingSummary] = useState({});
// Touch handler to prevent double-tap issues on iPad // Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas)
const handleTouchClick = (callback) => { const handleTouchClick = (callback) => {
return (e) => { 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
callback(); callback();

View File

@@ -7,7 +7,13 @@ const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) =>
className={cn("relative overflow-hidden", className)} className={cn("relative overflow-hidden", className)}
{...props} {...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} {children}
</div> </div>
</div> </div>

View File

@@ -436,6 +436,12 @@
-webkit-tap-highlight-color: transparent; -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 */ /* Preserve checkbox visibility */
input[type="checkbox"] { input[type="checkbox"] {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);