/* * 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 remarkGfm from 'remark-gfm'; 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 TokenUsagePie from './TokenUsagePie'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; import Fuse from 'fuse.js'; import CommandMenu from './CommandMenu'; // Helper function to decode HTML entities in text function decodeHtmlEntities(text) { if (!text) return text; return text .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); } // Normalize markdown text where providers mistakenly wrap short inline code with single-line triple fences. // Only convert fences that do NOT contain any newline to avoid touching real code blocks. function normalizeInlineCodeFences(text) { if (!text || typeof text !== 'string') return text; try { // ```code``` -> `code` return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`'); } catch { return text; } } // Small wrapper to keep markdown behavior consistent in one place const Markdown = ({ children, className }) => { const content = normalizeInlineCodeFences(String(children ?? '')); return (
{content}
); }; // 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); } } }; // Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) const markdownComponents = { code: ({ node, inline, className, children, ...props }) => { const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); const looksMultiline = /[\r\n]/.test(raw); const inlineDetected = inline || (node && node.type === 'inlineCode'); const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line if (shouldInline) { return ( {children} ); } const [copied, setCopied] = React.useState(false); const textToCopy = raw; const handleCopy = () => { const doSet = () => { setCopied(true); setTimeout(() => setCopied(false), 1500); }; try { if (navigator && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => { // Fallback const ta = document.createElement('textarea'); ta.value = textToCopy; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch {} document.body.removeChild(ta); doSet(); }); } else { const ta = document.createElement('textarea'); ta.value = textToCopy; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch {} document.body.removeChild(ta); doSet(); } } catch {} }; return (
          
            {children}
          
        
); }, blockquote: ({ children }) => (
{children}
), a: ({ href, children }) => ( {children} ), p: ({ children }) =>
{children}
, // GFM tables table: ({ children }) => (
{children}
), thead: ({ children }) => ( {children} ), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ) }; // Memoized message component to prevent unnecessary re-renders const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking }) => { const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || (prevMessage.type === 'user') || (prevMessage.type === 'tool') || (prevMessage.type === 'error')); 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 */} {showThinking && message.reasoning && (
💭 Thinking...
{message.reasoning}
)} {(() => { const content = formatUsageLimitText(String(message.content || '')); // Detect if content is pure JSON (starts with { or [) const trimmedContent = content.trim(); if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) && (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { try { const parsed = JSON.parse(trimmedContent); const formatted = JSON.stringify(parsed, null, 2); return (
JSON Response
                              
                                {formatted}
                              
                            
); } catch (e) { // Not valid JSON, fall through to normal rendering } } // Normal rendering for non-JSON content return message.type === 'assistant' ? ( {content} ) : (
{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, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, 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 inputContainerRef = useRef(null); const scrollContainerRef = useRef(null); const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls // Streaming throttle buffers const streamBufferRef = useRef(''); const streamTimerRef = useRef(null); const commandQueryTimerRef = 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 [commandQuery, setCommandQuery] = useState(''); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [tokenBudget, setTokenBudget] = useState(null); 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'; }); // Load permission mode for the current session useEffect(() => { if (selectedSession?.id) { const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`); if (savedMode) { setPermissionMode(savedMode); } else { setPermissionMode('default'); } } }, [selectedSession?.id]); // 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]); // Fetch slash commands on mount and when project changes useEffect(() => { const fetchCommands = async () => { if (!selectedProject) return; try { const response = await authenticatedFetch('/api/commands/list', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ projectPath: selectedProject.path }) }); if (!response.ok) { throw new Error('Failed to fetch commands'); } const data = await response.json(); // Combine built-in and custom commands const allCommands = [ ...(data.builtIn || []).map(cmd => ({ ...cmd, type: 'built-in' })), ...(data.custom || []).map(cmd => ({ ...cmd, type: 'custom' })) ]; setSlashCommands(allCommands); // Load command history from localStorage const historyKey = `command_history_${selectedProject.name}`; const history = safeLocalStorage.getItem(historyKey); if (history) { try { const parsedHistory = JSON.parse(history); // Sort commands by usage frequency const sortedCommands = allCommands.sort((a, b) => { const aCount = parsedHistory[a.name] || 0; const bCount = parsedHistory[b.name] || 0; return bCount - aCount; }); setSlashCommands(sortedCommands); } catch (e) { console.error('Error parsing command history:', e); } } } catch (error) { console.error('Error fetching slash commands:', error); setSlashCommands([]); } }; fetchCommands(); }, [selectedProject]); // Create Fuse instance for fuzzy search const fuse = useMemo(() => { if (!slashCommands.length) return null; return new Fuse(slashCommands, { keys: [ { name: 'name', weight: 2 }, { name: 'description', weight: 1 } ], threshold: 0.4, includeScore: true, minMatchCharLength: 1 }); }, [slashCommands]); // Filter commands based on query useEffect(() => { if (!commandQuery) { setFilteredCommands(slashCommands); return; } if (!fuse) { setFilteredCommands([]); return; } const results = fuse.search(commandQuery); setFilteredCommands(results.map(result => result.item)); }, [commandQuery, slashCommands, fuse]); // Calculate frequently used commands const frequentCommands = useMemo(() => { if (!selectedProject || slashCommands.length === 0) return []; const historyKey = `command_history_${selectedProject.name}`; const history = safeLocalStorage.getItem(historyKey); if (!history) return []; try { const parsedHistory = JSON.parse(history); // Sort commands by usage count const commandsWithUsage = slashCommands .map(cmd => ({ ...cmd, usageCount: parsedHistory[cmd.name] || 0 })) .filter(cmd => cmd.usageCount > 0) .sort((a, b) => b.usageCount - a.usageCount) .slice(0, 5); // Top 5 most used return commandsWithUsage; } catch (e) { console.error('Error parsing command history:', e); return []; } }, [selectedProject, slashCommands]); // Command selection callback with history tracking const handleCommandSelect = useCallback((command, index, isHover) => { if (!command || !selectedProject) return; // If hovering, just update the selected index if (isHover) { setSelectedCommandIndex(index); return; } // Update command history const historyKey = `command_history_${selectedProject.name}`; const history = safeLocalStorage.getItem(historyKey); let parsedHistory = {}; try { parsedHistory = history ? JSON.parse(history) : {}; } catch (e) { console.error('Error parsing command history:', e); } parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1; safeLocalStorage.setItem(historyKey, JSON.stringify(parsedHistory)); // Execute the command executeCommand(command); }, [selectedProject]); // Execute a command const handleBuiltInCommand = useCallback((result) => { const { action, data } = result; switch (action) { case 'clear': // Clear conversation history setChatMessages([]); setSessionMessages([]); break; case 'help': // Show help content setChatMessages(prev => [...prev, { role: 'assistant', content: data.content, timestamp: Date.now() }]); break; case 'model': // Show model information setChatMessages(prev => [...prev, { role: 'assistant', content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, timestamp: Date.now() }]); break; case 'cost': { const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; setChatMessages(prev => [...prev, { role: 'assistant', content: costMessage, timestamp: Date.now() }]); break; } case 'status': { const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; setChatMessages(prev => [...prev, { role: 'assistant', content: statusMessage, timestamp: Date.now() }]); break; } case 'memory': // Show memory file info if (data.error) { setChatMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${data.message}`, timestamp: Date.now() }]); } else { setChatMessages(prev => [...prev, { role: 'assistant', content: `📝 ${data.message}\n\nPath: \`${data.path}\``, timestamp: Date.now() }]); // Optionally open file in editor if (data.exists && onFileOpen) { onFileOpen(data.path); } } break; case 'config': // Open settings if (onShowSettings) { onShowSettings(); } break; case 'rewind': // Rewind conversation if (data.error) { setChatMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${data.message}`, timestamp: Date.now() }]); } else { // Remove last N messages setChatMessages(prev => prev.slice(0, -data.steps * 2)); // Remove user + assistant pairs setChatMessages(prev => [...prev, { role: 'assistant', content: `⏪ ${data.message}`, timestamp: Date.now() }]); } break; default: console.warn('Unknown built-in command action:', action); } }, [onFileOpen, onShowSettings]); // Ref to store handleSubmit so we can call it from handleCustomCommand const handleSubmitRef = useRef(null); // Handle custom command execution const handleCustomCommand = useCallback(async (result, args) => { const { content, hasBashCommands, hasFileIncludes } = result; // Show confirmation for bash commands if (hasBashCommands) { const confirmed = window.confirm( 'This command contains bash commands that will be executed. Do you want to proceed?' ); if (!confirmed) { setChatMessages(prev => [...prev, { role: 'assistant', content: '❌ Command execution cancelled', timestamp: Date.now() }]); return; } } // Set the input to the command content setInput(content); // Wait for state to update, then directly call handleSubmit setTimeout(() => { if (handleSubmitRef.current) { // Create a fake event to pass to handleSubmit const fakeEvent = { preventDefault: () => {} }; handleSubmitRef.current(fakeEvent); } }, 50); }, []); const executeCommand = useCallback(async (command) => { if (!command || !selectedProject) return; try { // Parse command and arguments from current input const commandMatch = input.match(new RegExp(`${command.name}\\s*(.*)`)); const args = commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; // Prepare context for command execution const context = { projectPath: selectedProject.path, projectName: selectedProject.name, sessionId: currentSessionId, provider, model: provider === 'cursor' ? cursorModel : 'claude-sonnet-4.5', tokenUsage: tokenBudget }; // Call the execute endpoint const response = await authenticatedFetch('/api/commands/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ commandName: command.name, commandPath: command.path, args, context }) }); if (!response.ok) { throw new Error('Failed to execute command'); } const result = await response.json(); // Handle built-in commands if (result.type === 'builtin') { handleBuiltInCommand(result); } else if (result.type === 'custom') { // Handle custom commands - inject as system message await handleCustomCommand(result, args); } // Clear the input after successful execution setInput(''); setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); setSelectedCommandIndex(-1); } catch (error) { console.error('Error executing command:', error); // Show error message to user setChatMessages(prev => [...prev, { role: 'assistant', content: `Error executing command: ${error.message}`, timestamp: Date.now() }]); } }, [input, selectedProject, currentSessionId, provider, cursorModel, tokenBudget]); // Handle built-in command actions // 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(decodeHtmlEntities(part.text)); } else if (part?.type === 'reasoning' && part?.text) { // Handle reasoning type - will be displayed in a collapsible section reasoningText = decodeHtmlEntities(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(decodeHtmlEntities(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 = decodeHtmlEntities(msg.message.content); } else { content = decodeHtmlEntities(String(msg.message.content)); } // Skip command messages, system messages, and empty content const shouldSkip = !content || content.startsWith('') || content.startsWith('') || content.startsWith('') || content.startsWith('') || content.startsWith('') || content.startsWith('Caveat:') || content.startsWith('This session is being continued from a previous') || content.startsWith('[Request interrupted'); if (!shouldSkip) { // Unescape double-escaped newlines and other escape sequences content = content.replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\r/g, '\r'); 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') { // Unescape double-escaped newlines and other escape sequences let text = part.text; if (typeof text === 'string') { text = text.replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\r/g, '\r'); } converted.push({ type: 'assistant', content: 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') { // Unescape double-escaped newlines and other escape sequences let text = msg.message.content; text = text.replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\r/g, '\r'); converted.push({ type: 'assistant', content: text, timestamp: msg.timestamp || new Date().toISOString() }); } } } return converted; }; // Memoize expensive convertSessionMessages operation const convertedMessages = useMemo(() => { return convertSessionMessages(sessionMessages); }, [sessionMessages]); // Note: Token budgets are not saved to JSONL files, only sent via WebSocket // So we don't try to extract them from loaded 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'; // Mark that we're loading a session to prevent multiple scroll triggers isLoadingSessionRef.current = true; // Only reset state if the session ID actually changed (not initial load) const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { // Reset pagination state when switching sessions setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); // Reset token budget when switching sessions // It will update when user sends a message and receives new budget from WebSocket setTokenBudget(null); // Reset loading state when switching sessions (unless the new session is processing) // The restore effect will set it back to true if needed setIsLoading(false); // Check if the session is currently processing on the backend if (ws && sendMessage) { sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); } } else if (currentSessionId === null) { // Initial load - reset pagination but not token budget setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); // Check if the session is currently processing on the backend if (ws && sendMessage) { sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); } } 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 will be handled by the main scroll useEffect after messages are rendered } 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); } // Mark loading as complete after messages are set // Use setTimeout to ensure state updates and DOM rendering are complete setTimeout(() => { isLoadingSessionRef.current = false; }, 250); }; loadMessages(); }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]); // External Message Update Handler: Reload messages when external CLI modifies current session // This triggers when App.jsx detects a JSONL file change for the currently-viewed session // Only reloads if the session is NOT active (respecting Session Protection System) useEffect(() => { if (externalMessageUpdate > 0 && selectedSession && selectedProject) { console.log('🔄 Reloading messages due to external CLI update'); const reloadExternalMessages = async () => { try { const provider = localStorage.getItem('selected-provider') || 'claude'; if (provider === 'cursor') { // Reload Cursor messages from SQLite const projectPath = selectedProject.fullPath || selectedProject.path; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); setSessionMessages([]); setChatMessages(converted); } else { // Reload Claude messages from API/JSONL const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false); setSessionMessages(messages); // convertedMessages will be automatically updated via useMemo // Smart scroll behavior: only auto-scroll if user is near bottom if (isNearBottom && autoScrollToBottom) { setTimeout(() => scrollToBottom(), 200); } // If user scrolled up, preserve their position (they're reading history) } } catch (error) { console.error('Error reloading messages from external update:', error); } }; reloadExternalMessages(); } }, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]); // 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]); // Track processing state: notify parent when isLoading becomes true // Note: onSessionNotProcessing is called directly in completion message handlers useEffect(() => { if (currentSessionId && isLoading && onSessionProcessing) { onSessionProcessing(currentSessionId); } }, [isLoading, currentSessionId, onSessionProcessing]); // Restore processing state when switching to a processing session useEffect(() => { if (currentSessionId && processingSessions) { const shouldBeProcessing = processingSessions.has(currentSessionId); if (shouldBeProcessing && !isLoading) { setIsLoading(true); setCanAbortSession(true); // Assume processing sessions can be aborted } } }, [currentSessionId, processingSessions]); useEffect(() => { // Handle WebSocket messages if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; console.log('🔵 WebSocket message received:', latestMessage.type, latestMessage); // Filter messages by session ID to prevent cross-session interference // Skip filtering for global messages that apply to all sessions const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete']; const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); // For new sessions (currentSessionId is null), allow messages through if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) { // Message is for a different session, ignore it console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId); return; } 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 'token-budget': // Token budget now fetched via API after message completion instead of WebSocket // This case is kept for compatibility but does nothing 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) { // Decode HTML entities and buffer deltas const decodedText = decodeHtmlEntities(messageData.delta.text); streamBufferRef.current += decodedText; 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()) { // Decode HTML entities and normalize usage limit message to local time let content = decodeHtmlEntities(part.text); content = formatUsageLimitText(content); // Add regular text message setChatMessages(prev => [...prev, { type: 'assistant', content: content, timestamp: new Date() }]); } } } else if (typeof messageData.content === 'string' && messageData.content.trim()) { // Decode HTML entities and normalize usage limit message to local time let content = decodeHtmlEntities(messageData.content); content = formatUsageLimitText(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': // Get session ID from message or fall back to current session const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; // Only update UI state if this is the current session if (cursorCompletedSessionId === currentSessionId) { setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); } // Always mark the completed session as inactive and not processing if (cursorCompletedSessionId) { if (onSessionInactive) { onSessionInactive(cursorCompletedSessionId); } if (onSessionNotProcessing) { onSessionNotProcessing(cursorCompletedSessionId); } } // Only process result for current session if (cursorCompletedSessionId === currentSessionId) { 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); } } // Store session ID for future use and trigger refresh (for new sessions) const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { setCurrentSessionId(cursorCompletedSessionId); 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': // Get session ID from message or fall back to current session const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); console.log('🎯 claude-complete received:', { completedSessionId, currentSessionId, match: completedSessionId === currentSessionId, isNew: !currentSessionId }); // Update UI state if this is the current session OR if we don't have a session ID yet (new session) if (completedSessionId === currentSessionId || !currentSessionId) { console.log('✅ Stopping loading state'); setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); // Fetch updated token usage after message completes if (selectedProject && selectedSession?.id) { const fetchUpdatedTokenUsage = async () => { try { const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; const response = await authenticatedFetch(url); if (response.ok) { const data = await response.json(); setTokenBudget(data); } } catch (error) { console.error('Failed to fetch updated token usage:', error); } }; fetchUpdatedTokenUsage(); } } // Always mark the completed session as inactive and not processing if (completedSessionId) { if (onSessionInactive) { onSessionInactive(completedSessionId); } if (onSessionNotProcessing) { onSessionNotProcessing(completedSessionId); } } // 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'); // No need to manually refresh - projects_updated WebSocket message will handle it console.log('✅ New session complete, ID set to:', pendingSessionId); } // Clear persisted chat messages after successful completion if (selectedProject && latestMessage.exitCode === 0) { safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); } break; case 'session-aborted': { // Get session ID from message or fall back to current session const abortedSessionId = latestMessage.sessionId || currentSessionId; // Only update UI state if this is the current session if (abortedSessionId === currentSessionId) { setIsLoading(false); setCanAbortSession(false); setClaudeStatus(null); } // Always mark the aborted session as inactive and not processing if (abortedSessionId) { if (onSessionInactive) { onSessionInactive(abortedSessionId); } if (onSessionNotProcessing) { onSessionNotProcessing(abortedSessionId); } } setChatMessages(prev => [...prev, { type: 'assistant', content: 'Session interrupted by user.', timestamp: new Date() }]); break; } case 'session-status': { const statusSessionId = latestMessage.sessionId; const isCurrentSession = statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); if (isCurrentSession && latestMessage.isProcessing) { // Session is currently processing, restore UI state setIsLoading(true); setCanAbortSession(true); if (onSessionProcessing) { onSessionProcessing(statusSessionId); } } 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 messages first load after session switch useEffect(() => { if (scrollContainerRef.current && chatMessages.length > 0 && !isLoadingSessionRef.current) { // Only scroll if we're not in the middle of loading a session // This prevents the "double scroll" effect during session switching // Also reset scroll state setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 200); // Delay to ensure full rendering } }, [selectedSession?.id, selectedProject?.name]); // Only trigger when session/project changes // 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 - set to 2 rows height 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]); // Load token usage when session changes (but don't poll to avoid conflicts with WebSocket) useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { // Reset for new/empty sessions setTokenBudget(null); return; } // Fetch token usage once when session loads const fetchInitialTokenUsage = async () => { try { const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; console.log('📊 Fetching initial token usage from:', url); const response = await authenticatedFetch(url); if (response.ok) { const data = await response.json(); console.log('✅ Initial token usage loaded:', data); setTokenBudget(data); } else { console.log('⚠️ No token usage data available for this session yet'); setTokenBudget(null); } } catch (error) { console.error('Failed to fetch initial token usage:', error); } }; fetchInitialTokenUsage(); }, [selectedSession?.id, selectedProject?.path]); 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 = useCallback(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}`); } }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); // Store handleSubmit in ref so handleCustomCommand can access it useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); const selectCommand = (command) => { if (!command) return; // Prepare the input with command name and any arguments that were already typed const textBeforeSlash = input.slice(0, slashPosition); const textAfterSlash = input.slice(slashPosition); const spaceIndex = textAfterSlash.indexOf(' '); const textAfterQuery = spaceIndex !==-1 ? textAfterSlash.slice(spaceIndex) : ''; const newInput = textBeforeSlash + command.name + ' ' + textAfterQuery; // Update input temporarily so executeCommand can parse arguments setInput(newInput); // Hide command menu setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); setSelectedCommandIndex(-1); // Clear debounce timer if (commandQueryTimerRef.current) { clearTimeout(commandQueryTimerRef.current); } // Execute the command (which will load its content and send to Claude) executeCommand(command); }; const handleKeyDown = (e) => { // Handle command menu navigation if (showCommandMenu && filteredCommands.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedCommandIndex(prev => prev < filteredCommands.length - 1 ? prev + 1 : 0 ); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedCommandIndex(prev => prev > 0 ? prev - 1 : filteredCommands.length - 1 ); return; } if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); if (selectedCommandIndex >= 0) { selectCommand(filteredCommands[selectedCommandIndex]); } else if (filteredCommands.length > 0) { selectCommand(filteredCommands[0]); } return; } if (e.key === 'Escape') { e.preventDefault(); setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); setSelectedCommandIndex(-1); if (commandQueryTimerRef.current) { clearTimeout(commandQueryTimerRef.current); } return; } } // 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 dropdowns are not showing) if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) { e.preventDefault(); const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; const currentIndex = modes.indexOf(permissionMode); const nextIndex = (currentIndex + 1) % modes.length; const newMode = modes[nextIndex]; setPermissionMode(newMode); // Save mode for this session if (selectedSession?.id) { localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); } 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; const cursorPos = e.target.selectionStart; // Auto-select Claude provider if no session exists and user starts typing if (!currentSessionId && newValue.trim() && provider === 'claude') { // Provider is already set to 'claude' by default, so no need to change it // The session will be created automatically when they submit } setInput(newValue); setCursorPosition(cursorPos); // Handle height reset when input becomes empty if (!newValue.trim()) { e.target.style.height = 'auto'; setIsTextareaExpanded(false); setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); return; } // Detect slash command at cursor position // Look backwards from cursor to find a slash that starts a command const textBeforeCursor = newValue.slice(0, cursorPos); // Check if we're in a code block (simple heuristic: between triple backticks) const backticksBefore = (textBeforeCursor.match(/```/g) || []).length; const inCodeBlock = backticksBefore % 2 === 1; if (inCodeBlock) { // Don't show command menu in code blocks setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); return; } // Find the last slash before cursor that could start a command // Slash is valid if it's at the start or preceded by whitespace const slashPattern = /(^|\s)\/(\S*)$/; const match = textBeforeCursor.match(slashPattern); if (match) { const slashPos = match.index + match[1].length; // Position of the slash const query = match[2]; // Text after the slash // Update states with debouncing for query setSlashPosition(slashPos); setShowCommandMenu(true); setSelectedCommandIndex(-1); // Debounce the command query update if (commandQueryTimerRef.current) { clearTimeout(commandQueryTimerRef.current); } commandQueryTimerRef.current = setTimeout(() => { setCommandQuery(query); }, 150); // 150ms debounce } else { // No slash command detected setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); if (commandQueryTimerRef.current) { clearTimeout(commandQueryTimerRef.current); } } }; 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; const newMode = modes[nextIndex]; setPermissionMode(newMode); // Save mode for this session if (selectedSession?.id) { localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); } }; // 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 */}
{/* Token usage pie chart - positioned next to mode indicator */} {/* Clear input button - positioned to the right of token pie, only shows when there's input */} {input.trim() && ( )} {/* 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}
))}
)} {/* Command Menu */} { setShowCommandMenu(false); setSlashPosition(-1); setCommandQuery(''); setSelectedCommandIndex(-1); }} position={{ top: textareaRef.current ? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316) : 0, left: textareaRef.current ? textareaRef.current.getBoundingClientRect().left : 16, bottom: textareaRef.current ? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8 : 90 }} isOpen={showCommandMenu} frequentCommands={commandQuery ? [] : frequentCommands} />