/* * 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 CursorLogo from './CursorLogo.jsx'; import NextTaskBanner from './NextTaskBanner.jsx'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; import ClaudeStatus from './ClaudeStatus'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; // Format "Claude AI usage limit reached|" into a local time string function formatUsageLimitText(text) { try { if (typeof text !== 'string') return text; return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => { let timestampMs = parseInt(ts, 10); if (!Number.isFinite(timestampMs)) return match; if (timestampMs < 1e12) timestampMs *= 1000; // seconds → ms const reset = new Date(timestampMs); // Time HH:mm in local time const timeStr = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }).format(reset); // Human-readable timezone: GMT±HH[:MM] (City) const offsetMinutesLocal = -reset.getTimezoneOffset(); const sign = offsetMinutesLocal >= 0 ? '+' : '-'; const abs = Math.abs(offsetMinutesLocal); const offH = Math.floor(abs / 60); const offM = abs % 60; const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`; const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; const cityRaw = tzId.split('/').pop() || ''; const city = cityRaw .replace(/_/g, ' ') .toLowerCase() .replace(/\b\w/g, c => c.toUpperCase()); const tzHuman = city ? `${gmt} (${city})` : gmt; // Readable date like "8 Jun 2025" const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`; return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`; }); } catch { return text; } } // 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/Tool messages on the left */
{!isGrouped && (
{message.type === 'error' ? (
!
) : message.type === 'tool' ? (
🔧
) : (
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( ) : ( )}
)}
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : '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 // Special handling for Write tool if (message.toolName === 'Write') { 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; } 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
) : (
{/* Thinking accordion for reasoning */} {message.reasoning && (
💭 Thinking...
{message.reasoning}
)} {message.type === 'assistant' ? (
{ return inline ? ( {children} ) : (
{children}
); }, blockquote: ({children}) => (
{children}
), a: ({href, children}) => ( {children} ), p: ({children}) => (
{children}
) }} > {formatUsageLimitText(String(message.content || ''))}
) : (
{formatUsageLimitText(String(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, onTaskClick, onShowAllTasks }) { const { tasksEnabled } = useTasksSettings(); 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 [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [messagesOffset, setMessagesOffset] = useState(0); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); const MESSAGES_PER_PAGE = 20; 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); // Streaming throttle buffers const streamBufferRef = useRef(''); const streamTimerRef = 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); const [provider, setProvider] = useState(() => { return localStorage.getItem('selected-provider') || 'claude'; }); const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || 'gpt-5'; }); // When selecting a session from Sidebar, auto-switch provider to match session's origin useEffect(() => { if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) { setProvider(selectedSession.__provider); localStorage.setItem('selected-provider', selectedSession.__provider); } }, [selectedSession]); // Load Cursor default model from config useEffect(() => { if (provider === 'cursor') { fetch('/api/cursor/config', { headers: { 'Authorization': `Bearer ${localStorage.getItem('auth-token')}` } }) .then(res => res.json()) .then(data => { if (data.success && data.config?.model?.modelId) { // Map Cursor model IDs to our simplified names const modelMap = { 'gpt-5': 'gpt-5', 'claude-4-sonnet': 'sonnet-4', 'sonnet-4': 'sonnet-4', 'claude-4-opus': 'opus-4.1', 'opus-4.1': 'opus-4.1' }; const mappedModel = modelMap[data.config.model.modelId] || data.config.model.modelId; if (!localStorage.getItem('cursor-model')) { setCursorModel(mappedModel); } } }) .catch(err => console.error('Error loading Cursor config:', err)); } }, [provider]); // 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 with pagination const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => { if (!projectName || !sessionId) return []; const isInitialLoad = !loadMore; if (isInitialLoad) { setIsLoadingSessionMessages(true); } else { setIsLoadingMoreMessages(true); } try { const currentOffset = loadMore ? messagesOffset : 0; const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset); if (!response.ok) { throw new Error('Failed to load session messages'); } const data = await response.json(); // Handle paginated response if (data.hasMore !== undefined) { setHasMoreMessages(data.hasMore); setTotalMessages(data.total); setMessagesOffset(currentOffset + (data.messages?.length || 0)); return data.messages || []; } else { // Backward compatibility for non-paginated response const messages = data.messages || []; setHasMoreMessages(false); setTotalMessages(messages.length); return messages; } } catch (error) { console.error('Error loading session messages:', error); return []; } finally { if (isInitialLoad) { setIsLoadingSessionMessages(false); } else { setIsLoadingMoreMessages(false); } } }, [messagesOffset]); // Load Cursor session messages from SQLite via backend const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => { if (!projectPath || !sessionId) return []; setIsLoadingSessionMessages(true); try { const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; const res = await authenticatedFetch(url); if (!res.ok) return []; const data = await res.json(); const blobs = data?.session?.messages || []; const converted = []; const toolUseMap = {}; // Map to store tool uses by ID for linking results // First pass: process all messages maintaining order for (let blobIdx = 0; blobIdx < blobs.length; blobIdx++) { const blob = blobs[blobIdx]; const content = blob.content; let text = ''; let role = 'assistant'; let reasoningText = null; // Move to outer scope try { // Handle different Cursor message formats if (content?.role && content?.content) { // Direct format: {"role":"user","content":[{"type":"text","text":"..."}]} // Skip system messages if (content.role === 'system') { continue; } // Handle tool messages if (content.role === 'tool') { // Tool result format - find the matching tool use message and update it if (Array.isArray(content.content)) { for (const item of content.content) { if (item?.type === 'tool-result') { // Map ApplyPatch to Edit for consistency let toolName = item.toolName || 'Unknown Tool'; if (toolName === 'ApplyPatch') { toolName = 'Edit'; } const toolCallId = item.toolCallId || content.id; const result = item.result || ''; // Store the tool result to be linked later if (toolUseMap[toolCallId]) { toolUseMap[toolCallId].toolResult = { content: result, isError: false }; } else { // No matching tool use found, create a standalone result message converted.push({ type: 'assistant', content: '', timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid, isToolUse: true, toolName: toolName, toolId: toolCallId, toolInput: null, toolResult: { content: result, isError: false } }); } } } } continue; // Don't add tool messages as regular messages } else { // User or assistant messages role = content.role === 'user' ? 'user' : 'assistant'; if (Array.isArray(content.content)) { // Extract text, reasoning, and tool calls from content array const textParts = []; for (const part of content.content) { if (part?.type === 'text' && part?.text) { textParts.push(part.text); } else if (part?.type === 'reasoning' && part?.text) { // Handle reasoning type - will be displayed in a collapsible section reasoningText = part.text; } else if (part?.type === 'tool-call') { // First, add any text/reasoning we've collected so far as a message if (textParts.length > 0 || reasoningText) { converted.push({ type: role, content: textParts.join('\n'), reasoning: reasoningText, timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid }); textParts.length = 0; reasoningText = null; } // Tool call in assistant message - format like Claude Code // Map ApplyPatch to Edit for consistency with Claude Code let toolName = part.toolName || 'Unknown Tool'; if (toolName === 'ApplyPatch') { toolName = 'Edit'; } const toolId = part.toolCallId || `tool_${blobIdx}`; // Create a tool use message with Claude Code format // Map Cursor args format to Claude Code format let toolInput = part.args; if (toolName === 'Edit' && part.args) { // ApplyPatch uses 'patch' format, convert to Edit format if (part.args.patch) { // Parse the patch to extract old and new content const patchLines = part.args.patch.split('\n'); let oldLines = []; let newLines = []; let inPatch = false; for (const line of patchLines) { if (line.startsWith('@@')) { inPatch = true; } else if (inPatch) { if (line.startsWith('-')) { oldLines.push(line.substring(1)); } else if (line.startsWith('+')) { newLines.push(line.substring(1)); } else if (line.startsWith(' ')) { // Context line - add to both oldLines.push(line.substring(1)); newLines.push(line.substring(1)); } } } const filePath = part.args.file_path; const absolutePath = filePath && !filePath.startsWith('/') ? `${projectPath}/${filePath}` : filePath; toolInput = { file_path: absolutePath, old_string: oldLines.join('\n') || part.args.patch, new_string: newLines.join('\n') || part.args.patch }; } else { // Direct edit format toolInput = part.args; } } else if (toolName === 'Read' && part.args) { // Map 'path' to 'file_path' // Convert relative path to absolute if needed const filePath = part.args.path || part.args.file_path; const absolutePath = filePath && !filePath.startsWith('/') ? `${projectPath}/${filePath}` : filePath; toolInput = { file_path: absolutePath }; } else if (toolName === 'Write' && part.args) { // Map fields for Write tool const filePath = part.args.path || part.args.file_path; const absolutePath = filePath && !filePath.startsWith('/') ? `${projectPath}/${filePath}` : filePath; toolInput = { file_path: absolutePath, content: part.args.contents || part.args.content }; } const toolMessage = { type: 'assistant', content: '', timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid, isToolUse: true, toolName: toolName, toolId: toolId, toolInput: toolInput ? JSON.stringify(toolInput) : null, toolResult: null // Will be filled when we get the tool result }; converted.push(toolMessage); toolUseMap[toolId] = toolMessage; // Store for linking results } else if (part?.type === 'tool_use') { // Old format support if (textParts.length > 0 || reasoningText) { converted.push({ type: role, content: textParts.join('\n'), reasoning: reasoningText, timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid }); textParts.length = 0; reasoningText = null; } const toolName = part.name || 'Unknown Tool'; const toolId = part.id || `tool_${blobIdx}`; const toolMessage = { type: 'assistant', content: '', timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid, isToolUse: true, toolName: toolName, toolId: toolId, toolInput: part.input ? JSON.stringify(part.input) : null, toolResult: null }; converted.push(toolMessage); toolUseMap[toolId] = toolMessage; } else if (typeof part === 'string') { textParts.push(part); } } // Add any remaining text/reasoning if (textParts.length > 0) { text = textParts.join('\n'); if (reasoningText && !text) { // Just reasoning, no text converted.push({ type: role, content: '', reasoning: reasoningText, timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid }); text = ''; // Clear to avoid duplicate } } else { text = ''; } } else if (typeof content.content === 'string') { text = content.content; } } } else if (content?.message?.role && content?.message?.content) { // Nested message format if (content.message.role === 'system') { continue; } role = content.message.role === 'user' ? 'user' : 'assistant'; if (Array.isArray(content.message.content)) { text = content.message.content .map(p => (typeof p === 'string' ? p : (p?.text || ''))) .filter(Boolean) .join('\n'); } else if (typeof content.message.content === 'string') { text = content.message.content; } } } catch (e) { console.log('Error parsing blob content:', e); } if (text && text.trim()) { const message = { type: role, content: text, timestamp: new Date(Date.now() + blobIdx * 1000), blobId: blob.id, sequence: blob.sequence, rowid: blob.rowid }; // Add reasoning if we have it if (reasoningText) { message.reasoning = reasoningText; } converted.push(message); } } // Sort messages by sequence/rowid to maintain chronological order converted.sort((a, b) => { // First sort by sequence if available (clean 1,2,3... numbering) if (a.sequence !== undefined && b.sequence !== undefined) { return a.sequence - b.sequence; } // Then try rowid (original SQLite row IDs) if (a.rowid !== undefined && b.rowid !== undefined) { return a.rowid - b.rowid; } // Fallback to timestamp return new Date(a.timestamp) - new Date(b.timestamp); }); return converted; } catch (e) { console.error('Error loading Cursor session messages:', e); 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 and load more messages const handleScroll = useCallback(async () => { if (scrollContainerRef.current) { const container = scrollContainerRef.current; const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); // Check if we should load more messages (scrolled near top) const scrolledNearTop = container.scrollTop < 100; const provider = localStorage.getItem('selected-provider') || 'claude'; if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') { // Save current scroll position const previousScrollHeight = container.scrollHeight; const previousScrollTop = container.scrollTop; // Load more messages const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true); if (moreMessages.length > 0) { // Prepend new messages to the existing ones setSessionMessages(prev => [...moreMessages, ...prev]); // Restore scroll position after DOM update setTimeout(() => { if (scrollContainerRef.current) { const newScrollHeight = scrollContainerRef.current.scrollHeight; const scrollDiff = newScrollHeight - previousScrollHeight; scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff; } }, 0); } } } }, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); useEffect(() => { // Load session messages when session changes const loadMessages = async () => { if (selectedSession && selectedProject) { const provider = localStorage.getItem('selected-provider') || 'claude'; // Reset pagination state when switching sessions setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); if (provider === 'cursor') { // For Cursor, set the session ID for resuming setCurrentSessionId(selectedSession.id); sessionStorage.setItem('cursorSessionId', selectedSession.id); // Only load messages from SQLite if this is NOT a system-initiated session change // For system-initiated changes, preserve existing messages if (!isSystemSessionChange) { // Load historical messages for Cursor session from SQLite const projectPath = selectedProject.fullPath || selectedProject.path; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); setSessionMessages([]); setChatMessages(converted); } else { // Reset the flag after handling system session change setIsSystemSessionChange(false); } } else { // For Claude, load messages normally with pagination 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, false); 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 { // Only clear messages if this is NOT a system-initiated session change AND we're not loading // During system session changes or while loading, preserve the chat messages if (!isSystemSessionChange && !isLoading) { setChatMessages([]); setSessionMessages([]); } setCurrentSessionId(null); sessionStorage.removeItem('cursorSessionId'); setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); } }; loadMessages(); }, [selectedSession, selectedProject, loadCursorSessionMessages, 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 Cursor streaming format (content_block_delta / content_block_stop) if (messageData && typeof messageData === 'object' && messageData.type) { if (messageData.type === 'content_block_delta' && messageData.delta?.text) { // Buffer deltas and flush periodically to reduce rerenders streamBufferRef.current += messageData.delta.text; if (!streamTimerRef.current) { streamTimerRef.current = setTimeout(() => { const chunk = streamBufferRef.current; streamBufferRef.current = ''; streamTimerRef.current = null; if (!chunk) return; setChatMessages(prev => { const updated = [...prev]; const last = updated[updated.length - 1]; if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { last.content = (last.content || '') + chunk; } else { updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); } return updated; }); }, 100); } return; } if (messageData.type === 'content_block_stop') { // Flush any buffered text and mark streaming message complete if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const chunk = streamBufferRef.current; streamBufferRef.current = ''; if (chunk) { setChatMessages(prev => { const updated = [...prev]; const last = updated[updated.length - 1]; if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { last.content = (last.content || '') + chunk; } else { updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); } return updated; }); } setChatMessages(prev => { const updated = [...prev]; const last = updated[updated.length - 1]; if (last && last.type === 'assistant' && last.isStreaming) { last.isStreaming = false; } return updated; }); return; } } // 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. // This works exactly like new session detection - preserve messages during navigation. 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 // This works exactly like new session init - messages stay visible during navigation 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()) { // Normalize usage limit message to local time let content = formatUsageLimitText(part.text); // Add regular text message setChatMessages(prev => [...prev, { type: 'assistant', content: content, timestamp: new Date() }]); } } } else if (typeof messageData.content === 'string' && messageData.content.trim()) { // Normalize usage limit message to local time let content = formatUsageLimitText(messageData.content); // 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': { const cleaned = String(latestMessage.data || ''); if (cleaned.trim()) { streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned); if (!streamTimerRef.current) { streamTimerRef.current = setTimeout(() => { const chunk = streamBufferRef.current; streamBufferRef.current = ''; streamTimerRef.current = null; if (!chunk) return; setChatMessages(prev => { const updated = [...prev]; const last = updated[updated.length - 1]; if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { last.content = last.content ? `${last.content}\n${chunk}` : chunk; } else { updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); } return updated; }); }, 100); } } } 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 'cursor-system': // Handle Cursor system/init messages similar to Claude try { const cdata = latestMessage.data; if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) { // If we already have a session and this differs, switch (duplication/redirect) if (currentSessionId && cdata.session_id !== currentSessionId) { console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id }); setIsSystemSessionChange(true); if (onNavigateToSession) { onNavigateToSession(cdata.session_id); } return; } // If we don't yet have a session, adopt this one if (!currentSessionId) { console.log('🔄 Cursor new session init detected:', { newSession: cdata.session_id }); setIsSystemSessionChange(true); if (onNavigateToSession) { onNavigateToSession(cdata.session_id); } return; } } // For other cursor-system messages, avoid dumping raw objects to chat } catch (e) { console.warn('Error handling cursor-system message:', e); } break; case 'cursor-user': // Handle Cursor user messages (usually echoes) // Don't add user messages as they're already shown from input break; case 'cursor-tool-use': // Handle Cursor tool use messages setChatMessages(prev => [...prev, { type: 'assistant', content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`, timestamp: new Date(), isToolUse: true, toolName: latestMessage.tool, toolInput: latestMessage.input }]); break; case 'cursor-error': // Show Cursor errors as error messages in chat setChatMessages(prev => [...prev, { type: 'error', content: `Cursor error: ${latestMessage.error || 'Unknown error'}`, timestamp: new Date() }]); break; case 'cursor-result': // Handle Cursor completion and final result text setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); try { const r = latestMessage.data || {}; const textResult = typeof r.result === 'string' ? r.result : ''; // Flush buffered deltas before finalizing if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const pendingChunk = streamBufferRef.current; streamBufferRef.current = ''; setChatMessages(prev => { const updated = [...prev]; // Try to consolidate into the last streaming assistant message const last = updated[updated.length - 1]; if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { // Replace streaming content with the final content so deltas don't remain const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || ''); last.content = finalContent; last.isStreaming = false; } else if (textResult && textResult.trim()) { updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); } return updated; }); } catch (e) { console.warn('Error handling cursor-result message:', e); } // Mark session as inactive const cursorSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId'); if (cursorSessionId && onSessionInactive) { onSessionInactive(cursorSessionId); } // Store session ID for future use and trigger refresh if (cursorSessionId && !currentSessionId) { setCurrentSessionId(cursorSessionId); sessionStorage.removeItem('pendingSessionId'); // Trigger a project refresh to update the sidebar with the new session if (window.refreshProjects) { setTimeout(() => window.refreshProjects(), 500); } } break; case 'cursor-output': // Handle Cursor raw terminal output; strip ANSI and ignore empty control-only payloads try { const raw = String(latestMessage.data ?? ''); const cleaned = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim(); if (cleaned) { streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned); if (!streamTimerRef.current) { streamTimerRef.current = setTimeout(() => { const chunk = streamBufferRef.current; streamBufferRef.current = ''; streamTimerRef.current = null; if (!chunk) return; setChatMessages(prev => { const updated = [...prev]; const last = updated[updated.length - 1]; if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { last.content = last.content ? `${last.content}\n${chunk}` : chunk; } else { updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); } return updated; }); }, 100); } } } catch (e) { console.warn('Error handling cursor-output message:', e); } 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'); // Trigger a project refresh to update the sidebar with the new session if (window.refreshProjects) { setTimeout(() => window.refreshProjects(), 500); } } // 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 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; } 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 // Determine effective session id for replies to avoid race on state updates const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); // Session Protection: Mark session as active to prevent automatic project updates during conversation // Use existing session if available; otherwise a temporary placeholder until backend provides real ID const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; if (onSessionActive) { onSessionActive(sessionToActivate); } // Get tools settings from localStorage based on provider const getToolsSettings = () => { try { const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); 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 based on provider if (provider === 'cursor') { // Send Cursor command (always use cursor-command; include resume/sessionId when replying) sendMessage({ type: 'cursor-command', command: input, sessionId: effectiveSessionId, options: { // Prefer fullPath (actual cwd for project), fallback to path cwd: selectedProject.fullPath || selectedProject.path, projectPath: selectedProject.fullPath || selectedProject.path, sessionId: effectiveSessionId, resume: !!effectiveSessionId, model: cursorModel, skipPermissions: toolsSettings?.skipPermissions || false, toolsSettings: toolsSettings } }); } else { // Send Claude command (existing code) 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, provider: provider }); } }; 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 ? (
{!selectedSession && !currentSessionId && (

Choose Your AI Assistant

Select a provider to start a new conversation

{/* Claude Button */} {/* Cursor Button */}
{/* Model Selection for Cursor - Always reserve space to prevent jumping */}

{provider === 'claude' ? 'Ready to use Claude AI. Start typing your message below.' : provider === 'cursor' ? `Ready to use Cursor with ${cursorModel}. Start typing your message below.` : 'Select a provider above to begin' }

{/* Show NextTaskBanner when provider is selected and ready */} {provider && tasksEnabled && (
setInput('Start the next task')} onShowAllTasks={onShowAllTasks} />
)}
)} {selectedSession && (

Continue your conversation

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

{/* Show NextTaskBanner for existing sessions too */} {tasksEnabled && (
setInput('Start the next task')} onShowAllTasks={onShowAllTasks} />
)}
)}
) : ( <> {/* Loading indicator for older messages */} {isLoadingMoreMessages && (

Loading older messages...

)} {/* Indicator showing there are more messages to load */} {hasMoreMessages && !isLoadingMoreMessages && (
{totalMessages > 0 && ( Showing {sessionMessages.length} of {totalMessages} messages • Scroll up to load more )}
)} {/* Legacy message count indicator (for non-paginated view) */} {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •
)} {visibleMessages.map((message, index) => { const prevMessage = index > 0 ? visibleMessages[index - 1] : null; return ( ); })} )} {isLoading && (
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( ) : ( )}
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}
{/* Abort button removed - functionality not yet implemented at backend */}
Thinking...
)}
{/* Input Area - Fixed Bottom */}
{/* 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}
))}
)}