/* * ChatInterface.jsx - Chat Component with Session Protection Integration * * SESSION PROTECTION INTEGRATION: * =============================== * * This component integrates with the Session Protection System to prevent project updates * from interrupting active conversations: * * Key Integration Points: * 1. handleSubmit() - Marks session as active when user sends message (including temp ID for new sessions) * 2. session-created handler - Replaces temporary session ID with real WebSocket session ID * 3. claude-complete handler - Marks session as inactive when conversation finishes * 4. session-aborted handler - Marks session as inactive when conversation is aborted * * This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. */ import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; import ReactMarkdown from 'react-markdown'; import { useDropzone } from 'react-dropzone'; import TodoList from './TodoList'; import ClaudeLogo from './ClaudeLogo.jsx'; 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 && prevMessage.type === 'assistant' && !prevMessage.isToolUse && !message.isToolUse; const messageRef = React.useRef(null); const [isExpanded, setIsExpanded] = React.useState(false); React.useEffect(() => { if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && !isExpanded) { setIsExpanded(true); // Find all details elements and open them const details = messageRef.current.querySelectorAll('details'); details.forEach(detail => { detail.open = true; }); } }); }, { threshold: 0.1 } ); observer.observe(messageRef.current); return () => { if (messageRef.current) { observer.unobserve(messageRef.current); } }; }, [autoExpandTools, isExpanded, message.isToolUse]); return (
{message.type === 'user' ? ( /* User message bubble on the right */
{message.content}
{message.images && message.images.length > 0 && (
{message.images.map((img, idx) => ( {img.name} window.open(img.data, '_blank')} /> ))}
)}
{new Date(message.timestamp).toLocaleTimeString()}
{!isGrouped && (
U
)}
) : ( /* Claude/Error messages on the left */
{!isGrouped && (
{message.type === 'error' ? (
!
) : (
)}
{message.type === 'error' ? 'Error' : 'Claude'}
)}
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
Using {message.toolName} {message.toolId}
{onShowSettings && ( )}
{message.toolInput && message.toolName === 'Edit' && (() => { try { const input = JSON.parse(message.toolInput); if (input.file_path && input.old_string && input.new_string) { return (
📝 View edit diff for
Diff
{createDiff(input.old_string, input.new_string).map((diffLine, i) => (
{diffLine.type === 'removed' ? '-' : '+'} {diffLine.content}
))}
{showRawParameters && (
View raw parameters
                                  {message.toolInput}
                                
)}
); } } catch (e) { // Fall back to raw display if parsing fails } return (
View input parameters
                        {message.toolInput}
                      
); })()} {message.toolInput && message.toolName !== 'Edit' && (() => { // Debug log to see what we're dealing with console.log('Tool display - name:', message.toolName, 'input type:', typeof message.toolInput); // Special handling for Write tool if (message.toolName === 'Write') { console.log('Write tool detected, toolInput:', message.toolInput); try { let input; // Handle both JSON string and already parsed object if (typeof message.toolInput === 'string') { input = JSON.parse(message.toolInput); } else { input = message.toolInput; } console.log('Parsed Write input:', input); if (input.file_path && input.content !== undefined) { return (
📄 Creating new file:
New File
{createDiff('', input.content).map((diffLine, i) => (
{diffLine.type === 'removed' ? '-' : '+'} {diffLine.content}
))}
{showRawParameters && (
View raw parameters
                                    {message.toolInput}
                                  
)}
); } } catch (e) { // Fall back to regular display } } // Special handling for TodoWrite tool if (message.toolName === 'TodoWrite') { try { const input = JSON.parse(message.toolInput); if (input.todos && Array.isArray(input.todos)) { return (
Updating Todo List
{showRawParameters && (
View raw parameters
                                    {message.toolInput}
                                  
)}
); } } catch (e) { // Fall back to regular display } } // Special handling for Bash tool if (message.toolName === 'Bash') { try { const input = JSON.parse(message.toolInput); return (
Running command
Terminal
$ {input.command}
{input.description && (
{input.description}
)} {showRawParameters && (
View raw parameters
                                  {message.toolInput}
                                
)}
); } catch (e) { // Fall back to regular display } } // Special handling for Read tool if (message.toolName === 'Read') { try { const input = JSON.parse(message.toolInput); if (input.file_path) { const filename = input.file_path.split('/').pop(); return (
Read{' '}
); } } catch (e) { // Fall back to regular display } } // Special handling for exit_plan_mode tool if (message.toolName === 'exit_plan_mode') { try { const input = JSON.parse(message.toolInput); if (input.plan) { // Replace escaped newlines with actual newlines const planContent = input.plan.replace(/\\n/g, '\n'); return (
📋 View implementation plan
{planContent}
); } } catch (e) { // Fall back to regular display } } // Regular tool input display for other tools return (
View input parameters
                        {message.toolInput}
                      
); })()} {/* Tool Result Section */} {message.toolResult && (
{message.toolResult.isError ? ( ) : ( )}
{message.toolResult.isError ? 'Tool Error' : 'Tool Result'}
{(() => { const content = String(message.toolResult.content || ''); // Special handling for TodoWrite/TodoRead results if ((message.toolName === 'TodoWrite' || message.toolName === 'TodoRead') && (content.includes('Todos have been modified successfully') || content.includes('Todo list') || (content.startsWith('[') && content.includes('"content"') && content.includes('"status"')))) { try { // Try to parse if it looks like todo JSON data let todos = null; if (content.startsWith('[')) { todos = JSON.parse(content); } else if (content.includes('Todos have been modified successfully')) { // For TodoWrite success messages, we don't have the data in the result return (
Todo list has been updated successfully
); } if (todos && Array.isArray(todos)) { return (
Current Todo List
); } } catch (e) { // Fall through to regular handling } } // Special handling for exit_plan_mode tool results if (message.toolName === 'exit_plan_mode') { try { // The content should be JSON with a "plan" field const parsed = JSON.parse(content); if (parsed.plan) { // Replace escaped newlines with actual newlines const planContent = parsed.plan.replace(/\\n/g, '\n'); return (
Implementation Plan
{planContent}
); } } catch (e) { // Fall through to regular handling } } // Special handling for interactive prompts if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') { const lines = content.split('\n'); const promptIndex = lines.findIndex(line => line.includes('Do you want to proceed?')); const beforePrompt = lines.slice(0, promptIndex).join('\n'); const promptLines = lines.slice(promptIndex); // Extract the question and options const questionLine = promptLines.find(line => line.includes('Do you want to proceed?')) || ''; const options = []; // Parse numbered options (1. Yes, 2. No, etc.) promptLines.forEach(line => { const optionMatch = line.match(/^\s*(\d+)\.\s+(.+)$/); if (optionMatch) { options.push({ number: optionMatch[1], text: optionMatch[2].trim() }); } }); // Find which option was selected (usually indicated by "> 1" or similar) const selectedMatch = content.match(/>\s*(\d+)/); const selectedOption = selectedMatch ? selectedMatch[1] : null; return (
{beforePrompt && (
{beforePrompt}
)}

Interactive Prompt

{questionLine}

{/* Option buttons */}
{options.map((option) => ( ))}
{selectedOption && (

✓ Claude selected option {selectedOption}

In the CLI, you would select this option interactively using arrow keys or by typing the number.

)}
); } const fileEditMatch = content.match(/The file (.+?) has been updated\./); if (fileEditMatch) { return (
File updated successfully
); } // Handle Write tool output for file creation const fileCreateMatch = content.match(/(?:The file|File) (.+?) has been (?:created|written)(?: successfully)?\.?/); if (fileCreateMatch) { return (
File created successfully
); } // Special handling for Write tool - hide content if it's just the file content if (message.toolName === 'Write' && !message.toolResult.isError) { // For Write tool, the diff is already shown in the tool input section // So we just show a success message here return (
File written successfully

