18 Commits

Author SHA1 Message Date
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
viper151
d8bc6348d5 Merge pull request #29 from siteboon/login
Login
2025-07-10 20:52:08 +02:00
viper151
ac32026cfc Update package.json 2025-07-10 20:50:57 +02:00
simos
ec9ff3336a Update package version to 1.1.3, add new dependencies for authentication and database management, and implement user authentication features including registration and login. Enhance API routes for protected access and integrate WebSocket authentication. 2025-07-09 18:25:58 +00:00
simos
b27702797f Refactor CodeEditor component to improve dark mode support and enhance loading styles 2025-07-08 16:53:08 +00:00
simos
c8aa3d5d4e Add word wrap feature to CodeEditor component and clean up styles 2025-07-08 15:31:03 +00:00
simos
bca97a5284 Update GitPanel 2025-07-08 15:14:12 +00:00
simos
1bdc75e37b Enhance project directory handling by integrating extractProjectDirectory and clearProjectDirectoryCache functions. Adjust git route handlers to utilize the new directory extraction logic for improved project path resolution. 2025-07-08 15:10:44 +00:00
simos
c5e3bd0633 Enhance project directory handling by adding extractProjectDirectory function. Update generateDisplayName to utilize actual project directory when available. Adjust getProjects and addProjectManually to incorporate new directory extraction logic for improved project path resolution. 2025-07-08 14:04:23 +00:00
simos
27f34db777 Refactor ChatInterface and MicButton components for improved scroll behavior and microphone support. Adjusted auto-scroll thresholds, added error handling for microphone access, and hid unused UI elements. 2025-07-08 13:48:33 +00:00
simos
fca741ab3f modified: package-lock.json 2025-07-08 12:53:15 +00:00
simos
2de9766597 Update package version to 1.1.1, downgrade node-fetch to 2.7.0, 2025-07-08 12:53:03 +00:00
simos
fbe2f356cf modified: package.json 2025-07-08 12:48:14 +00:00
simos
5bbc0446a7 Add version update notification to Sidebar component for both desktop and mobile views 2025-07-08 12:48:10 +00:00
simos
1906f3b53f Update Sidebar component to include version information props for enhanced version management 2025-07-08 12:46:10 +00:00
simos
4e0e0e6e92 Integrate version checking system with App.jsx and Sidebar components 2025-07-08 12:38:39 +00:00
simos
781394bbcd Add smart version checking feature - automatically detects updates and shows notifications 2025-07-08 12:18:16 +00:00
31 changed files with 2719 additions and 670 deletions

1
.nvmrc Normal file
View File

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

1084
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.0.0",
"version": "1.2.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\"",
@@ -33,14 +34,17 @@
"@uiw/react-codemirror": "^4.23.13",
"@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",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"node-fetch": "^3.3.2",
"node-fetch": "^2.7.0",
"node-pty": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -54,12 +58,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,10 @@
const { spawn } = require('child_process');
import { spawn } from 'child_process';
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 } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
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');
}
// 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,6 +80,11 @@ 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)
@@ -201,7 +229,7 @@ function abortClaudeSession(sessionId) {
return false;
}
module.exports = {
export {
spawnClaude,
abortClaudeSession
};

86
server/database/db.js Normal file
View File

@@ -0,0 +1,86 @@
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 Database(DB_PATH);
console.log('Connected to SQLite database');
// Initialize database with schema
const initializeDatabase = async () => {
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: () => {
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) => {
try {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
const result = stmt.run(username, passwordHash);
return { id: result.lastInsertRowid, username };
} catch (err) {
throw err;
}
},
// Get user by username
getUserByUsername: (username) => {
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) => {
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
throw err;
}
},
// Get user by ID
getUserById: (userId) => {
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;
}
}
};
export {
db,
initializeDatabase,
userDb
};

