diff --git a/package-lock.json b/package-lock.json index 3d51519..623c2d6 100755 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1977163..14b40d8 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.js b/server/index.js index e564115..2a7c45b 100755 --- a/server/index.js +++ b/server/index.js @@ -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 diff --git a/server/routes/auth.js b/server/routes/auth.js index ab04236..82a7c0d 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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') { diff --git a/src/App.jsx b/src/App.jsx index 68e6e21..b9a9d8c 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -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} /> @@ -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} /> )} diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 39be27e..d61ddd3 100755 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -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 {/* Hint text */}
- 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"}
- 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"}
diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..5ebe7ca --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -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 ( +
+
+
+
+ + + +
+

+ Something went wrong +

+
+
+

An error occurred while loading the chat interface.

+ {this.props.showDetails && this.state.error && ( +
+ Error Details +
+                    {this.state.error.toString()}
+                    {this.state.errorInfo && this.state.errorInfo.componentStack}
+                  
+
+ )} +
+
+ +
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/components/MainContent.jsx b/src/components/MainContent.jsx index 677248b..52dc236 100755 --- a/src/components/MainContent.jsx +++ b/src/components/MainContent.jsx @@ -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 */}
- + + +
diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx index 6a5d091..76e9514 100755 --- a/src/components/QuickSettingsPanel.jsx +++ b/src/components/QuickSettingsPanel.jsx @@ -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 = ({
+ {/* Input Settings */} +
+

Input Settings

+ + +

+ When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends. +

+
+ {/* Whisper Dictation Settings - HIDDEN */}

Whisper Dictation