Merge branch 'main' into fix/sidebar-folder-name-display

This commit is contained in:
aëldrin_sagë
2025-07-23 18:59:43 +02:00
committed by GitHub
9 changed files with 307 additions and 69 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "claude-code-ui",
"version": "1.2.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-code-ui",
"version": "1.2.0",
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.24",

View File

@@ -22,7 +22,6 @@
"author": "Claude Code UI Contributors",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.24",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",

View File

@@ -889,7 +889,12 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
// Serve React app for all other routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../dist/index.html'));
if (process.env.NODE_ENV === 'production') {
res.sendFile(path.join(__dirname, '../dist/index.html'));
} else {
// In development, redirect to Vite dev server
res.redirect(`http://localhost:${process.env.VITE_PORT || 3001}`);
}
});
// Helper function to convert permissions to rwx format

View File

@@ -1,6 +1,6 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../database/db.js';
import { userDb, db } from '../database/db.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
const router = express.Router();
@@ -33,31 +33,41 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
}
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
// Use a transaction to prevent race conditions
db.prepare('BEGIN').run();
try {
// Check if users already exist (only allow one user)
const hasUsers = userDb.hasUsers();
if (hasUsers) {
db.prepare('ROLLBACK').run();
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run();
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
db.prepare('ROLLBACK').run();
throw error;
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Registration error:', error);
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {

View File

@@ -64,6 +64,10 @@ function AppContent() {
const saved = localStorage.getItem('autoScrollToBottom');
return saved !== null ? JSON.parse(saved) : true;
});
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
const saved = localStorage.getItem('sendByCtrlEnter');
return saved !== null ? JSON.parse(saved) : false;
});
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
@@ -586,6 +590,7 @@ function AppContent() {
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
</div>
@@ -617,6 +622,11 @@ function AppContent() {
setAutoScrollToBottom(value);
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
}}
sendByCtrlEnter={sendByCtrlEnter}
onSendByCtrlEnterChange={(value) => {
setSendByCtrlEnter(value);
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
}}
isMobile={isMobile}
/>
)}

View File

