diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx deleted file mode 100644 index 93a04d2..0000000 --- a/src/components/ChatInterface.jsx +++ /dev/null @@ -1,5696 +0,0 @@ -/* - * 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, useLayoutEffect, memo } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { useDropzone } from 'react-dropzone'; -import TodoList from './TodoList'; -import ClaudeLogo from './ClaudeLogo.jsx'; -import CursorLogo from './CursorLogo.jsx'; -import CodexLogo from './CodexLogo.jsx'; -import NextTaskBanner from './NextTaskBanner.jsx'; -import QuickSettingsPanel from './QuickSettingsPanel'; - -import { useTasksSettings } from '../contexts/TasksSettingsContext'; -import { useTranslation } from 'react-i18next'; - -import ClaudeStatus from './ClaudeStatus'; -import TokenUsagePie from './TokenUsagePie'; -import { MicButton } from './MicButton.jsx'; -import { api, authenticatedFetch } from '../utils/api'; -import ThinkingModeSelector, { thinkingModes } from './ThinkingModeSelector.jsx'; -import Fuse from 'fuse.js'; -import CommandMenu from './CommandMenu'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; - -import { safeJsonParse } from '../lib/utils.js'; - -// ! Move all utility functions to utils/chatUtils.ts - -// 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; - } -} - -// Unescape \n, \t, \r while protecting LaTeX formulas ($...$ and $$...$$) from being corrupted -function unescapeWithMathProtection(text) { - if (!text || typeof text !== 'string') return text; - - const mathBlocks = []; - const PLACEHOLDER_PREFIX = '__MATH_BLOCK_'; - const PLACEHOLDER_SUFFIX = '__'; - - // Extract and protect math formulas - let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => { - const index = mathBlocks.length; - mathBlocks.push(match); - return `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`; - }); - - // Process escape sequences on non-math content - processedText = processedText.replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\r/g, '\r'); - - // Restore math formulas - processedText = processedText.replace( - new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'), - (match, index) => { - return mathBlocks[parseInt(index)]; - } - ); - - return processedText; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -// Small wrapper to keep markdown behavior consistent in one place -const Markdown = ({ children, className }) => { - const content = normalizeInlineCodeFences(String(children ?? '')); - const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); - const rehypePlugins = useMemo(() => [rehypeKatex], []); - - 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); - } - } -}; - -const CLAUDE_SETTINGS_KEY = 'claude-settings'; - - -function getClaudeSettings() { - const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); - if (!raw) { - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - projectSortOrder: 'name' - }; - } - - try { - const parsed = JSON.parse(raw); - return { - ...parsed, - allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [], - disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], - skipPermissions: Boolean(parsed.skipPermissions), - projectSortOrder: parsed.projectSortOrder || 'name' - }; - } catch { - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - projectSortOrder: 'name' - }; - } -} - -function buildClaudeToolPermissionEntry(toolName, toolInput) { - if (!toolName) return null; - if (toolName !== 'Bash') return toolName; - - const parsed = safeJsonParse(toolInput); - const command = typeof parsed?.command === 'string' ? parsed.command.trim() : ''; - if (!command) return toolName; - - const tokens = command.split(/\s+/); - if (tokens.length === 0) return toolName; - - // For Bash, allow the command family instead of every Bash invocation. - if (tokens[0] === 'git' && tokens[1]) { - return `Bash(${tokens[0]} ${tokens[1]}:*)`; - } - return `Bash(${tokens[0]}:*)`; -} - -// Normalize tool inputs for display in the permission banner. -// This does not sanitize/redact secrets; it is strictly formatting so users -// can see the raw input that triggered the permission prompt. -function formatToolInputForDisplay(input) { - if (input === undefined || input === null) return ''; - if (typeof input === 'string') return input; - try { - return JSON.stringify(input, null, 2); - } catch { - return String(input); - } -} - -function getClaudePermissionSuggestion(message, provider) { - if (provider !== 'claude') return null; - if (!message?.toolResult?.isError) return null; - - const toolName = message?.toolName; - const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput); - - if (!entry) return null; - - const settings = getClaudeSettings(); - const isAllowed = settings.allowedTools.includes(entry); - return { toolName, entry, isAllowed }; -} - -function grantClaudeToolPermission(entry) { - if (!entry) return { success: false }; - - const settings = getClaudeSettings(); - const alreadyAllowed = settings.allowedTools.includes(entry); - const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry]; - const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry); - const updatedSettings = { - ...settings, - allowedTools: nextAllowed, - disallowedTools: nextDisallowed, - lastUpdated: new Date().toISOString() - }; - - safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings)); - return { success: true, alreadyAllowed, updatedSettings }; -} - -// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const CodeBlock = ({ node, inline, className, children, ...props }) => { - const { t } = useTranslation('chat'); - const [copied, setCopied] = React.useState(false); - 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 - - // Inline code rendering - if (shouldInline) { - return ( - - {children} - - ); - } - - // Extract language from className (format: language-xxx) - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : 'text'; - 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 {} - }; - - // Code block with syntax highlighting - return ( -
- {/* Language label */} - {language && language !== 'text' && ( -
- {language} -
- )} - - {/* Copy button */} - - - {/* Syntax highlighted code */} - - {raw} - -
- ); - }; - -// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const markdownComponents = { - code: CodeBlock, - 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, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { - const { t } = useTranslation('chat'); - 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); - const permissionSuggestion = getClaudePermissionSuggestion(message, provider); - const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); - - React.useEffect(() => { - setPermissionGrantState('idle'); - }, [permissionSuggestion?.entry, message.toolId]); - - 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' ? ( - - ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( - - ) : ( - - )} -
- )} -
- {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))} -
-
- )} - -
- - {message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? ( - (() => { - // Minimize Grep and Glob tools since they happen frequently - const isSearchTool = ['Grep', 'Glob'].includes(message.toolName); - - if (isSearchTool) { - return ( - <> -
-
-
- - - - {message.toolName} - - {message.toolInput && (() => { - try { - const input = JSON.parse(message.toolInput); - return ( - - {input.pattern && {t('search.pattern')} {input.pattern}} - {input.path && {t('search.in')} {input.path}} - - ); - } catch (e) { - return null; - } - })()} -
- {message.toolResult && ( - - {t('tools.searchResults')} - - - - - )} -
-
- - ); - } - - // Full display for other tools - return ( -
- {/* Decorative gradient overlay */} -
- -
-
-
- - - - - {/* Subtle pulse animation */} -
-
-
- - {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 ( -
-
- $ - {input.command} -
- {input.description && ( -
- {input.description} -
- )} -
- ); - } 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 && (() => { - // Hide tool results for Edit/Write/Bash unless there's an error - const shouldHideResult = !message.toolResult.isError && - (message.toolName === 'Edit' || message.toolName === 'Write' || message.toolName === 'ApplyPatch' || message.toolName === 'Bash'); - - if (shouldHideResult) { - return null; - } - - return ( -
- {/* Decorative gradient overlay */} -
- -
-
- - {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 Grep/Glob results with structured data - if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) { - const toolData = message.toolResult.toolUseResult; - - // Handle files_with_matches mode or any tool result with filenames array - if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) { - return ( -
-
- - Found {toolData.numFiles || toolData.filenames.length} {(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'} - -
-
- {toolData.filenames.map((filePath, index) => { - const fileName = filePath.split('/').pop(); - const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); - - return ( -
{ - if (onFileOpen) { - onFileOpen(filePath); - } - }} - className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-green-100/50 dark:hover:bg-green-800/20 cursor-pointer transition-colors" - > - - - -
-
- {fileName} -
-
- {dirPath} -
-
- - - -
- ); - })} -
-
- ); - } - } - - // 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} - - ); - })()} - {permissionSuggestion && ( -
-
- - {onShowSettings && ( - - )} -
-
- Adds {permissionSuggestion.entry} to Allowed Tools. -
- {permissionGrantState === 'error' && ( -
- Unable to update permissions. Please try again. -
- )} - {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( -
- Permission saved. Retry the request to use the tool. -
- )} -
- )} -
-
- ); - })()} -
- ); - })() - ) : message.isInteractivePrompt ? ( - // Special handling for interactive prompts -
-
-
- - - -
-
-

- Interactive Prompt -

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

- {questionLine} -

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

- ⏳ Waiting for your response in the CLI -

-

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

-
- - ); - })()} -
-
-
- ) : message.isToolUse && message.toolName === 'Read' ? ( - // Simple Read tool indicator - (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path) { - const filename = input.file_path.split('/').pop(); - return ( -
-
- - - - Read - -
-
- ); - } - } catch (e) { - return ( -
-
- - - - Read file -
-
- ); - } - })() - ) : message.isToolUse && message.toolName === 'TodoWrite' ? ( - // Simple TodoWrite tool indicator with tasks - (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.todos && Array.isArray(input.todos)) { - return ( -
-
- - - - Update todo list -
- -
- ); - } - } catch (e) { - return ( -
-
- - - - Update todo list -
-
- ); - } - })() - ) : message.isToolUse && message.toolName === 'TodoRead' ? ( - // Simple TodoRead tool indicator -
-
- - - - Read todo list -
-
- ) : message.isThinking ? ( - /* Thinking messages - collapsible by default */ -
-
- - - - - 💭 Thinking... - -
- - {message.content} - -
-
-
- ) : ( -
- {/* 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, latestMessage, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { - const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); - const { t } = useTranslation('chat'); - 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'); - // In-memory queue of tool permission prompts for the current UI view. - // These are not persisted and do not survive a page refresh; introduced so - // the UI can present pending approvals while the SDK waits. - const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); - 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 inputHighlightRef = useRef(null); - const scrollContainerRef = useRef(null); - const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls - const isLoadingMoreRef = useRef(false); - const topLoadLockRef = useRef(false); - const pendingScrollRestoreRef = useRef(null); - // Streaming throttle buffers - const streamBufferRef = useRef(''); - const streamTimerRef = useRef(null); - // Track the session that this view expects when starting a brand‑new chat - // (prevents background sessions from streaming into a different view). - const pendingViewSessionRef = useRef(null); - const commandQueryTimerRef = useRef(null); - const [debouncedInput, setDebouncedInput] = useState(''); - const [showFileDropdown, setShowFileDropdown] = useState(false); - const [fileList, setFileList] = useState([]); - const [fileMentions, setFileMentions] = 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 [thinkingMode, setThinkingMode] = useState('none'); - const [provider, setProvider] = useState(() => { - return localStorage.getItem('selected-provider') || 'claude'; - }); - const [cursorModel, setCursorModel] = useState(() => { - return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; - }); - const [claudeModel, setClaudeModel] = useState(() => { - return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT; - }); - const [codexModel, setCodexModel] = useState(() => { - return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; - }); - // Track provider transitions so we only clear approvals when provider truly changes. - // This does not sync with the backend; it just prevents UI prompts from disappearing. - const lastProviderRef = useRef(provider); - - const resetStreamingState = useCallback(() => { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - streamBufferRef.current = ''; - }, []); - // 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]); - - // Clear pending permission prompts when switching providers; filter when switching sessions. - // This does not preserve prompts across provider changes; it exists to keep the - // Claude approval flow intact while preventing prompts from a different provider. - useEffect(() => { - if (lastProviderRef.current !== provider) { - setPendingPermissionRequests([]); - lastProviderRef.current = provider; - } - }, [provider]); - - // When the selected session changes, drop prompts that belong to other sessions. - // This does not attempt to migrate prompts across sessions; it only filters, - // introduced so the UI does not show approvals for a session the user is no longer viewing. - useEffect(() => { - setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id)); - }, [selectedSession?.id]); - - // Load Cursor default model from config - useEffect(() => { - if (provider === 'cursor') { - authenticatedFetch('/api/cursor/config') - .then(res => res.json()) - .then(data => { - if (data.success && data.config?.model?.modelId) { - // Use the model from config directly - const modelId = data.config.model.modelId; - if (!localStorage.getItem('cursor-model')) { - setCursorModel(modelId); - } - } - }) - .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 : claudeModel, - 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, provider = 'claude') => { - 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, provider); - if (!response.ok) { - throw new Error('Failed to load session messages'); - } - const data = await response.json(); - - // Extract token usage if present (Codex includes it in messages response) - if (isInitialLoad && data.tokenUsage) { - setTokenBudget(data.tokenUsage); - } - - // 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()), - // Extract structured tool result data (e.g., for Grep, Glob) - toolUseResult: msg.toolUseResult || null - }); - } - } - } - } - - // 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 with math formula protection - content = unescapeWithMathProtection(content); - converted.push({ - type: messageType, - content: content, - timestamp: msg.timestamp || new Date().toISOString() - }); - } - } - - // Handle thinking messages (Codex reasoning) - else if (msg.type === 'thinking' && msg.message?.content) { - converted.push({ - type: 'assistant', - content: unescapeWithMathProtection(msg.message.content), - timestamp: msg.timestamp || new Date().toISOString(), - isThinking: true - }); - } - - // Handle tool_use messages (Codex function calls) - else if (msg.type === 'tool_use' && msg.toolName) { - converted.push({ - type: 'assistant', - content: '', - timestamp: msg.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: msg.toolName, - toolInput: msg.toolInput || '', - toolCallId: msg.toolCallId - }); - } - - // Handle tool_result messages (Codex function outputs) - else if (msg.type === 'tool_result') { - // Find the matching tool_use by callId, or the last tool_use without a result - for (let i = converted.length - 1; i >= 0; i--) { - if (converted[i].isToolUse && !converted[i].toolResult) { - if (!msg.toolCallId || converted[i].toolCallId === msg.toolCallId) { - converted[i].toolResult = { - content: msg.output || '', - isError: false - }; - break; - } - } - } - } - - // 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 with math formula protection - let text = part.text; - if (typeof text === 'string') { - text = unescapeWithMathProtection(text); - } - 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 ? { - content: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content), - isError: toolResult.isError, - toolUseResult: toolResult.toolUseResult - } : null, - toolError: toolResult?.isError || false, - toolResultTimestamp: toolResult?.timestamp || new Date() - }); - } - } - } else if (typeof msg.message.content === 'string') { - // Unescape with math formula protection - let text = msg.message.content; - text = unescapeWithMathProtection(text); - 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; - // Don't reset isUserScrolledUp here - let the scroll handler manage it - // This prevents fighting with user's scroll position during streaming - } - }, []); - - // 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; - }, []); - - const loadOlderMessages = useCallback(async (container) => { - if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false; - if (!hasMoreMessages || !selectedSession || !selectedProject) return false; - - const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider === 'cursor') return false; - - isLoadingMoreRef.current = true; - const previousScrollHeight = container.scrollHeight; - const previousScrollTop = container.scrollTop; - - try { - const moreMessages = await loadSessionMessages( - selectedProject.name, - selectedSession.id, - true, - sessionProvider - ); - - if (moreMessages.length > 0) { - pendingScrollRestoreRef.current = { - height: previousScrollHeight, - top: previousScrollTop - }; - // Prepend new messages to the existing ones - setSessionMessages(prev => [...moreMessages, ...prev]); - } - return true; - } finally { - isLoadingMoreRef.current = false; - } - }, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); - - // 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; - if (!scrolledNearTop) { - topLoadLockRef.current = false; - } else if (!topLoadLockRef.current) { - const didLoad = await loadOlderMessages(container); - if (didLoad) { - topLoadLockRef.current = true; - } - } - } - }, [isNearBottom, loadOlderMessages]); - - // Restore scroll position after paginated messages render - useLayoutEffect(() => { - if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; - - const { height, top } = pendingScrollRestoreRef.current; - const container = scrollContainerRef.current; - const newScrollHeight = container.scrollHeight; - const scrollDiff = newScrollHeight - height; - - container.scrollTop = top + Math.max(scrollDiff, 0); - pendingScrollRestoreRef.current = null; - }, [chatMessages.length]); - - 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) { - if (!isSystemSessionChange) { - // Clear any streaming leftovers from the previous session - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - } - // 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, selectedSession.__provider || 'claude'); - 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 { - // New session view (no selected session) - always reset UI state - if (!isSystemSessionChange) { - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); - } - setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - setTokenBudget(null); - } - - // 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, resetStreamingState]); - - // 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) { - 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/Codex messages from API/JSONL - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); - setSessionMessages(messages); - // convertedMessages will be automatically updated via useMemo - - // Smart scroll behavior: only auto-scroll if user is near bottom - const shouldAutoScroll = autoScrollToBottom && isNearBottom(); - if (shouldAutoScroll) { - 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]); - - // When the user navigates to a specific session, clear any pending "new session" marker. - useEffect(() => { - if (selectedSession?.id) { - pendingViewSessionRef.current = null; - } - }, [selectedSession?.id]); - - // 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 (latestMessage) { - const messageData = latestMessage.data?.message || latestMessage.data; - - // 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']; - const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); - const lifecycleMessageTypes = new Set([ - 'claude-complete', - 'codex-complete', - 'cursor-result', - 'session-aborted', - 'claude-error', - 'cursor-error', - 'codex-error' - ]); - - const isClaudeSystemInit = latestMessage.type === 'claude-response' && - messageData && - messageData.type === 'system' && - messageData.subtype === 'init'; - const isCursorSystemInit = latestMessage.type === 'cursor-system' && - latestMessage.data && - latestMessage.data.type === 'system' && - latestMessage.data.subtype === 'init'; - - const systemInitSessionId = isClaudeSystemInit - ? messageData?.session_id - : isCursorSystemInit - ? latestMessage.data?.session_id - : null; - - const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; - const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); - const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView; - const isUnscopedError = !latestMessage.sessionId && - pendingViewSessionRef.current && - !pendingViewSessionRef.current.sessionId && - (latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error'); - - const handleBackgroundLifecycle = (sessionId) => { - if (!sessionId) return; - if (onSessionInactive) { - onSessionInactive(sessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(sessionId); - } - }; - - if (!shouldBypassSessionFilter) { - if (!activeViewSessionId) { - // No session in view; ignore session-scoped traffic. - if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - if (!isUnscopedError) { - return; - } - } - if (!latestMessage.sessionId && !isUnscopedError) { - // Drop unscoped messages to prevent cross-session bleed. - return; - } - if (latestMessage.sessionId !== activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - // Message is for a different session, ignore it - console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId); - 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); - if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { - pendingViewSessionRef.current.sessionId = latestMessage.sessionId; - } - - // Mark as system change to prevent clearing messages when session ID updates - setIsSystemSessionChange(true); - - // 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); - } - - // Attach the real session ID to any pending permission requests so they - // do not disappear during the "new-session -> real-session" transition. - // This does not create or auto-approve requests; it only keeps UI state aligned. - setPendingPermissionRequests(prev => prev.map(req => ( - req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId } - ))); - } - break; - - case 'token-budget': - // Use token budget from WebSocket for active sessions - if (latestMessage.data) { - setTokenBudget(latestMessage.data); - } - break; - - case 'claude-response': - - // 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 && - isSystemInitForView) { - - 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 && - isSystemInitForView) { - - 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 && - isSystemInitForView) { - 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-permission-request': { - // Receive a tool approval request from the backend and surface it in the UI. - // This does not approve anything automatically; it only queues a prompt, - // introduced so the user can decide before the SDK continues. - if (provider !== 'claude' || !latestMessage.requestId) { - break; - } - - setPendingPermissionRequests(prev => { - if (prev.some(req => req.requestId === latestMessage.requestId)) { - return prev; - } - return [ - ...prev, - { - requestId: latestMessage.requestId, - toolName: latestMessage.toolName || 'UnknownTool', - input: latestMessage.input, - context: latestMessage.context, - sessionId: latestMessage.sessionId || null, - receivedAt: new Date() - } - ]; - }); - - // Keep the session in a "waiting" state while approval is pending. - // This does not resume the run; it only updates the UI status so the - // user knows Claude is blocked on a decision. - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ - text: 'Waiting for permission', - tokens: 0, - can_interrupt: true - }); - break; - } - - case 'claude-permission-cancelled': { - // Backend cancelled the approval (timeout or SDK cancel); remove the banner. - // We currently do not show a user-facing warning here; this is intentional - // to avoid noisy alerts when the SDK cancels in the background. - if (!latestMessage.requestId) { - break; - } - setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId)); - 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 (!isSystemInitForView) { - return; - } - // 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'); - - // 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) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // 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}`); - } - // Conversation finished; clear any stale permission prompts. - // This does not remove saved permissions; it only resets transient UI state. - setPendingPermissionRequests([]); - break; - - case 'codex-response': - // Handle Codex SDK responses - const codexData = latestMessage.data; - if (codexData) { - // Handle item events - if (codexData.type === 'item') { - switch (codexData.itemType) { - case 'agent_message': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - break; - - case 'reasoning': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date(), - isThinking: true - }]); - } - break; - - case 'command_execution': - if (codexData.command) { - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'Bash', - toolInput: codexData.command, - toolResult: codexData.output || null, - exitCode: codexData.exitCode - }]); - } - break; - - case 'file_change': - if (codexData.changes?.length > 0) { - const changesList = codexData.changes.map(c => `${c.kind}: ${c.path}`).join('\n'); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'FileChanges', - toolInput: changesList, - toolResult: `Status: ${codexData.status}` - }]); - } - break; - - case 'mcp_tool_call': - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: `${codexData.server}:${codexData.tool}`, - toolInput: JSON.stringify(codexData.arguments, null, 2), - toolResult: codexData.result ? JSON.stringify(codexData.result, null, 2) : (codexData.error?.message || null) - }]); - break; - - case 'error': - if (codexData.message?.content) { - setChatMessages(prev => [...prev, { - type: 'error', - content: codexData.message.content, - timestamp: new Date() - }]); - } - break; - - default: - console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); - } - } - - // Handle turn complete - if (codexData.type === 'turn_complete') { - // Turn completed, message stream done - setIsLoading(false); - } - - // Handle turn failed - if (codexData.type === 'turn_failed') { - setIsLoading(false); - setChatMessages(prev => [...prev, { - type: 'error', - content: codexData.error?.message || 'Turn failed', - timestamp: new Date() - }]); - } - } - break; - - case 'codex-complete': - // Handle Codex session completion - const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - if (codexCompletedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (codexCompletedSessionId) { - if (onSessionInactive) { - onSessionInactive(codexCompletedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(codexCompletedSessionId); - } - } - - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); - const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; - if (codexPendingSessionId && !currentSessionId) { - setCurrentSessionId(codexActualSessionId); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - onNavigateToSession(codexActualSessionId); - } - sessionStorage.removeItem('pendingSessionId'); - console.log('Codex session complete, ID set to:', codexPendingSessionId); - } - - if (selectedProject) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - } - break; - - case 'codex-error': - // Handle Codex errors - setIsLoading(false); - setCanAbortSession(false); - setChatMessages(prev => [...prev, { - type: 'error', - content: latestMessage.error || 'An error occurred with Codex', - timestamp: new Date() - }]); - 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); - } - } - - // Abort ends the run; clear permission prompts to avoid dangling UI state. - // This does not change allowlists; it only clears the current banner. - setPendingPermissionRequests([]); - - 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; - - } - } - }, [latestMessage]); - - // 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]); - - const activeFileMentions = useMemo(() => { - if (!input || fileMentions.length === 0) return []; - return fileMentions.filter(path => input.includes(path)); - }, [fileMentions, input]); - - const sortedFileMentions = useMemo(() => { - if (activeFileMentions.length === 0) return []; - const unique = Array.from(new Set(activeFileMentions)); - return unique.sort((a, b) => b.length - a.length); - }, [activeFileMentions]); - - const fileMentionRegex = useMemo(() => { - if (sortedFileMentions.length === 0) return null; - const pattern = sortedFileMentions.map(escapeRegExp).join('|'); - return new RegExp(`(${pattern})`, 'g'); - }, [sortedFileMentions]); - - const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]); - - const renderInputWithMentions = useCallback((text) => { - if (!text) return ''; - if (!fileMentionRegex) return text; - const parts = text.split(fileMentionRegex); - return parts.map((part, index) => ( - fileMentionSet.has(part) ? ( - - {part} - - ) : ( - {part} - ) - )); - }, [fileMentionRegex, fileMentionSet]); - - // 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 - // Reset scroll state when switching sessions - setIsUserScrolledUp(false); - setTimeout(() => { - scrollToBottom(); - // After scrolling, the scroll event handler will naturally set isUserScrolledUp based on position - }, 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 for Claude sessions only - // (Codex token usage is included in messages response, Cursor doesn't support it) - useEffect(() => { - if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { - setTokenBudget(null); - return; - } - - const sessionProvider = selectedSession.__provider || 'claude'; - - // Skip for Codex (included in messages) and Cursor (not supported) - if (sessionProvider !== 'claude') { - return; - } - - // Fetch token usage for Claude sessions - const fetchInitialTokenUsage = 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); - } else { - setTokenBudget(null); - } - } catch (error) { - console.error('Failed to fetch initial token usage:', error); - } - }; - - fetchInitialTokenUsage(); - }, [selectedSession?.id, selectedSession?.__provider, 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; - - // Apply thinking mode prefix if selected - let messageContent = input; - const selectedThinkingMode = thinkingModes.find(mode => mode.id === thinkingMode); - if (selectedThinkingMode && selectedThinkingMode.prefix) { - messageContent = `${selectedThinkingMode.prefix}: ${input}`; - } - - // Upload images first if any - let uploadedImages = []; - if (attachedImages.length > 0) { - const formData = new FormData(); - attachedImages.forEach(file => { - formData.append('images', file); - }); - - try { - const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, { - method: 'POST', - headers: {}, // Let browser set Content-Type for FormData - 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 (!effectiveSessionId && !selectedSession?.id) { - // We are starting a brand-new session in this view. Track it so we only - // accept streaming updates for this run. - pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; - } - if (onSessionActive) { - onSessionActive(sessionToActivate); - } - - // Get tools settings from localStorage based on provider - const getToolsSettings = () => { - try { - const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' ? 'codex-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: messageContent, - 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 if (provider === 'codex') { - // Send Codex command - sendMessage({ - type: 'codex-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: selectedProject.fullPath || selectedProject.path, - projectPath: selectedProject.fullPath || selectedProject.path, - sessionId: effectiveSessionId, - resume: !!effectiveSessionId, - model: codexModel, - permissionMode: permissionMode === 'plan' ? 'default' : permissionMode - } - }); - } else { - // Send Claude command (existing code) - sendMessage({ - type: 'claude-command', - command: messageContent, - options: { - projectPath: selectedProject.path, - cwd: selectedProject.fullPath, - sessionId: currentSessionId, - resume: !!currentSessionId, - toolsSettings: toolsSettings, - permissionMode: permissionMode, - model: claudeModel, - images: uploadedImages // Pass images to backend - } - }); - } - - setInput(''); - setAttachedImages([]); - setUploadingImages(new Map()); - setImageErrors(new Map()); - setIsTextareaExpanded(false); - setThinkingMode('none'); // Reset thinking mode after sending - - // 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, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom, thinkingMode]); - - const handleGrantToolPermission = useCallback((suggestion) => { - if (!suggestion || provider !== 'claude') { - return { success: false }; - } - return grantClaudeToolPermission(suggestion.entry); - }, [provider]); - - // Send a UI decision back to the server (single or batched request IDs). - // This does not validate tool inputs or permissions; the backend enforces rules. - // It exists so "Allow & remember" can resolve multiple queued prompts at once. - const handlePermissionDecision = useCallback((requestIds, decision) => { - const ids = Array.isArray(requestIds) ? requestIds : [requestIds]; - const validIds = ids.filter(Boolean); - if (validIds.length === 0) { - return; - } - - validIds.forEach((requestId) => { - sendMessage({ - type: 'claude-permission-response', - requestId, - allow: Boolean(decision?.allow), - updatedInput: decision?.updatedInput, - message: decision?.message, - rememberEntry: decision?.rememberEntry - }); - }); - - setPendingPermissionRequests(prev => { - const next = prev.filter(req => !validIds.includes(req.requestId)); - if (next.length === 0) { - setClaudeStatus(null); - } - return next; - }); - }, [sendMessage]); - - // 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(); - // Codex doesn't support plan mode - const modes = provider === 'codex' - ? ['default', 'acceptEdits', 'bypassPermissions'] - : ['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 + 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); - setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path])); - - // 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 syncInputOverlayScroll = useCallback((target) => { - if (!inputHighlightRef.current || !target) return; - inputHighlightRef.current.scrollTop = target.scrollTop; - inputHighlightRef.current.scrollLeft = target.scrollLeft; - }, []); - - const handleTextareaClick = (e) => { - setCursorPosition(e.target.selectionStart); - }; - - -// ! Unused - const handleNewSession = () => { - setChatMessages([]); - setInput(''); - setIsLoading(false); - setCanAbortSession(false); - }; - - const handleAbortSession = () => { - if (currentSessionId && canAbortSession) { - sendMessage({ - type: 'abort-session', - sessionId: currentSessionId, - provider: provider - }); - } - }; - - const handleModeSwitch = () => { - // Codex doesn't support plan mode - const modes = provider === 'codex' - ? ['default', 'acceptEdits', 'bypassPermissions'] - : ['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 ? ( -
-
-
-

{t('session.loading.sessionMessages')}

-
-
- ) : chatMessages.length === 0 ? ( -
- {!selectedSession && !currentSessionId && ( -
-

{t('providerSelection.title')}

-

- {t('providerSelection.description')} -

- -
- {/* Claude Button */} - - - {/* Cursor Button */} - - - {/* Codex Button */} - -
- - {/* Model Selection - Always reserve space to prevent jumping */} -
- - {provider === 'claude' ? ( - - ) : provider === 'codex' ? ( - - ) : ( - - )} -
- -

- {provider === 'claude' - ? t('providerSelection.readyPrompt.claude', { model: claudeModel }) - : provider === 'cursor' - ? t('providerSelection.readyPrompt.cursor', { model: cursorModel }) - : provider === 'codex' - ? t('providerSelection.readyPrompt.codex', { model: codexModel }) - : t('providerSelection.readyPrompt.default') - } -

- - {/* Show NextTaskBanner when provider is selected and ready, only if TaskMaster is installed */} - {provider && tasksEnabled && isTaskMasterInstalled && ( -
- setInput('Start the next task')} - onShowAllTasks={onShowAllTasks} - /> -
- )} -
- )} - {selectedSession && ( -
-

{t('session.continue.title')}

-

- {t('session.continue.description')} -

- - {/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */} - {tasksEnabled && isTaskMasterInstalled && ( -
- setInput('Start the next task')} - onShowAllTasks={onShowAllTasks} - /> -
- )} -
- )} -
- ) : ( - <> - {/* Loading indicator for older messages */} - {isLoadingMoreMessages && ( -
-
-
-

{t('session.loading.olderMessages')}

-
-
- )} - - {/* Indicator showing there are more messages to load */} - {hasMoreMessages && !isLoadingMoreMessages && ( -
- {totalMessages > 0 && ( - - {t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })} • - {t('session.messages.scrollToLoad')} - - )} -
- )} - - {/* Legacy message count indicator (for non-paginated view) */} - {!hasMoreMessages && chatMessages.length > visibleMessageCount && ( -
- {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} • - -
- )} - - {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') === 'codex' ? ( - - ) : ( - - )} -
-
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : '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 */} -
- {pendingPermissionRequests.length > 0 && ( - // Permission banner for tool approvals. This renders the input, allows - // "allow once" or "allow & remember", and supports batching similar requests. - // It does not persist permissions by itself; persistence is handled by - // the existing localStorage-based settings helpers, introduced to surface - // approvals before tool execution resumes. -
- {pendingPermissionRequests.map((request) => { - const rawInput = formatToolInputForDisplay(request.input); - const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput); - const settings = getClaudeSettings(); - const alreadyAllowed = permissionEntry - ? settings.allowedTools.includes(permissionEntry) - : false; - const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember'; - // Group pending prompts that resolve to the same allow rule so - // a single "Allow & remember" can clear them in one click. - // This does not attempt fuzzy matching; it only batches identical rules. - const matchingRequestIds = permissionEntry - ? pendingPermissionRequests - .filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry) - .map(item => item.requestId) - : [request.requestId]; - - return ( -
-
-
-
- Permission required -
-
- Tool: {request.toolName} -
-
- {permissionEntry && ( -
- Allow rule: {permissionEntry} -
- )} -
- - {rawInput && ( -
- - View tool input - -
-                          {rawInput}
-                        
-
- )} - -
- - - -
-
- ); - })} -
- )} - -
- - - {/* Thinking Mode Selector */} - { - provider === 'claude' && ( - - - )} - {/* Token usage pie chart - positioned next to mode indicator */} - - - {/* Slash commands button */} - - - {/* 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} - /> - -
- - -
-