The file content is displayed in the diff view above

); } if (content.includes('cat -n') && content.includes('→')) { return (
View file content
{content}
); } if (content.length > 300) { return (
View full output ({content.length} chars)
{content}
); } return (
{content}
); })()}
)}
) : message.isInteractivePrompt ? ( // Special handling for interactive prompts

Interactive Prompt

{(() => { const lines = message.content.split('\n').filter(line => line.trim()); const questionLine = lines.find(line => line.includes('?')) || lines[0] || ''; const options = []; // Parse the menu options lines.forEach(line => { // Match lines like "❯ 1. Yes" or " 2. No" const optionMatch = line.match(/[❯\s]*(\d+)\.\s+(.+)/); if (optionMatch) { const isSelected = line.includes('❯'); options.push({ number: optionMatch[1], text: optionMatch[2].trim(), isSelected }); } }); return ( <>

{questionLine}

{/* Option buttons */}
{options.map((option) => ( ))}

⏳ Waiting for your response in the CLI

Please select an option in your terminal where Claude is running.

); })()}
) : message.isToolUse && message.toolName === 'Read' ? ( // Simple Read tool indicator (() => { try { const input = JSON.parse(message.toolInput); if (input.file_path) { const filename = input.file_path.split('/').pop(); return (
📖 Read{' '}
); } } catch (e) { return (
📖 Read file
); } })() ) : message.isToolUse && message.toolName === 'TodoWrite' ? ( // Simple TodoWrite tool indicator with tasks (() => { try { const input = JSON.parse(message.toolInput); if (input.todos && Array.isArray(input.todos)) { return (
📝 Update todo list
); } } catch (e) { return (
📝 Update todo list
); } })() ) : message.isToolUse && message.toolName === 'TodoRead' ? ( // Simple TodoRead tool indicator
📋 Read todo list
) : (
{message.type === 'assistant' ? (
{ return inline ? ( {children} ) : (
{children}
); }, blockquote: ({children}) => (
{children}
), a: ({href, children}) => ( {children} ), p: ({children}) => (
{children}
) }} > {String(message.content || '')}
) : (
{message.content}
)}
)}
{new Date(message.timestamp).toLocaleTimeString()}
)}
); }); // ImageAttachment component for displaying image previews const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { const [preview, setPreview] = useState(null); useEffect(() => { const url = URL.createObjectURL(file); setPreview(url); return () => URL.revokeObjectURL(url); }, [file]); return (
{file.name} {uploadProgress !== undefined && uploadProgress < 100 && (
{uploadProgress}%
)} {error && (
)}
); }; // ChatInterface: Main chat component with Session Protection System integration // // Session Protection System prevents automatic project updates from interrupting active conversations: // - onSessionActive: Called when user sends message to mark session as protected // - onSessionInactive: Called when conversation completes/aborts to re-enable updates // - 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, sendByCtrlEnter }) { const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; } return ''; }); const [chatMessages, setChatMessages] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); return saved ? JSON.parse(saved) : []; } return []; }); const [isLoading, setIsLoading] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [isInputFocused, setIsInputFocused] = useState(false); const [sessionMessages, setSessionMessages] = useState([]); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [permissionMode, setPermissionMode] = useState('default'); const [attachedImages, setAttachedImages] = useState([]); const [uploadingImages, setUploadingImages] = useState(new Map()); const [imageErrors, setImageErrors] = useState(new Map()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); const [debouncedInput, setDebouncedInput] = useState(''); const [showFileDropdown, setShowFileDropdown] = useState(false); const [fileList, setFileList] = useState([]); const [filteredFiles, setFilteredFiles] = useState([]); const [selectedFileIndex, setSelectedFileIndex] = useState(-1); const [cursorPosition, setCursorPosition] = useState(0); const [atSymbolPosition, setAtSymbolPosition] = useState(-1); const [canAbortSession, setCanAbortSession] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const scrollPositionRef = useRef({ height: 0, top: 0 }); const [showCommandMenu, setShowCommandMenu] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [filteredCommands, setFilteredCommands] = useState([]); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1); const [slashPosition, setSlashPosition] = useState(-1); const [visibleMessageCount, setVisibleMessageCount] = useState(100); const [claudeStatus, setClaudeStatus] = useState(null); // Memoized diff calculation to prevent recalculating on every render const createDiff = useMemo(() => { const cache = new Map(); return (oldStr, newStr) => { const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`; if (cache.has(key)) { return cache.get(key); } const result = calculateDiff(oldStr, newStr); cache.set(key, result); if (cache.size > 100) { const firstKey = cache.keys().next().value; cache.delete(firstKey); } return result; }; }, []); // Load session messages from API const loadSessionMessages = useCallback(async (projectName, sessionId) => { if (!projectName || !sessionId) return []; setIsLoadingSessionMessages(true); try { const response = await api.sessionMessages(projectName, sessionId); if (!response.ok) { throw new Error('Failed to load session messages'); } const data = await response.json(); return data.messages || []; } catch (error) { console.error('Error loading session messages:', error); return []; } finally { setIsLoadingSessionMessages(false); } }, []); // Actual diff calculation function const calculateDiff = (oldStr, newStr) => { const oldLines = oldStr.split('\n'); const newLines = newStr.split('\n'); // Simple diff algorithm - find common lines and differences const diffLines = []; let oldIndex = 0; let newIndex = 0; while (oldIndex < oldLines.length || newIndex < newLines.length) { const oldLine = oldLines[oldIndex]; const newLine = newLines[newIndex]; if (oldIndex >= oldLines.length) { // Only new lines remaining diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 }); newIndex++; } else if (newIndex >= newLines.length) { // Only old lines remaining diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 }); oldIndex++; } else if (oldLine === newLine) { // Lines are the same - skip in diff view (or show as context) oldIndex++; newIndex++; } else { // Lines are different diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 }); diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 }); oldIndex++; newIndex++; } } return diffLines; }; const convertSessionMessages = (rawMessages) => { const converted = []; const toolResults = new Map(); // Map tool_use_id to tool result // First pass: collect all tool results for (const msg of rawMessages) { if (msg.message?.role === 'user' && Array.isArray(msg.message?.content)) { for (const part of msg.message.content) { if (part.type === 'tool_result') { toolResults.set(part.tool_use_id, { content: part.content, isError: part.is_error, timestamp: new Date(msg.timestamp || Date.now()) }); } } } } // Second pass: process messages and attach tool results to tool uses for (const msg of rawMessages) { // Handle user messages if (msg.message?.role === 'user' && msg.message?.content) { let content = ''; let messageType = 'user'; if (Array.isArray(msg.message.content)) { // Handle array content, but skip tool results (they're attached to tool uses) const textParts = []; for (const part of msg.message.content) { if (part.type === 'text') { textParts.push(part.text); } // Skip tool_result parts - they're handled in the first pass } content = textParts.join('\n'); } else if (typeof msg.message.content === 'string') { content = msg.message.content; } else { content = String(msg.message.content); } // Skip command messages and empty content if (content && !content.startsWith('') && !content.startsWith('[Request interrupted')) { converted.push({ type: messageType, content: content, timestamp: msg.timestamp || new Date().toISOString() }); } } // Handle assistant messages else if (msg.message?.role === 'assistant' && msg.message?.content) { if (Array.isArray(msg.message.content)) { for (const part of msg.message.content) { if (part.type === 'text') { converted.push({ type: 'assistant', content: part.text, timestamp: msg.timestamp || new Date().toISOString() }); } else if (part.type === 'tool_use') { // Get the corresponding tool result const toolResult = toolResults.get(part.id); converted.push({ type: 'assistant', content: '', timestamp: msg.timestamp || new Date().toISOString(), isToolUse: true, toolName: part.name, toolInput: JSON.stringify(part.input), toolResult: toolResult ? (typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content)) : null, toolError: toolResult?.isError || false, toolResultTimestamp: toolResult?.timestamp || new Date() }); } } } else if (typeof msg.message.content === 'string') { converted.push({ type: 'assistant', content: msg.message.content, timestamp: msg.timestamp || new Date().toISOString() }); } } } return converted; }; // Memoize expensive convertSessionMessages operation const convertedMessages = useMemo(() => { return convertSessionMessages(sessionMessages); }, [sessionMessages]); // Define scroll functions early to avoid hoisting issues in useEffect dependencies const scrollToBottom = useCallback(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; setIsUserScrolledUp(false); } }, []); // Check if user is near the bottom of the scroll container const isNearBottom = useCallback(() => { if (!scrollContainerRef.current) return false; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; // 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 nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); } }, [isNearBottom]); useEffect(() => { // Load session messages when session changes const loadMessages = async () => { if (selectedSession && selectedProject) { setCurrentSessionId(selectedSession.id); // Only load messages from API if this is a user-initiated session change // For system-initiated changes, preserve existing messages and rely on WebSocket if (!isSystemSessionChange) { const messages = await loadSessionMessages(selectedProject.name, selectedSession.id); setSessionMessages(messages); // convertedMessages will be automatically updated via useMemo // Scroll to bottom after loading session messages if auto-scroll is enabled if (autoScrollToBottom) { setTimeout(() => scrollToBottom(), 200); } } else { // Reset the flag after handling system session change setIsSystemSessionChange(false); } } else { setChatMessages([]); setSessionMessages([]); setCurrentSessionId(null); } }; loadMessages(); }, [selectedSession, selectedProject, loadSessionMessages, scrollToBottom, isSystemSessionChange]); // Update chatMessages when convertedMessages changes useEffect(() => { if (sessionMessages.length > 0) { setChatMessages(convertedMessages); } }, [convertedMessages, sessionMessages]); // Notify parent when input focus changes useEffect(() => { if (onInputFocusChange) { onInputFocusChange(isInputFocused); } }, [isInputFocused, onInputFocusChange]); // Persist input draft to localStorage useEffect(() => { if (selectedProject && input !== '') { safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); } else if (selectedProject && input === '') { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } }, [input, selectedProject]); // Persist chat messages to localStorage useEffect(() => { if (selectedProject && chatMessages.length > 0) { safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); } }, [chatMessages, selectedProject]); // Load saved state when project changes (but don't interfere with session loading) useEffect(() => { if (selectedProject) { // Always load saved input draft for the project const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; if (savedInput !== input) { setInput(savedInput); } } }, [selectedProject?.name]); useEffect(() => { // Handle WebSocket messages if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; switch (latestMessage.type) { case 'session-created': // New session created by Claude CLI - we receive the real session ID here // Store it temporarily until conversation completes (prevents premature session association) if (latestMessage.sessionId && !currentSessionId) { sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); // Session Protection: Replace temporary "new-session-*" identifier with real session ID // This maintains protection continuity - no gap between temp ID and real ID // The temporary session is removed and real session is marked as active if (onReplaceTemporarySession) { onReplaceTemporarySession(latestMessage.sessionId); } } break; case 'claude-response': const messageData = latestMessage.data.message || latestMessage.data; // Handle Claude CLI session duplication bug workaround: // When resuming a session, Claude CLI creates a new session instead of resuming. // We detect this by checking for system/init messages with session_id that differs // from our current session. When found, we need to switch the user to the new session. if (latestMessage.data.type === 'system' && latestMessage.data.subtype === 'init' && latestMessage.data.session_id && currentSessionId && latestMessage.data.session_id !== currentSessionId) { console.log('🔄 Claude CLI session duplication detected:', { originalSession: currentSessionId, newSession: latestMessage.data.session_id }); // Mark this as a system-initiated session change to preserve messages setIsSystemSessionChange(true); // Switch to the new session using React Router navigation // This triggers the session loading logic in App.jsx without a page reload if (onNavigateToSession) { onNavigateToSession(latestMessage.data.session_id); } return; // Don't process the message further, let the navigation handle it } // Handle system/init for new sessions (when currentSessionId is null) if (latestMessage.data.type === 'system' && latestMessage.data.subtype === 'init' && latestMessage.data.session_id && !currentSessionId) { console.log('🔄 New session init detected:', { newSession: latestMessage.data.session_id }); // Mark this as a system-initiated session change to preserve messages setIsSystemSessionChange(true); // Switch to the new session if (onNavigateToSession) { onNavigateToSession(latestMessage.data.session_id); } return; // Don't process the message further, let the navigation handle it } // For system/init messages that match current session, just ignore them if (latestMessage.data.type === 'system' && latestMessage.data.subtype === 'init' && latestMessage.data.session_id && currentSessionId && latestMessage.data.session_id === currentSessionId) { console.log('🔄 System init message for current session, ignoring'); return; // Don't process the message further } // Handle different types of content in the response if (Array.isArray(messageData.content)) { for (const part of messageData.content) { if (part.type === 'tool_use') { // Add tool use message const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; setChatMessages(prev => [...prev, { type: 'assistant', content: '', timestamp: new Date(), isToolUse: true, toolName: part.name, toolInput: toolInput, toolId: part.id, toolResult: null // Will be updated when result comes in }]); } else if (part.type === 'text' && part.text?.trim()) { // Check for usage limit message and format it user-friendly let content = part.text; if (content.includes('Claude AI usage limit reached|')) { const parts = content.split('|'); if (parts.length === 2) { const timestamp = parseInt(parts[1]); if (!isNaN(timestamp)) { const resetTime = new Date(timestamp * 1000); content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`; } } } // Add regular text message setChatMessages(prev => [...prev, { type: 'assistant', content: content, timestamp: new Date() }]); } } } else if (typeof messageData.content === 'string' && messageData.content.trim()) { // Check for usage limit message and format it user-friendly let content = messageData.content; if (content.includes('Claude AI usage limit reached|')) { const parts = content.split('|'); if (parts.length === 2) { const timestamp = parseInt(parts[1]); if (!isNaN(timestamp)) { const resetTime = new Date(timestamp * 1000); content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`; } } } // Add regular text message setChatMessages(prev => [...prev, { type: 'assistant', content: content, timestamp: new Date() }]); } // Handle tool results from user messages (these come separately) if (messageData.role === 'user' && Array.isArray(messageData.content)) { for (const part of messageData.content) { if (part.type === 'tool_result') { // Find the corresponding tool use and update it with the result setChatMessages(prev => prev.map(msg => { if (msg.isToolUse && msg.toolId === part.tool_use_id) { return { ...msg, toolResult: { content: part.content, isError: part.is_error, timestamp: new Date() } }; } return msg; })); } } } break; case 'claude-output': setChatMessages(prev => [...prev, { type: 'assistant', content: latestMessage.data, timestamp: new Date() }]); break; case 'claude-interactive-prompt': // Handle interactive prompts from CLI setChatMessages(prev => [...prev, { type: 'assistant', content: latestMessage.data, timestamp: new Date(), isInteractivePrompt: true }]); break; case 'claude-error': setChatMessages(prev => [...prev, { type: 'error', content: `Error: ${latestMessage.error}`, timestamp: new Date() }]); break; case 'claude-complete': setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); // Session Protection: Mark session as inactive to re-enable automatic project updates // Conversation is complete, safe to allow project updates again // Use real session ID if available, otherwise use pending session ID const activeSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId'); if (activeSessionId && onSessionInactive) { onSessionInactive(activeSessionId); } // If we have a pending session ID and the conversation completed successfully, use it const pendingSessionId = sessionStorage.getItem('pendingSessionId'); if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { setCurrentSessionId(pendingSessionId); sessionStorage.removeItem('pendingSessionId'); } // Clear persisted chat messages after successful completion if (selectedProject && latestMessage.exitCode === 0) { safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); } break; case 'session-aborted': setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); // Session Protection: Mark session as inactive when aborted // User or system aborted the conversation, re-enable project updates if (currentSessionId && onSessionInactive) { onSessionInactive(currentSessionId); } setChatMessages(prev => [...prev, { type: 'assistant', content: 'Session interrupted by user.', timestamp: new Date() }]); break; case 'claude-status': // Handle Claude working status messages console.log('🔔 Received claude-status message:', latestMessage); const statusData = latestMessage.data; if (statusData) { // Parse the status message to extract relevant information let statusInfo = { text: 'Working...', tokens: 0, can_interrupt: true }; // Check for different status message formats if (statusData.message) { statusInfo.text = statusData.message; } else if (statusData.status) { statusInfo.text = statusData.status; } else if (typeof statusData === 'string') { statusInfo.text = statusData; } // Extract token count if (statusData.tokens) { statusInfo.tokens = statusData.tokens; } else if (statusData.token_count) { statusInfo.tokens = statusData.token_count; } // Check if can interrupt if (statusData.can_interrupt !== undefined) { statusInfo.can_interrupt = statusData.can_interrupt; } console.log('📊 Setting claude status:', statusInfo); setClaudeStatus(statusInfo); setIsLoading(true); setCanAbortSession(statusInfo.can_interrupt); } break; } } }, [messages]); // Load file list when project changes useEffect(() => { if (selectedProject) { fetchProjectFiles(); } }, [selectedProject]); const fetchProjectFiles = async () => { try { const response = await api.getFiles(selectedProject.name); if (response.ok) { const files = await response.json(); // Flatten the file tree to get all file paths const flatFiles = flattenFileTree(files); setFileList(flatFiles); } } catch (error) { console.error('Error fetching files:', error); } }; const flattenFileTree = (files, basePath = '') => { let result = []; for (const file of files) { const fullPath = basePath ? `${basePath}/${file.name}` : file.name; if (file.type === 'directory' && file.children) { result = result.concat(flattenFileTree(file.children, fullPath)); } else if (file.type === 'file') { result.push({ name: file.name, path: fullPath, relativePath: file.path }); } } return result; }; // Handle @ symbol detection and file filtering useEffect(() => { const textBeforeCursor = input.slice(0, cursorPosition); const lastAtIndex = textBeforeCursor.lastIndexOf('@'); if (lastAtIndex !== -1) { const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); // Check if there's a space after the @ symbol (which would end the file reference) if (!textAfterAt.includes(' ')) { setAtSymbolPosition(lastAtIndex); setShowFileDropdown(true); // Filter files based on the text after @ const filtered = fileList.filter(file => file.name.toLowerCase().includes(textAfterAt.toLowerCase()) || file.path.toLowerCase().includes(textAfterAt.toLowerCase()) ).slice(0, 10); // Limit to 10 results setFilteredFiles(filtered); setSelectedFileIndex(-1); } else { setShowFileDropdown(false); setAtSymbolPosition(-1); } } else { setShowFileDropdown(false); setAtSymbolPosition(-1); } }, [input, cursorPosition, fileList]); // Debounced input handling useEffect(() => { const timer = setTimeout(() => { setDebouncedInput(input); }, 150); // 150ms debounce return () => clearTimeout(timer); }, [input]); // Show only recent messages for better performance const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) { return chatMessages; } return chatMessages.slice(-visibleMessageCount); }, [chatMessages, visibleMessageCount]); // Capture scroll position before render when auto-scroll is disabled useEffect(() => { if (!autoScrollToBottom && scrollContainerRef.current) { const container = scrollContainerRef.current; scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop }; } }); useEffect(() => { // 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(), 50); // Small delay to ensure DOM is updated } } else { // When auto-scroll is disabled, preserve the visual position const container = scrollContainerRef.current; const prevHeight = scrollPositionRef.current.height; const prevTop = scrollPositionRef.current.top; const newHeight = container.scrollHeight; const heightDiff = newHeight - prevHeight; // If content was added above the current view, adjust scroll position if (heightDiff > 0 && prevTop > 0) { container.scrollTop = prevTop + heightDiff; } } } }, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]); // Scroll to bottom when component mounts with existing messages or when messages first load useEffect(() => { 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 } }, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear // Add scroll event listener to detect user scrolling useEffect(() => { const scrollContainer = scrollContainerRef.current; if (scrollContainer) { scrollContainer.addEventListener('scroll', handleScroll); return () => scrollContainer.removeEventListener('scroll', handleScroll); } }, [handleScroll]); // Initial textarea setup useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; // Check if initially expanded const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(isExpanded); } }, []); // Only run once on mount // Reset textarea height when input is cleared programmatically useEffect(() => { if (textareaRef.current && !input.trim()) { textareaRef.current.style.height = 'auto'; setIsTextareaExpanded(false); } }, [input]); const handleTranscript = useCallback((text) => { if (text.trim()) { setInput(prevInput => { const newInput = prevInput.trim() ? `${prevInput} ${text}` : text; // Update textarea height after setting new content setTimeout(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; // Check if expanded after transcript const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(isExpanded); } }, 0); return newInput; }); } }, []); // Load earlier messages by increasing the visible message count const loadEarlierMessages = useCallback(() => { setVisibleMessageCount(prevCount => prevCount + 100); }, []); // Handle image files from drag & drop or file picker const handleImageFiles = useCallback((files) => { const validFiles = files.filter(file => { 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 (validFiles.length > 0) { setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images } }, []); // Handle clipboard paste for images const handlePaste = useCallback(async (e) => { const items = Array.from(e.clipboardData.items); for (const item of items) { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { handleImageFiles([file]); } } } // Fallback for some browsers/platforms if (items.length === 0 && e.clipboardData.files.length > 0) { const files = Array.from(e.clipboardData.files); const imageFiles = files.filter(f => f.type.startsWith('image/')); if (imageFiles.length > 0) { handleImageFiles(imageFiles); } } }, [handleImageFiles]); // Setup dropzone const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'] }, maxSize: 5 * 1024 * 1024, // 5MB maxFiles: 5, onDrop: handleImageFiles, noClick: true, // We'll use our own button noKeyboard: true }); const handleSubmit = async (e) => { e.preventDefault(); if (!input.trim() || isLoading || !selectedProject) return; // Upload images first if any let uploadedImages = []; if (attachedImages.length > 0) { const formData = new FormData(); attachedImages.forEach(file => { formData.append('images', file); }); try { const token = safeLocalStorage.getItem('auth-token'); const headers = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, { method: 'POST', headers: headers, body: formData }); if (!response.ok) { throw new Error('Failed to upload images'); } const result = await response.json(); uploadedImages = result.images; } catch (error) { console.error('Image upload failed:', error); setChatMessages(prev => [...prev, { type: 'error', content: `Failed to upload images: ${error.message}`, timestamp: new Date() }]); return; } } const userMessage = { type: 'user', content: input, images: uploadedImages, timestamp: new Date() }; setChatMessages(prev => [...prev, userMessage]); setIsLoading(true); setCanAbortSession(true); // Set a default status when starting setClaudeStatus({ text: 'Processing', tokens: 0, can_interrupt: true }); // 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: // 1. Existing sessions: Use the real currentSessionId // 2. New sessions: Generate temporary identifier "new-session-{timestamp}" since real ID comes via WebSocket later // This ensures no gap in protection between message send and session creation const sessionToActivate = currentSessionId || `new-session-${Date.now()}`; if (onSessionActive) { onSessionActive(sessionToActivate); } // Get tools settings from localStorage const getToolsSettings = () => { try { const savedSettings = safeLocalStorage.getItem('claude-tools-settings'); if (savedSettings) { return JSON.parse(savedSettings); } } catch (error) { console.error('Error loading tools settings:', error); } return { allowedTools: [], disallowedTools: [], skipPermissions: false }; }; const toolsSettings = getToolsSettings(); // Send command to Claude CLI via WebSocket with images sendMessage({ type: 'claude-command', command: input, options: { projectPath: selectedProject.path, cwd: selectedProject.fullPath, sessionId: currentSessionId, resume: !!currentSessionId, toolsSettings: toolsSettings, permissionMode: permissionMode, images: uploadedImages // Pass images to backend } }); setInput(''); setAttachedImages([]); setUploadingImages(new Map()); setImageErrors(new Map()); setIsTextareaExpanded(false); // Reset textarea height if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } // Clear the saved draft since message was sent if (selectedProject) { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } }; const handleKeyDown = (e) => { // Handle file dropdown navigation if (showFileDropdown && filteredFiles.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedFileIndex(prev => prev < filteredFiles.length - 1 ? prev + 1 : 0 ); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedFileIndex(prev => prev > 0 ? prev - 1 : filteredFiles.length - 1 ); return; } if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); if (selectedFileIndex >= 0) { selectFile(filteredFiles[selectedFileIndex]); } else if (filteredFiles.length > 0) { selectFile(filteredFiles[0]); } return; } if (e.key === 'Escape') { e.preventDefault(); setShowFileDropdown(false); return; } } // 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 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: Send message only if not in IME composition if (!sendByCtrlEnter) { e.preventDefault(); handleSubmit(e); } } // Shift+Enter: Allow default behavior (new line) } }; const selectFile = (file) => { const textBeforeAt = input.slice(0, atSymbolPosition); const textAfterAtQuery = input.slice(atSymbolPosition); const spaceIndex = textAfterAtQuery.indexOf(' '); const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : ''; const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery; const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1; // Immediately ensure focus is maintained if (textareaRef.current && !textareaRef.current.matches(':focus')) { textareaRef.current.focus(); } // Update input and cursor position setInput(newInput); setCursorPosition(newCursorPos); // Hide dropdown setShowFileDropdown(false); setAtSymbolPosition(-1); // Set cursor position synchronously if (textareaRef.current) { // Use requestAnimationFrame for smoother updates requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); // Ensure focus is maintained if (!textareaRef.current.matches(':focus')) { textareaRef.current.focus(); } } }); } }; const handleInputChange = (e) => { const newValue = e.target.value; setInput(newValue); setCursorPosition(e.target.selectionStart); // Handle height reset when input becomes empty if (!newValue.trim()) { e.target.style.height = 'auto'; setIsTextareaExpanded(false); } }; const handleTextareaClick = (e) => { setCursorPosition(e.target.selectionStart); }; const handleNewSession = () => { setChatMessages([]); setInput(''); setIsLoading(false); setCanAbortSession(false); }; const handleAbortSession = () => { if (currentSessionId && canAbortSession) { sendMessage({ type: 'abort-session', sessionId: currentSessionId }); } }; 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 (