16
server/database/init.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Initialize authentication database
PRAGMA foreign_keys = ON;
-- Users table (single user system)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);

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,28 +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 } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git');
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) {
@@ -76,6 +85,9 @@ function setupProjectsWatcher() {
debounceTimer = setTimeout(async () => {
try {
// Clear project directory cache when files change
clearProjectDirectoryCache();
// Get updated projects list
const updatedProjects = await getProjects();
@@ -139,19 +151,43 @@ const wss = new WebSocketServer({
server,
verifyClient: (info) => {
console.log('WebSocket connection attempt to:', info.req.url);
return true; // Accept all connections for now
// Extract token from query parameters or headers
const url = new URL(info.req.url, 'http://localhost');
const token = url.searchParams.get('token') ||
info.req.headers.authorization?.split(' ')[1];
// Verify token
const user = authenticateWebSocket(token);
if (!user) {
console.log('❌ WebSocket authentication failed');
return false;
}
// Store user info in the request for later use
info.req.user = user;
console.log('✅ WebSocket authenticated for user:', user.username);
return true;
}
});
app.use(cors());
app.use(express.json());
// Optional API key validation (if configured)
app.use('/api', validateApiKey);
// Authentication routes (public)
app.use('/api/auth', authRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
// Static files served after API routes
app.use(express.static(path.join(__dirname, '../dist')));
// Git API Routes
app.use('/api/git', gitRoutes);
// API Routes
app.get('/api/config', (req, res) => {
// API Routes (protected)
app.get('/api/config', authenticateToken, (req, res) => {
// Always use the server's actual IP and port for WebSocket connections
const serverIP = getServerIP();
const host = `${serverIP}:${PORT}`;
@@ -165,7 +201,7 @@ app.get('/api/config', (req, res) => {
});
});
app.get('/api/projects', async (req, res) => {
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
res.json(projects);
@@ -174,7 +210,7 @@ app.get('/api/projects', async (req, res) => {
}
});
app.get('/api/projects/:projectName/sessions', async (req, res) => {
app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
try {
const { limit = 5, offset = 0 } = req.query;
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
@@ -185,7 +221,7 @@ app.get('/api/projects/:projectName/sessions', async (req, res) => {
});
// Get messages for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const messages = await getSessionMessages(projectName, sessionId);
@@ -196,7 +232,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, r
});
// Rename project endpoint
app.put('/api/projects/:projectName/rename', async (req, res) => {
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
try {
const { displayName } = req.body;
await renameProject(req.params.projectName, displayName);
@@ -207,7 +243,7 @@ app.put('/api/projects/:projectName/rename', async (req, res) => {
});
// Delete session endpoint
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
await deleteSession(projectName, sessionId);
@@ -218,7 +254,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) =>
});
// Delete project endpoint (only if empty)
app.delete('/api/projects/:projectName', async (req, res) => {
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
@@ -229,7 +265,7 @@ app.delete('/api/projects/:projectName', async (req, res) => {
});
// Create project endpoint
app.post('/api/projects/create', async (req, res) => {
app.post('/api/projects/create', authenticateToken, async (req, res) => {
try {
const { path: projectPath } = req.body;
@@ -246,21 +282,21 @@ app.post('/api/projects/create', async (req, res) => {
});
// Read file content endpoint
app.get('/api/projects/:projectName/file', async (req, res) => {
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath } = req.query;
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);
@@ -275,15 +311,15 @@ app.get('/api/projects/:projectName/file', async (req, res) => {
});
// Serve binary file content endpoint (for images, etc.)
app.get('/api/projects/:projectName/files/content', async (req, res) => {
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: filePath } = req.query;
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)) {
@@ -292,7 +328,7 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
// 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' });
}
@@ -321,14 +357,14 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
});
// Save file content endpoint
app.put('/api/projects/:projectName/file', async (req, res) => {
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath, content } = req.body;
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)) {
@@ -342,14 +378,14 @@ app.put('/api/projects/:projectName/file', 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,
@@ -368,56 +404,24 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
}
});
app.get('/api/projects/:projectName/files', async (req, res) => {
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const fs = require('fs').promises;
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
// Try different methods to get the actual project path
let actualPath = projectPath;
// Using fsPromises from import
// Use extractProjectDirectory to get the actual project path
let actualPath;
try {
// First try to read metadata.json
const metadataPath = path.join(projectPath, 'metadata.json');
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
actualPath = metadata.path || metadata.cwd;
} catch (e) {
// Fallback: try to find the actual path by testing different dash interpretations
let testPath = req.params.projectName;
if (testPath.startsWith('-')) {
testPath = testPath.substring(1);
}
// Try to intelligently decode the path by testing which directories exist
const pathParts = testPath.split('-');
actualPath = '/' + pathParts.join('/');
// If the simple replacement doesn't work, try to find the correct path
// by testing combinations where some dashes might be part of directory names
if (!require('fs').existsSync(actualPath)) {
// Try different combinations of dash vs slash
for (let i = pathParts.length - 1; i >= 0; i--) {
let testParts = [...pathParts];
// Try joining some parts with dashes instead of slashes
for (let j = i; j < testParts.length - 1; j++) {
testParts[j] = testParts[j] + '-' + testParts[j + 1];
testParts.splice(j + 1, 1);
let testActualPath = '/' + testParts.join('/');
if (require('fs').existsSync(testActualPath)) {
actualPath = testActualPath;
break;
}
}
if (require('fs').existsSync(actualPath)) break;
}
}
actualPath = await extractProjectDirectory(req.params.projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
// Fallback to simple dash replacement
actualPath = req.params.projectName.replace(/-/g, '/');
}
// 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}` });
}
@@ -438,12 +442,16 @@ wss.on('connection', (ws, request) => {
const url = request.url;
console.log('🔗 Client connected to:', url);
if (url === '/shell') {
// Parse URL to get pathname without query parameters
const urlObj = new URL(url, 'http://localhost');
const pathname = urlObj.pathname;
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (url === '/ws') {
} else if (pathname === '/ws') {
handleChatConnection(ws);
} else {
console.log('❌ Unknown WebSocket path:', url);
console.log('❌ Unknown WebSocket path:', pathname);
ws.close();
}
});
@@ -658,9 +666,9 @@ function handleShellConnection(ws) {
});
}
// Audio transcription endpoint
app.post('/api/transcribe', async (req, res) => {
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
@@ -680,7 +688,7 @@ app.post('/api/transcribe', 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,
@@ -691,7 +699,6 @@ app.post('/api/transcribe', async (req, res) => {
formData.append('language', 'en');
// Make request to OpenAI
const fetch = require('node-fetch');
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
@@ -724,7 +731,7 @@ app.post('/api/transcribe', 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;
@@ -814,11 +821,11 @@ app.get('*', (req, res) => {
});
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
@@ -839,7 +846,7 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
// 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.)
@@ -865,9 +872,24 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
});
// Initialize database and start server
async function startServer() {
try {
// Initialize authentication database
await initializeDatabase();
console.log('✅ Database initialization skipped (testing)');
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
await setupProjectsWatcher(); // Re-enabled with better-sqlite3
});
} catch (error) {
console.error('❌ Failed to start server:', error);
process.exit(1);
}
}
startServer();

80
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,80 @@
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';
// Optional API key middleware
const validateApiKey = (req, res, next) => {
// Skip API key validation if not configured
if (!process.env.API_KEY) {
return next();
}
const apiKey = req.headers['x-api-key'];
if (apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
};
// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user still exists and is active
const user = userDb.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
req.user = user;
next();
} catch (error) {
console.error('Token verification error:', error);
return res.status(403).json({ error: 'Invalid token' });
}
};
// Generate JWT token (never expires)
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
},
JWT_SECRET
// No expiration - token lasts forever
);
};
// WebSocket authentication function
const authenticateWebSocket = (token) => {
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
}
};
export {
validateApiKey,
authenticateToken,
generateToken,
authenticateWebSocket,
JWT_SECRET
};

View File

@@ -1,6 +1,18 @@
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();
let cacheTimestamp = Date.now();
// Clear cache when needed (called when project files change)
function clearProjectDirectoryCache() {
projectDirectoryCache.clear();
cacheTimestamp = Date.now();
console.log('🗑️ Project directory cache cleared');
}
// Load project configuration file
async function loadProjectConfig() {
@@ -21,9 +33,9 @@ async function saveProjectConfig(config) {
}
// Generate better display name from path
async function generateDisplayName(projectName) {
// Convert "-home-user-projects-myapp" to a readable format
let projectPath = projectName.replace(/-/g, '/');
async function generateDisplayName(projectName, actualProjectDir = null) {
// Use actual project directory if provided, otherwise decode from project name
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
// Try to read package.json from the project path
try {
@@ -54,6 +66,111 @@ async function generateDisplayName(projectName) {
return projectPath;
}
// Extract the actual project directory from JSONL sessions (with caching)
async function extractProjectDirectory(projectName) {
// Check cache first
if (projectDirectoryCache.has(projectName)) {
return projectDirectoryCache.get(projectName);
}
console.log(`🔍 Extracting project directory for: ${projectName}`);
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
let extractedPath;
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
// Fall back to decoded project name if no sessions
extractedPath = projectName.replace(/-/g, '/');
} else {
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.cwd) {
// Count occurrences of each cwd
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
// Track the most recent cwd
const timestamp = new Date(entry.timestamp || 0).getTime();
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestCwd = entry.cwd;
}
}
} catch (parseError) {
// Skip malformed lines
}
}
}
}
// Determine the best cwd to use
if (cwdCounts.size === 0) {
// No cwd found, fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
} else if (cwdCounts.size === 1) {
// Only one cwd, use it
extractedPath = Array.from(cwdCounts.keys())[0];
} else {
// Multiple cwd values - prefer the most recent one if it has reasonable usage
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
const maxCount = Math.max(...cwdCounts.values());
// Use most recent if it has at least 25% of the max count
if (mostRecentCount >= maxCount * 0.25) {
extractedPath = latestCwd;
} else {
// Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) {
extractedPath = cwd;
break;
}
}
}
// Fallback (shouldn't reach here)
if (!extractedPath) {
extractedPath = latestCwd || projectName.replace(/-/g, '/');
}
}
}
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
return extractedPath;
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
// Cache the fallback result too
projectDirectoryCache.set(projectName, extractedPath);
return extractedPath;
}
}
async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const config = await loadProjectConfig();
@@ -69,14 +186,17 @@ async function getProjects() {
existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions
const actualProjectDir = await extractProjectDirectory(entry.name);
// Get display name from config or generate one
const customName = config[entry.name]?.displayName;
const autoDisplayName = await generateDisplayName(entry.name);
const fullPath = entry.name.replace(/-/g, '/');
const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
const fullPath = actualProjectDir;
const project = {
name: entry.name,
path: projectPath,
path: actualProjectDir,
displayName: customName || autoDisplayName,
fullPath: fullPath,
isCustomName: !!customName,
@@ -105,17 +225,27 @@ async function getProjects() {
// Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
const fullPath = projectName.replace(/-/g, '/');
// Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath;
const project = {
name: projectName,
path: null, // No physical path yet
displayName: projectConfig.displayName || await generateDisplayName(projectName),
fullPath: fullPath,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
};
if (!actualProjectDir) {
try {
actualProjectDir = await extractProjectDirectory(projectName);
} catch (error) {
// Fall back to decoded project name
actualProjectDir = projectName.replace(/-/g, '/');
}
}
const project = {
name: projectName,
path: actualProjectDir,
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
};
projects.push(project);
}
@@ -196,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
@@ -280,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
@@ -463,16 +593,16 @@ async function addProjectManually(projectPath, displayName = null) {
return {
name: projectName,
path: null,
path: absolutePath,
fullPath: absolutePath,
displayName: displayName || await generateDisplayName(projectName),
displayName: displayName || await generateDisplayName(projectName, absolutePath),
isManuallyAdded: true,
sessions: []
};
}
module.exports = {
export {
getProjects,
getSessions,
getSessionMessages,
@@ -483,5 +613,7 @@ module.exports = {
deleteProject,
addProjectManually,
loadProjectConfig,
saveProjectConfig
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
};

125
server/routes/auth.js Normal file
View File

@@ -0,0 +1,125 @@
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();
// Check auth status and setup requirements
router.get('/status', async (req, res) => {
try {
const hasUsers = await userDb.hasUsers();
res.json({
needsSetup: !hasUsers,
isAuthenticated: false // Will be overridden by frontend if token exists
});
} catch (error) {
console.error('Auth status error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// User registration (setup) - only allowed if no users exist
router.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (username.length < 3 || password.length < 6) {
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
}
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Registration error:', error);
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
res.status(409).json({ error: 'Username already exists' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// User login
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
// Get user from database
const user = userDb.getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get current user (protected route)
router.get('/user', authenticateToken, (req, res) => {
res.json({
user: req.user
});
});
// Logout (client-side token removal, but this endpoint can be used for logging)
router.post('/logout', authenticateToken, (req, res) => {
// In a simple JWT system, logout is mainly client-side
// This endpoint exists for consistency and potential future logging
res.json({ success: true, message: 'Logged out successfully' });
});
export default router;

View File

@@ -1,17 +1,49 @@
const express = require('express');
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs').promises;
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);
// Helper function to get the actual project path from the encoded project name
function getActualProjectPath(projectName) {
// Claude stores projects with dashes instead of slashes
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui"
return projectName.replace(/-/g, '/');
async function getActualProjectPath(projectName) {
try {
return await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fallback to the old method
return projectName.replace(/-/g, '/');
}
}
// Helper function to validate git repository
async function validateGitRepository(projectPath) {
try {
// Check if directory exists
await fs.access(projectPath);
} catch {
throw new Error(`Project path not found: ${projectPath}`);
}
try {
// Use --show-toplevel to get the root of the git repository
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
const normalizedGitRoot = path.resolve(gitRoot.trim());
const normalizedProjectPath = path.resolve(projectPath);
// Ensure the git root matches our project path (prevent using parent git repos)
if (normalizedGitRoot !== normalizedProjectPath) {
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
}
} catch (error) {
if (error.message.includes('Project directory is not a git repository')) {
throw error;
}
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
}
}
// Get git status for a project
@@ -23,24 +55,11 @@ router.get('/status', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
console.log('Git status for project:', project, '-> path:', projectPath);
// Check if directory exists
try {
await fs.access(projectPath);
} catch {
console.error('Project path not found:', projectPath);
return res.json({ error: 'Project not found' });
}
// Check if it's a git repository
try {
await execAsync('git rev-parse --git-dir', { cwd: projectPath });
} catch {
console.error('Not a git repository:', projectPath);
return res.json({ error: 'Not a git repository' });
}
// Validate git repository
await validateGitRepository(projectPath);
// Get current branch
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
@@ -79,7 +98,14 @@ router.get('/status', async (req, res) => {
});
} catch (error) {
console.error('Git status error:', error);
res.json({ error: error.message });
res.json({
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
? error.message
: 'Git operation failed',
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
? error.message
: `Failed to get git status: ${error.message}`
});
}
});
@@ -92,7 +118,10 @@ router.get('/diff', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Check if file is untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
@@ -133,7 +162,10 @@ router.post('/commit', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Stage selected files
for (const file of files) {
@@ -159,9 +191,12 @@ router.get('/branches', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
console.log('Git branches for project:', project, '-> path:', projectPath);
// Validate git repository
await validateGitRepository(projectPath);
// Get all branches
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
@@ -199,7 +234,7 @@ router.post('/checkout', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Checkout the branch
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
@@ -220,7 +255,7 @@ router.post('/create-branch', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Create and checkout new branch
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
@@ -241,7 +276,7 @@ router.get('/commits', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Get commit log with stats
const { stdout } = await execAsync(
@@ -292,7 +327,7 @@ router.get('/commit-diff', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Get diff for the commit
const { stdout } = await execAsync(
@@ -316,7 +351,7 @@ router.post('/generate-commit-message', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Get diff for selected files
let combinedDiff = '';
@@ -385,4 +420,4 @@ function generateSimpleCommitMessage(files, diff) {
}
}
module.exports = router;
export default router;

View File

@@ -28,6 +28,10 @@ import QuickSettingsPanel from './components/QuickSettingsPanel';
import { useWebSocket } from './utils/websocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck';
import { api } from './utils/api';
// Main App component with routing
@@ -35,6 +39,9 @@ function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false);
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [selectedSession, setSelectedSession] = useState(null);
@@ -178,7 +185,7 @@ function AppContent() {
const fetchProjects = async () => {
try {
setIsLoadingProjects(true);
const response = await fetch('/api/projects');
const response = await api.projects();
const data = await response.json();
// Optimize to preserve object references when data hasn't changed
@@ -300,7 +307,7 @@ function AppContent() {
const handleSidebarRefresh = async () => {
// Refresh only the sessions for all projects, don't change selected state
try {
const response = await fetch('/api/projects');
const response = await api.projects();
const freshProjects = await response.json();
// Optimize to preserve object references and minimize re-renders
@@ -399,6 +406,92 @@ function AppContent() {
}
};
// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
if (!showVersionModal) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setShowVersionModal(false)}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" 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.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">A new version is ready</p>
</div>
</div>
<button
onClick={() => setShowVersionModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" 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>
{/* Version Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
</div>
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
</div>
</div>
{/* Upgrade Instructions */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">How to upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Run this command in your Claude Code UI directory to update to the latest version.
</p>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<button
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Later
</button>
<button
onClick={() => {
// Copy command to clipboard
navigator.clipboard.writeText('git checkout main && git pull && npm install');
setShowVersionModal(false);
}}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Copy Command
</button>
</div>
</div>
</div>
);
};
return (
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
@@ -417,6 +510,10 @@ function AppContent() {
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
/>
</div>
</div>
@@ -458,6 +555,10 @@ function AppContent() {
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
/>
</div>
</div>
@@ -484,6 +585,7 @@ function AppContent() {
onShowSettings={() => setShowToolsSettings(true)}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/>
</div>
@@ -524,6 +626,9 @@ function AppContent() {
isOpen={showToolsSettings}
onClose={() => setShowToolsSettings(false)}
/>
{/* Version Upgrade Modal */}
<VersionUpgradeModal />
</div>
);
}
@@ -532,12 +637,16 @@ function AppContent() {
function App() {
return (
<ThemeProvider>
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
<AuthProvider>
<ProtectedRoute>
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
</ProtectedRoute>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -23,6 +23,7 @@ import ClaudeLogo from './ClaudeLogo.jsx';
import ClaudeStatus from './ClaudeStatus';
import { MicButton } from './MicButton.jsx';
import { api } from '../utils/api';
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
@@ -902,6 +903,7 @@ 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 messagesEndRef = useRef(null);
const textareaRef = useRef(null);
const scrollContainerRef = useRef(null);
@@ -949,7 +951,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setIsLoadingSessionMessages(true);
try {
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`);
const response = await api.sessionMessages(projectName, sessionId);
if (!response.ok) {
throw new Error('Failed to load session messages');
}
@@ -1112,15 +1114,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const isNearBottom = useCallback(() => {
if (!scrollContainerRef.current) return false;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
// Consider "near bottom" if within 100px of the bottom
return scrollHeight - scrollTop - clientHeight < 100;
// Consider "near bottom" if within 50px of the bottom
return scrollHeight - scrollTop - clientHeight < 50;
}, []);
// Handle scroll events to detect when user manually scrolls up
const handleScroll = useCallback(() => {
if (scrollContainerRef.current) {
const wasNearBottom = isNearBottom();
setIsUserScrolledUp(!wasNearBottom);
const nearBottom = isNearBottom();
setIsUserScrolledUp(!nearBottom);
}
}, [isNearBottom]);
@@ -1451,7 +1453,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const fetchProjectFiles = async () => {
try {
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
const response = await api.getFiles(selectedProject.name);
if (response.ok) {
const files = await response.json();
// Flatten the file tree to get all file paths
@@ -1540,13 +1542,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
});
useEffect(() => {
// Only auto-scroll to bottom when new messages arrive if:
// 1. Auto-scroll is enabled in settings
// 2. User hasn't manually scrolled up
// Auto-scroll to bottom when new messages arrive
if (scrollContainerRef.current && chatMessages.length > 0) {
if (autoScrollToBottom) {
// If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up
if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 0);
setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated
}
} else {
// When auto-scroll is disabled, preserve the visual position
@@ -1564,12 +1565,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]);
// Scroll to bottom when component mounts with existing messages
// Scroll to bottom when component mounts with existing messages or when messages first load
useEffect(() => {
if (scrollContainerRef.current && chatMessages.length > 0 && autoScrollToBottom) {
setTimeout(() => scrollToBottom(), 100); // Small delay to ensure rendering
if (scrollContainerRef.current && chatMessages.length > 0) {
// Always scroll to bottom when messages first load (user expects to see latest)
// Also reset scroll state
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 200); // Longer delay to ensure full rendering
}
}, [scrollToBottom, autoScrollToBottom]);
}, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear
// Add scroll event listener to detect user scrolling
useEffect(() => {
@@ -1636,8 +1640,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
can_interrupt: true
});
// Always scroll to bottom when user sends a message (they're actively participating)
setTimeout(() => scrollToBottom(), 0);
// Always scroll to bottom when user sends a message and reset scroll state
setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
// Session Protection: Mark session as active to prevent automatic project updates during conversation
// This is crucial for maintaining chat state integrity. We handle two cases:
@@ -1677,7 +1682,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
cwd: selectedProject.fullPath,
sessionId: currentSessionId,
resume: !!currentSessionId,
toolsSettings: toolsSettings
toolsSettings: toolsSettings,
permissionMode: permissionMode
}
});
@@ -1722,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
if (e.key === 'Enter') {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
@@ -1786,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
if (!selectedProject) {
return (
@@ -1882,21 +1905,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)}
<div ref={messagesEndRef} />
{/* Floating scroll to bottom button */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-10 h-10 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-10"
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>
)}
</div>
{/* Input Area - Fixed Bottom */}
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
@@ -1908,6 +1919,57 @@ 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' : ''}`}>
<textarea
@@ -1977,8 +2039,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</svg>
</button>
)}
{/* Mic button */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2">
{/* Mic button - HIDDEN */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton
onTranscript={handleTranscript}
className="w-10 h-10 sm:w-10 sm:h-10"
@@ -2037,12 +2099,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

@@ -10,6 +10,7 @@ import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, Decoration } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
function CodeEditor({ file, onClose, projectPath }) {
const [content, setContent] = useState('');
@@ -19,6 +20,7 @@ function CodeEditor({ file, onClose, projectPath }) {
const [isDarkMode, setIsDarkMode] = useState(true);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false);
// Create diff highlighting
const diffEffect = StateEffect.define();
@@ -138,7 +140,7 @@ function CodeEditor({ file, onClose, projectPath }) {
try {
setLoading(true);
const response = await fetch(`/api/projects/${file.projectName}/file?filePath=${encodeURIComponent(file.path)}`);
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
@@ -175,16 +177,7 @@ function CodeEditor({ file, onClose, projectPath }) {
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch(`/api/projects/${file.projectName}/file`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filePath: file.path,
content: content
})
});
const response = await api.saveFile(file.projectName, file.path, content);
if (!response.ok) {
const errorData = await response.json();
@@ -265,28 +258,17 @@ function CodeEditor({ file, onClose, projectPath }) {
}
return (
<>
<style>
{`
.code-editor-modal {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-modal:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`code-editor-modal shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
@@ -295,14 +277,14 @@ function CodeEditor({ file, onClose, projectPath }) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded whitespace-nowrap">
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
<p className="text-sm text-gray-500 truncate">{file.path}</p>
</div>
</div>
@@ -317,6 +299,18 @@ function CodeEditor({ file, onClose, projectPath }) {
</button>
)}
<button
onClick={() => setWordWrap(!wordWrap)}
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
wordWrap
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-gray-900'
}`}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -384,7 +378,8 @@ function CodeEditor({ file, onClose, projectPath }) {
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme
diffTheme,
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
@@ -421,7 +416,6 @@ function CodeEditor({ file, onClose, projectPath }) {
</div>
</div>
</div>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
import { cn } from '../lib/utils';
import CodeEditor from './CodeEditor';
import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
function FileTree({ selectedProject }) {
const [files, setFiles] = useState([]);
@@ -22,7 +23,7 @@ function FileTree({ selectedProject }) {
const fetchFiles = async () => {
setLoading(true);
try {
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
const response = await api.getFiles(selectedProject.name);
if (!response.ok) {
const errorText = await response.text();

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react';
import { MicButton } from './MicButton.jsx';
import { authenticatedFetch } from '../utils/api';
function GitPanel({ selectedProject, isMobile }) {
const [gitStatus, setGitStatus] = useState(null);
@@ -55,14 +56,14 @@ function GitPanel({ selectedProject, isMobile }) {
setIsLoading(true);
try {
const response = await fetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
console.log('Git status response:', data);
if (data.error) {
console.error('Git status error:', data.error);
setGitStatus(null);
setGitStatus({ error: data.error, details: data.details });
} else {
setGitStatus(data);
setCurrentBranch(data.branch || 'main');
@@ -93,7 +94,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchBranches = async () => {
try {
const response = await fetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
const response = await authenticatedFetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
if (!data.error && data.branches) {
@@ -106,7 +107,7 @@ function GitPanel({ selectedProject, isMobile }) {
const switchBranch = async (branchName) => {
try {
const response = await fetch('/api/git/checkout', {
const response = await authenticatedFetch('/api/git/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -133,7 +134,7 @@ function GitPanel({ selectedProject, isMobile }) {
setIsCreatingBranch(true);
try {
const response = await fetch('/api/git/create-branch', {
const response = await authenticatedFetch('/api/git/create-branch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -162,7 +163,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchFileDiff = async (filePath) => {
try {
const response = await fetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (!data.error && data.diff) {
@@ -178,7 +179,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchRecentCommits = async () => {
try {
const response = await fetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
const data = await response.json();
if (!data.error && data.commits) {
@@ -191,7 +192,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchCommitDiff = async (commitHash) => {
try {
const response = await fetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
const response = await authenticatedFetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
const data = await response.json();
if (!data.error && data.diff) {
@@ -208,7 +209,7 @@ function GitPanel({ selectedProject, isMobile }) {
const generateCommitMessage = async () => {
setIsGeneratingMessage(true);
try {
const response = await fetch('/api/git/generate-commit-message', {
const response = await authenticatedFetch('/api/git/generate-commit-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -275,7 +276,7 @@ function GitPanel({ selectedProject, isMobile }) {
setIsCommitting(true);
try {
const response = await fetch('/api/git/commit', {
const response = await authenticatedFetch('/api/git/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -506,169 +507,191 @@ function GitPanel({ selectedProject, isMobile }) {
</button>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" />
<span>Changes</span>
{/* Git Repository Not Found Message */}
{gitStatus?.error ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
<GitBranch className="w-20 h-20 mb-6 opacity-30" />
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
{gitStatus.details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
)}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
</p>
</div>
</button>
<button
onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</div>
</button>
</div>
{/* Changes View */}
{activeView === 'changes' && (
</div>
) : (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<textarea
ref={textareaRef}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
rows="3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
{/* Tab Navigation - Only show when git is available */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" />
<span>Changes</span>
</div>
</button>
<button
onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</div>
</button>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<textarea
ref={textareaRef}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
rows="3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Generate commit message"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={handleCommit}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
</button>
</div>
</div>
</>
)}
{/* File Selection Controls - Only show in changes view and when git is working */}
{activeView === 'changes' && gitStatus && !gitStatus.error && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
</span>
<div className="flex gap-2">
<button
onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Generate commit message"
onClick={() => {
const allFiles = new Set([
...(gitStatus?.modified || []),
...(gitStatus?.added || []),
...(gitStatus?.deleted || []),
...(gitStatus?.untracked || [])
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
Select All
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Deselect All
</button>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
)}
{/* Status Legend Toggle */}
{!gitStatus?.error && (
<div className="border-b border-gray-200 dark:border-gray-700">
<button
onClick={handleCommit}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
</div>
</div>
</div>
)}
</div>
</div>
)}
</>
)}
{/* File Selection Controls - Only show in changes view */}
{activeView === 'changes' && gitStatus && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
</span>
<div className="flex gap-2">
<button
onClick={() => {
const allFiles = new Set([
...(gitStatus?.modified || []),
...(gitStatus?.added || []),
...(gitStatus?.deleted || []),
...(gitStatus?.untracked || [])
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Select All
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Deselect All
</button>
</div>
</div>
)}
{/* Status Legend Toggle */}
<div className="border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
</div>
</div>
</div>
)}
</div>
{/* File List - Changes View */}
{activeView === 'changes' && (
{/* File List - Changes View - Only show when git is available */}
{activeView === 'changes' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
@@ -690,8 +713,8 @@ function GitPanel({ selectedProject, isMobile }) {
</div>
)}
{/* History View */}
{activeView === 'history' && (
{/* History View - Only show when git is available */}
{activeView === 'history' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
const LoginForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError('Please enter both username and password');
return;
}
setIsLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
<p className="text-muted-foreground mt-2">
Sign in to your Claude Code UI account
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter your credentials to access Claude Code UI
</p>
</div>
</div>
</div>
</div>
);
};
export default LoginForm;

View File

@@ -5,13 +5,35 @@ import { transcribeWithWhisper } from '../utils/whisper';
export function MicButton({ onTranscript, className = '' }) {
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
const [error, setError] = useState(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef(null);
const streamRef = useRef(null);
const chunksRef = useRef([]);
const lastTapRef = useRef(0);
// Version indicator to verify updates
// Check microphone support on mount
useEffect(() => {
const checkSupport = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError('Microphone not supported. Please use HTTPS or a modern browser.');
return;
}
// Additional check for secure context
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError('Microphone requires HTTPS. Please use a secure connection.');
return;
}
setIsSupported(true);
setError(null);
};
checkSupport();
}, []);
// Start recording
const startRecording = async () => {
@@ -20,6 +42,11 @@ export function MicButton({ onTranscript, className = '' }) {
setError(null);
chunksRef.current = [];
// Check if getUserMedia is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
@@ -79,7 +106,23 @@ export function MicButton({ onTranscript, className = '' }) {
console.log('Recording started successfully');
} catch (err) {
console.error('Failed to start recording:', err);
setError('Microphone access denied');
// Provide specific error messages based on error type
let errorMessage = 'Microphone access failed';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please check your audio devices.';
} else if (err.name === 'NotSupportedError') {
errorMessage = 'Microphone not supported by this browser.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Microphone is being used by another application.';
} else if (err.message.includes('HTTPS')) {
errorMessage = err.message;
}
setError(errorMessage);
setState('idle');
}
};
@@ -109,6 +152,11 @@ export function MicButton({ onTranscript, className = '' }) {
e.stopPropagation();
}
// Don't proceed if microphone is not supported
if (!isSupported) {
return;
}
// Debounce for mobile double-tap issue
const now = Date.now();
if (now - lastTapRef.current < 300) {
@@ -138,6 +186,14 @@ export function MicButton({ onTranscript, className = '' }) {
// Button appearance based on state
const getButtonAppearance = () => {
if (!isSupported) {
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-400 cursor-not-allowed',
disabled: true
};
}
switch (state) {
case 'recording':
return {

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import ClaudeLogo from './ClaudeLogo';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
);
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -142,8 +142,8 @@ const QuickSettingsPanel = ({
</label>
</div>
{/* Whisper Dictation Settings */}
<div className="space-y-2">
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
<div className="space-y-2">

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
const SetupForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { register } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
setIsLoading(true);
const result = await register(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
<p className="text-muted-foreground mt-2">
Set up your account to get started
</p>
</div>
{/* Setup Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Setting up...' : 'Create Account'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
This is a single-user system. Only one account can be created.
</p>
</div>
</div>
</div>
</div>
);
};
export default SetupForm;

View File

@@ -322,10 +322,21 @@ function Shell({ selectedProject, selectedSession, isActive }) {
if (isConnecting || isConnected) return;
try {
// Get authentication token
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const configResponse = await fetch('/api/config', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
@@ -343,7 +354,8 @@ function Shell({ selectedProject, selectedSession, isActive }) {
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/shell`;
// Include token in WebSocket URL as query parameter
const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`;
ws.current = new WebSocket(wsUrl);

View File

@@ -6,6 +6,7 @@ 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 { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import { api } from '../utils/api';
// Move formatTimeAgo outside component to avoid recreation on every render
const formatTimeAgo = (dateString, currentTime) => {
@@ -44,7 +45,11 @@ function Sidebar({
onProjectDelete,
isLoading,
onRefresh,
onShowSettings
onShowSettings,
updateAvailable,
latestVersion,
currentVersion,
onShowVersionModal
}) {
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
@@ -61,9 +66,13 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = 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) => {
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();
@@ -128,13 +137,7 @@ function Sidebar({
const saveProjectName = async (projectName) => {
try {
const response = await fetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ displayName: editingName }),
});
const response = await api.renameProject(projectName, editingName);
if (response.ok) {
// Refresh projects to get updated data
@@ -160,9 +163,7 @@ function Sidebar({
}
try {
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
method: 'DELETE',
});
const response = await api.deleteSession(projectName, sessionId);
if (response.ok) {
// Call parent callback if provided
@@ -185,9 +186,7 @@ function Sidebar({
}
try {
const response = await fetch(`/api/projects/${projectName}`, {
method: 'DELETE',
});
const response = await api.deleteProject(projectName);
if (response.ok) {
// Call parent callback if provided
@@ -214,15 +213,7 @@ function Sidebar({
setCreatingProject(true);
try {
const response = await fetch('/api/projects/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: newProjectPath.trim()
}),
});
const response = await api.createProject(newProjectPath.trim());
if (response.ok) {
const result = await response.json();
@@ -1036,6 +1027,50 @@ function Sidebar({
</div>
</ScrollArea>
{/* Version Update Notification */}
{updateAvailable && (
<div className="md:p-2 border-t border-border/50 flex-shrink-0">
{/* Desktop Version Notification */}
<div className="hidden md:block">
<Button
variant="ghost"
className="w-full justify-start gap-3 p-3 h-auto font-normal text-left hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors duration-200 border border-blue-200 dark:border-blue-700 rounded-lg mb-2"
onClick={onShowVersionModal}
>
<div className="relative">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" 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.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
</div>
</Button>
</div>
{/* Mobile Version Notification */}
<div className="md:hidden p-3 pb-2">
<button
className="w-full h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl flex items-center justify-start gap-3 px-4 active:scale-[0.98] transition-all duration-150"
onClick={onShowVersionModal}
>
<div className="relative">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" 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.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
</div>
</button>
</div>
</div>
)}
{/* Settings Section */}
<div className="md:p-2 md:border-t md:border-border flex-shrink-0">
{/* Mobile Settings */}

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

@@ -0,0 +1,158 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../utils/api';
const AuthContext = createContext({
user: null,
token: null,
login: () => {},
register: () => {},
logout: () => {},
isLoading: true,
needsSetup: false,
error: null
});
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('auth-token'));
const [isLoading, setIsLoading] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const [error, setError] = useState(null);
// Check authentication status on mount
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
setIsLoading(true);
setError(null);
// Check if system needs setup
const statusResponse = await api.auth.status();
const statusData = await statusResponse.json();
if (statusData.needsSetup) {
setNeedsSetup(true);
setIsLoading(false);
return;
}
// If we have a token, verify it
if (token) {
try {
const userResponse = await api.auth.user();
if (userResponse.ok) {
const userData = await userResponse.json();
setUser(userData.user);
setNeedsSetup(false);
} else {
// Token is invalid
localStorage.removeItem('auth-token');
setToken(null);
setUser(null);
}
} catch (error) {
console.error('Token verification failed:', error);
localStorage.removeItem('auth-token');
setToken(null);
setUser(null);
}
}
} catch (error) {
console.error('Auth status check failed:', error);
setError('Failed to check authentication status');
} finally {
setIsLoading(false);
}
};
const login = async (username, password) => {
try {
setError(null);
const response = await api.auth.login(username, password);
const data = await response.json();
if (response.ok) {
setToken(data.token);
setUser(data.user);
localStorage.setItem('auth-token', data.token);
return { success: true };
} else {
setError(data.error || 'Login failed');
return { success: false, error: data.error || 'Login failed' };
}
} catch (error) {
console.error('Login error:', error);
const errorMessage = 'Network error. Please try again.';
setError(errorMessage);
return { success: false, error: errorMessage };
}
};
const register = async (username, password) => {
try {
setError(null);
const response = await api.auth.register(username, password);
const data = await response.json();
if (response.ok) {
setToken(data.token);
setUser(data.user);
setNeedsSetup(false);
localStorage.setItem('auth-token', data.token);
return { success: true };
} else {
setError(data.error || 'Registration failed');
return { success: false, error: data.error || 'Registration failed' };
}
} catch (error) {
console.error('Registration error:', error);
const errorMessage = 'Network error. Please try again.';
setError(errorMessage);
return { success: false, error: errorMessage };
}
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('auth-token');
// Optional: Call logout endpoint for logging
if (token) {
api.auth.logout().catch(error => {
console.error('Logout endpoint error:', error);
});
}
};
const value = {
user,
token,
login,
register,
logout,
isLoading,
needsSetup,
error
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,39 @@
// hooks/useVersionCheck.js
import { useState, useEffect } from 'react';
import { version } from '../../package.json';
export const useVersionCheck = (owner, repo) => {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestVersion, setLatestVersion] = useState(null);
useEffect(() => {
const checkVersion = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
const data = await response.json();
// Handle the case where there might not be any releases
if (data.tag_name) {
const latest = data.tag_name.replace(/^v/, '');
setLatestVersion(latest);
setUpdateAvailable(version !== latest);
} else {
// No releases found, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
}
} catch (error) {
console.error('Version check failed:', error);
// On error, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
}
};
checkVersion();
const interval = setInterval(checkVersion, 5 * 60 * 1000); // Check every 5 minutes
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version };
};

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

81
src/utils/api.js Normal file
View File

@@ -0,0 +1,81 @@
// Utility function for authenticated API calls
export const authenticatedFetch = (url, options = {}) => {
const token = localStorage.getItem('auth-token');
const defaultHeaders = {
'Content-Type': 'application/json',
};
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}
return fetch(url, {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
});
};
// API endpoints
export const api = {
// Auth endpoints (no token required)
auth: {
status: () => fetch('/api/auth/status'),
login: (username, password) => fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
register: (username, password) => fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
user: () => authenticatedFetch('/api/auth/user'),
logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),
},
// Protected endpoints
config: () => authenticatedFetch('/api/config'),
projects: () => authenticatedFetch('/api/projects'),
sessions: (projectName, limit = 5, offset = 0) =>
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
sessionMessages: (projectName, sessionId) =>
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
renameProject: (projectName, displayName) =>
authenticatedFetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',
body: JSON.stringify({ displayName }),
}),
deleteSession: (projectName, sessionId) =>
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteProject: (projectName) =>
authenticatedFetch(`/api/projects/${projectName}`, {
method: 'DELETE',
}),
createProject: (path) =>
authenticatedFetch('/api/projects/create', {
method: 'POST',
body: JSON.stringify({ path }),
}),
readFile: (projectName, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
saveFile: (projectName, filePath, content) =>
authenticatedFetch(`/api/projects/${projectName}/file`, {
method: 'PUT',
body: JSON.stringify({ filePath, content }),
}),
getFiles: (projectName) =>
authenticatedFetch(`/api/projects/${projectName}/files`),
transcribe: (formData) =>
authenticatedFetch('/api/transcribe', {
method: 'POST',
body: formData,
headers: {}, // Let browser set Content-Type for FormData
}),
};

View File

@@ -21,10 +21,21 @@ export function useWebSocket() {
const connect = async () => {
try {
// Get authentication token
const token = localStorage.getItem('auth-token');
if (!token) {
console.warn('No authentication token found for WebSocket connection');
return;
}
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const configResponse = await fetch('/api/config', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
@@ -44,7 +55,8 @@ export function useWebSocket() {
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/ws`;
// Include token in WebSocket URL as query parameter
const wsUrl = `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`;
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {

View File

@@ -1,3 +1,5 @@
import { api } from './api';
export async function transcribeWithWhisper(audioBlob, onStatusChange) {
const formData = new FormData();
const fileName = `recording_${Date.now()}.webm`;
@@ -14,10 +16,7 @@ export async function transcribeWithWhisper(audioBlob, onStatusChange) {
onStatusChange('transcribing');
}
const response = await fetch('/api/transcribe', {
method: 'POST',
body: formData,
});
const response = await api.transcribe(formData);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));