@@ -26,6 +26,88 @@ import ClaudeStatus from './ClaudeStatus';
import { MicButton } from './MicButton.jsx';
import { api } from '../utils/api';
// Safe localStorage utility to handle quota exceeded errors
const safeLocalStorage = {
setItem: (key, value) => {
try {
// For chat messages, implement compression and size limits
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
// Limit to last 50 messages to prevent storage bloat
if (Array.isArray(parsed) && parsed.length > 50) {
console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`);
const truncated = parsed.slice(-50);
value = JSON.stringify(truncated);
}
} catch (parseError) {
console.warn('Could not parse chat messages for truncation:', parseError);
}
}
localStorage.setItem(key, value);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, clearing old data');
// Clear old chat messages to free up space
const keys = Object.keys(localStorage);
const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort();
// Remove oldest chat data first, keeping only the 3 most recent projects
if (chatKeys.length > 3) {
chatKeys.slice(0, chatKeys.length - 3).forEach(k => {
localStorage.removeItem(k);
console.log(`Removed old chat data: ${k}`);
});
}
// If still failing, clear draft inputs too
const draftKeys = keys.filter(k => k.startsWith('draft_input_'));
draftKeys.forEach(k => {
localStorage.removeItem(k);
});
// Try again with reduced data
try {
localStorage.setItem(key, value);
} catch (retryError) {
console.error('Failed to save to localStorage even after cleanup:', retryError);
// Last resort: Try to save just the last 10 messages
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length > 10) {
const minimal = parsed.slice(-10);
localStorage.setItem(key, JSON.stringify(minimal));
console.warn('Saved only last 10 messages due to quota constraints');
}
} catch (finalError) {
console.error('Final save attempt failed:', finalError);
}
}
}
} else {
console.error('localStorage error:', error);
}
}
},
getItem: (key) => {
try {
return localStorage.getItem(key);
} catch (error) {
console.error('localStorage getItem error:', error);
return null;
}
},
removeItem: (key) => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error('localStorage removeItem error:', error);
}
}
};
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
const isGrouped = prevMessage && prevMessage.type === message.type &&
@@ -1016,16 +1098,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
//
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) {
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
}
return '';
});
const [chatMessages, setChatMessages] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
const saved = localStorage.getItem(`chat_messages_${selectedProject.name}`);
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
return saved ? JSON.parse(saved) : [];
}
return [];
@@ -1310,16 +1392,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Persist input draft to localStorage
useEffect(() => {
if (selectedProject && input !== '') {
localStorage.setItem(`draft_input_${selectedProject.name}`, input);
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
} else if (selectedProject && input === '') {
localStorage.removeItem(`draft_input_${selectedProject.name}`);
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
}, [input, selectedProject]);
// Persist chat messages to localStorage
useEffect(() => {
if (selectedProject && chatMessages.length > 0) {
localStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
}
}, [chatMessages, selectedProject]);
@@ -1327,7 +1409,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
useEffect(() => {
if (selectedProject) {
// Always load saved input draft for the project
const savedInput = localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
if (savedInput !== input) {
setInput(savedInput);
}
@@ -1546,7 +1628,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Clear persisted chat messages after successful completion
if (selectedProject && latestMessage.exitCode === 0) {
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
@@ -1803,14 +1885,33 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle image files from drag & drop or file picker
const handleImageFiles = useCallback((files) => {
const validFiles = files.filter(file => {
if (!file.type.startsWith('image/')) {
try {
// Validate file object and properties
if (!file || typeof file !== 'object') {
console.warn('Invalid file object:', file);
return false;
}
if (!file.type || !file.type.startsWith('image/')) {
return false;
}
if (!file.size || file.size > 5 * 1024 * 1024) {
// Safely get file name with fallback
const fileName = file.name || 'Unknown file';
setImageErrors(prev => {
const newMap = new Map(prev);
newMap.set(fileName, 'File too large (max 5MB)');
return newMap;
});
return false;
}
return true;
} catch (error) {
console.error('Error validating file:', error, file);
return false;
}
if (file.size > 5 * 1024 * 1024) {
setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)'));
return false;
}
return true;
});
if (validFiles.length > 0) {
@@ -1866,7 +1967,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
});
try {
const token = localStorage.getItem('auth-token');
const token = safeLocalStorage.getItem('auth-token');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
@@ -1929,7 +2030,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Get tools settings from localStorage
const getToolsSettings = () => {
try {
const savedSettings = localStorage.getItem('claude-tools-settings');
const savedSettings = safeLocalStorage.getItem('claude-tools-settings');
if (savedSettings) {
return JSON.parse(savedSettings);
}
@@ -1975,7 +2076,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Clear the saved draft since message was sent
if (selectedProject) {
localStorage.removeItem(`draft_input_${selectedProject.name}`);
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
};
@@ -2024,14 +2125,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
if (e.key === 'Enter') {
// If we're in composition, don't send message
if (e.nativeEvent.isComposing) {
return; // Let IME handle the Enter key
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
// Ctrl+Enter or Cmd+Enter: Send message
e.preventDefault();
handleSubmit(e);
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
// Plain Enter: Also send message (keeping original behavior)
e.preventDefault();
handleSubmit(e);
// Plain Enter: Send message only if not in IME composition
if (!sendByCtrlEnter) {
e.preventDefault();
handleSubmit(e);
}
}
// Shift+Enter: Allow default behavior (new line)
}
@@ -2462,12 +2570,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
{/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
Press Enter to send Shift+Enter for new line Tab to change modes @ to reference files
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
</div>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
}`}>
Enter to send Tab for modes @ for files
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
: "Enter to send • Tab for modes • @ for files"}
</div>
</form>
</div>

View File

@@ -0,0 +1,73 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error details
console.error('ErrorBoundary caught an error:', error, errorInfo);
// You can also log the error to an error reporting service here
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{this.props.showDetails && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onRetry) this.props.onRetry();
}}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -17,6 +17,7 @@ import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
import Shell from './Shell';
import GitPanel from './GitPanel';
import ErrorBoundary from './ErrorBoundary';
function MainContent({
selectedProject,
@@ -39,7 +40,8 @@ function MainContent({
onShowSettings, // Show tools settings panel
autoExpandTools, // Auto-expand tool accordions
showRawParameters, // Show raw parameters in tool accordions
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
}) {
const [editingFile, setEditingFile] = useState(null);
@@ -269,23 +271,26 @@ function MainContent({
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
/>
</ErrorBoundary>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
<FileTree selectedProject={selectedProject} />

View File

@@ -11,7 +11,8 @@ import {
Mic,
Brain,
Sparkles,
FileText
FileText,
Languages
} from 'lucide-react';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
onShowRawParametersChange,
autoScrollToBottom,
onAutoScrollChange,
sendByCtrlEnter,
onSendByCtrlEnterChange,
isMobile
}) => {
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
</label>
</div>
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>