Select a project to start chatting with Claude

); } return ( <>
{/* Messages Area - Scrollable Middle Section */}
{isLoadingSessionMessages && chatMessages.length === 0 ? (

Loading session messages...

) : chatMessages.length === 0 ? (

Start a conversation with Claude

Ask questions about your code, request changes, or get help with development tasks

) : ( <> {chatMessages.length > visibleMessageCount && (
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •
)} {visibleMessages.map((message, index) => { const prevMessage = index > 0 ? visibleMessages[index - 1] : null; return ( ); })} )} {isLoading && (
C
Claude
{/* Abort button removed - functionality not yet implemented at backend */}
Thinking...
)}
{/* Input Area - Fixed Bottom */}
{/* Claude Working Status - positioned above the input form */} {/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
{/* Scroll to bottom button - positioned next to mode indicator */} {isUserScrolledUp && chatMessages.length > 0 && ( )}
{/* Drag overlay */} {isDragActive && (

Drop images here

)} {/* Image attachments preview */} {attachedImages.length > 0 && (
{attachedImages.map((file, index) => ( { setAttachedImages(prev => prev.filter((_, i) => i !== index)); }} uploadProgress={uploadingImages.get(file.name)} error={imageErrors.get(file.name)} /> ))}
)} {/* File dropdown - positioned outside dropzone to avoid conflicts */} {showFileDropdown && filteredFiles.length > 0 && (
{filteredFiles.map((file, index) => (
{ // Prevent textarea from losing focus on mobile e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectFile(file); }} >
{file.name}
{file.path}
))}
)}