import { useEffect, useRef, useState, useMemo, memo, useCallback, lazy, Suspense, useLayoutEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import TextareaAutosize from 'react-textarea-autosize'; import { User, Bot, Terminal, CheckCircle, AlertCircle, Info, FileText, Search, FolderSearch, Pencil, FilePlus, Globe, ChevronDown, ChevronRight, Play, ArrowDown, ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useAuth } from '../contexts/AuthContext'; import { useHosts } from '../contexts/HostContext'; // Lazy load SyntaxHighlighter - saves ~500KB from initial bundle const SyntaxHighlighter = lazy(() => import('react-syntax-highlighter').then(mod => ({ default: mod.Prism })) ); // Import style separately (small JSON, OK to load eagerly for consistency) import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; // Fallback component for code blocks while SyntaxHighlighter loads const CodeFallback = memo(function CodeFallback({ children }) { return (
      {children}
    
); }); // Wrapper for lazy-loaded SyntaxHighlighter with Suspense const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, ...props }) { return ( {children}}> {children} ); }); // Helper to extract and filter system-reminder tags function parseSystemReminders(text) { if (!text || typeof text !== 'string') return { content: text || '', reminders: [] }; const reminders = []; const reminderRegex = /([\s\S]*?)<\/system-reminder>/g; let match; while ((match = reminderRegex.exec(text)) !== null) { reminders.push(match[1].trim()); } const content = text.replace(/[\s\S]*?<\/system-reminder>/g, '').trim(); return { content, reminders }; } // Helper to extract thinking tags from content function parseThinking(text) { if (!text || typeof text !== 'string') return { content: text || '', thinking: [] }; const thinking = []; const thinkingRegex = /([\s\S]*?)<\/thinking>/g; let match; while ((match = thinkingRegex.exec(text)) !== null) { thinking.push(match[1].trim()); } const content = text.replace(/[\s\S]*?<\/thinking>/g, '').trim(); return { content, thinking }; } // Collapsible thinking block component function ThinkingBlock({ thinking }) { const [show, setShow] = useState(false); if (!thinking || thinking.length === 0) return null; return (
{show && (
{thinking.map((thought, idx) => (
{thought}
))}
)}
); } // Collapsible system hints component function SystemHints({ reminders, inline = false }) { const [show, setShow] = useState(false); if (!reminders || reminders.length === 0) return null; if (inline) { return ( ); } return ( <> {show && (
{reminders.map((reminder, idx) => (
System Hint {idx + 1}:
{reminder}
))}
)} ); } // Custom comparison for MessageList - only re-render when these specific things change const messageListPropsAreEqual = (prev, next) => { // Check if messages array length changed if (prev.messages.length !== next.messages.length) return false; // Check if last message content changed (for streaming) if (prev.messages.length > 0 && next.messages.length > 0) { const prevLast = prev.messages[prev.messages.length - 1]; const nextLast = next.messages[next.messages.length - 1]; if (prevLast.content !== nextLast.content) return false; if (prevLast.type !== nextLast.type) return false; } // Check other props return ( prev.isProcessing === next.isProcessing && prev.onSendMessage === next.onSendMessage && prev.hostId === next.hostId ); }; export const MessageList = memo(function MessageList({ messages, isProcessing, onSendMessage, hostId }) { const { hosts } = useHosts(); const hostConfig = hosts[hostId] || null; const containerRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const [newMessageCount, setNewMessageCount] = useState(0); const prevMessageCount = useRef(messages.length); const userScrolledAway = useRef(false); const prevHostId = useRef(hostId); // Cache for incremental updates - avoid full rebuild on streaming content changes // Moved up so it's available for the reset effect const processedCacheRef = useRef({ messages: [], result: [], toolResultMap: new Map() }); // Reset scroll state when switching sessions (hostId changes or messages array replaced) // This fixes the "scroll to bottom" button appearing incorrectly after switching from split view useLayoutEffect(() => { // Detect session change by checking if hostId changed or if messages were reset const hostChanged = prevHostId.current !== hostId; const messagesReset = messages.length === 0 || (prevMessageCount.current > 0 && messages.length > 0 && messages[0]?.timestamp !== processedCacheRef.current.messages[0]?.timestamp); if (hostChanged || messagesReset) { // Reset all scroll-related state userScrolledAway.current = false; setShowScrollButton(false); setNewMessageCount(0); prevMessageCount.current = messages.length; prevHostId.current = hostId; // Clear processed cache to force rebuild processedCacheRef.current = { messages: [], result: [], toolResultMap: new Map() }; // Scroll to bottom after session switch if (containerRef.current) { requestAnimationFrame(() => { if (containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }); } } }, [hostId, messages]); // Check if scrolled to bottom const checkIfAtBottom = useCallback(() => { if (!containerRef.current) return true; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; return scrollHeight - scrollTop - clientHeight < 150; }, []); // Handle scroll const handleScroll = useCallback(() => { const atBottom = checkIfAtBottom(); if (atBottom) { userScrolledAway.current = false; setShowScrollButton(false); setNewMessageCount(0); } else { userScrolledAway.current = true; setShowScrollButton(true); } }, [checkIfAtBottom]); // Preprocess messages to pair tool_use with tool_result // Optimized: only rebuild when structure changes, not content const processedMessages = useMemo(() => { const cache = processedCacheRef.current; // Fast path: if only last message content changed (streaming), return cached result if (cache.messages.length === messages.length && messages.length > 0) { const lastCached = cache.messages[cache.messages.length - 1]; const lastCurrent = messages[messages.length - 1]; // Check if structure is same (only content might have changed) if (lastCached.type === lastCurrent.type && lastCached.timestamp === lastCurrent.timestamp && lastCached.toolUseId === lastCurrent.toolUseId) { // Content update only - update the cached result's last item content if (cache.result.length > 0 && lastCurrent.type === 'assistant') { cache.result[cache.result.length - 1] = { ...cache.result[cache.result.length - 1], content: lastCurrent.content }; } return cache.result; } } // Full rebuild needed const result = []; const toolResultMap = new Map(); // First pass: collect all tool_results by toolUseId messages.forEach(msg => { if (msg.type === 'tool_result' && msg.toolUseId) { toolResultMap.set(msg.toolUseId, msg); } }); // Second pass: build processed list, attaching results to tool_use messages.forEach((msg, index) => { if (msg.type === 'tool_use') { const toolResult = toolResultMap.get(msg.toolUseId); result.push({ ...msg, _pairedResult: toolResult || null, _originalIndex: index, }); } else if (msg.type === 'tool_result') { // Skip tool_results that were paired with a tool_use if (!toolResultMap.has(msg.toolUseId) || !messages.some(m => m.type === 'tool_use' && m.toolUseId === msg.toolUseId)) { // Orphan tool_result (no matching tool_use), show it standalone result.push({ ...msg, _originalIndex: index }); } // Otherwise skip - it's already paired } else { result.push({ ...msg, _originalIndex: index }); } }); // Update cache cache.messages = messages; cache.result = result; cache.toolResultMap = toolResultMap; return result; }, [messages]); // Virtualizer for efficient rendering of large message lists // Estimate height based on message type const estimateSize = useCallback((index) => { const msg = processedMessages[index]; if (!msg) return 100; switch (msg.type) { case 'user': return 80; case 'assistant': return 200; case 'tool_use': return 150; case 'tool_result': return 120; case 'system': return 40; case 'error': return 80; default: return 100; } }, [processedMessages]); const virtualizer = useVirtualizer({ count: processedMessages.length + (isProcessing ? 1 : 0), // +1 for processing indicator getScrollElement: () => containerRef.current, estimateSize, overscan: 5, // Render 5 extra items above/below viewport measureElement: (element) => { // Measure actual element height for dynamic sizing return element?.getBoundingClientRect().height ?? estimateSize(0); }, }); // Auto-scroll to bottom when new messages arrive or content changes (if not scrolled away) useLayoutEffect(() => { // Skip if user manually scrolled away if (userScrolledAway.current) { const newCount = messages.length - prevMessageCount.current; if (newCount > 0) { setNewMessageCount(prev => prev + newCount); } prevMessageCount.current = messages.length; return; } // Use native scroll for reliability - virtualizer.scrollToIndex can be unreliable if (containerRef.current && processedMessages.length > 0) { // Small delay to let DOM update after content change requestAnimationFrame(() => { if (containerRef.current && !userScrolledAway.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }); } prevMessageCount.current = messages.length; }, [messages, processedMessages.length, isProcessing]); // Also scroll on streaming content updates (last message content change) const lastMessageContent = messages[messages.length - 1]?.content; useLayoutEffect(() => { if (!userScrolledAway.current && containerRef.current && lastMessageContent) { requestAnimationFrame(() => { if (containerRef.current && !userScrolledAway.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }); } }, [lastMessageContent]); // Scroll to bottom function - use native scroll for reliability const scrollToBottomVirtual = useCallback(() => { userScrolledAway.current = false; if (containerRef.current) { containerRef.current.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' }); } setShowScrollButton(false); setNewMessageCount(0); }, []); if (messages.length === 0) { return (

Welcome to Claude Web UI

Start a session and begin chatting with Claude. All messages are streamed in real-time via JSON.

); } const items = virtualizer.getVirtualItems(); return (
{/* Virtual list container */}
{items.map((virtualItem) => { const isProcessingIndicator = virtualItem.index === processedMessages.length; return (
{isProcessingIndicator ? ( // Processing indicator
) : ( )}
); })}
{/* Floating scroll-to-bottom button */} {showScrollButton && ( )}
); }, messageListPropsAreEqual); // Memoize Message component to prevent re-renders during streaming const Message = memo(function Message({ message, onSendMessage, hostConfig }) { const { type, content, tool, input, timestamp, toolUseId, attachments } = message; const { user } = useAuth(); // Assistant name and avatar from host config const assistantName = hostConfig?.name || 'Claude'; const assistantAvatar = hostConfig?.avatar || '🤖'; // Memoize parsed system reminders and thinking for assistant messages const parsedContent = useMemo(() => { if (type === 'assistant') { const withoutReminders = parseSystemReminders(content); const withoutThinking = parseThinking(withoutReminders.content); return { content: withoutThinking.content, reminders: withoutReminders.reminders, thinking: withoutThinking.thinking, }; } return null; }, [type, content]); const renderContent = () => { switch (type) { case 'user': const hasAttachments = attachments && attachments.length > 0; const imageCount = hasAttachments ? attachments.filter(a => a.isImage).length : 0; const fileCount = hasAttachments ? attachments.length - imageCount : 0; return (
{user?.avatar ? ( {user.name ) : (
)}
{user?.name?.split(' ')[0] || 'You'}
{/* Attachment badge */} {hasAttachments && (
{imageCount > 0 && ( {imageCount} image{imageCount > 1 ? 's' : ''} )} {fileCount > 0 && ( {fileCount} file{fileCount > 1 ? 's' : ''} )}
)}
{content}
); case 'assistant': { const { content: cleanContent, reminders, thinking } = parsedContent; return (
{assistantAvatar?.startsWith('/') ? ( {assistantName} ) : (
{assistantAvatar}
)}
{assistantName}
{String(children).replace(/\n$/, '')} ) : ( {children} ); } }} > {cleanContent}
); } case 'tool_use': // Note: ExitPlanMode approval now handled via PermissionDialog modal // Just show it as a normal tool use card in the message list return ; case 'tool_result': return ; case 'system': return (
{content}
); case 'error': return (
Error
{content}
); default: return (
Unknown message type: {type}
); } }; return
{renderContent()}
; }); // Tool configuration with icons, colors, and display logic const TOOL_CONFIG = { Read: { icon: FileText, color: 'blue', label: 'Reading file', getSummary: (input) => input.file_path?.split('/').slice(-2).join('/') || 'file', }, Glob: { icon: FolderSearch, color: 'cyan', label: 'Searching files', getSummary: (input) => input.pattern || 'pattern', }, Grep: { icon: Search, color: 'yellow', label: 'Searching content', getSummary: (input) => `"${input.pattern?.substring(0, 30)}${input.pattern?.length > 30 ? '...' : ''}"` || 'pattern', }, Edit: { icon: Pencil, color: 'orange', label: 'Editing file', getSummary: (input) => input.file_path?.split('/').slice(-2).join('/') || 'file', }, Write: { icon: FilePlus, color: 'green', label: 'Writing file', getSummary: (input) => input.file_path?.split('/').slice(-2).join('/') || 'file', }, Bash: { icon: Terminal, color: 'purple', label: 'Running command', getSummary: (input) => { const desc = input.description; if (desc) return desc; const cmd = input.command || ''; return cmd.length > 50 ? cmd.substring(0, 50) + '...' : cmd; }, }, WebFetch: { icon: Globe, color: 'pink', label: 'Fetching URL', getSummary: (input) => { try { const url = new URL(input.url || ''); return url.hostname; } catch { return input.url?.substring(0, 30) || 'url'; } }, }, WebSearch: { icon: Globe, color: 'cyan', label: 'Web search', getSummary: (input) => `"${input.query?.substring(0, 40)}${input.query?.length > 40 ? '...' : ''}"` || 'query', }, Task: { icon: Play, color: 'indigo', label: 'Running agent', getSummary: (input) => input.description || input.subagent_type || 'task', }, TodoWrite: { icon: CheckCircle, color: 'green', label: 'Updating todos', getSummary: (input) => { const todos = input.todos || []; const inProgress = todos.filter(t => t.status === 'in_progress').length; const completed = todos.filter(t => t.status === 'completed').length; return `${completed}/${todos.length} done${inProgress ? `, ${inProgress} active` : ''}`; }, }, NotebookEdit: { icon: FileText, color: 'orange', label: 'Editing notebook', getSummary: (input) => input.notebook_path?.split('/').pop() || 'notebook', }, KillShell: { icon: Terminal, color: 'red', label: 'Killing shell', getSummary: (input) => input.shell_id || 'shell', }, TaskOutput: { icon: Play, color: 'indigo', label: 'Getting task output', getSummary: (input) => input.task_id?.substring(0, 8) || 'task', }, Skill: { icon: Zap, color: 'purple', label: 'Launching skill', getSummary: (input) => input.skill || 'skill', }, SlashCommand: { icon: Command, color: 'cyan', label: 'Running command', getSummary: (input) => input.command?.split(' ')[0] || 'command', }, AskUserQuestion: { icon: HelpCircle, color: 'yellow', label: 'Asking question', getSummary: (input) => { const questions = input.questions || []; if (questions.length === 1) return questions[0].header || 'question'; return `${questions.length} questions`; }, isInteractive: true, }, ExitPlanMode: { icon: ClipboardList, color: 'purple', label: 'Plan Review', getSummary: () => 'Awaiting approval', isInteractive: true, }, EnterPlanMode: { icon: Brain, color: 'purple', label: 'Entering Plan Mode', getSummary: () => 'Planning...', }, }; const COLOR_CLASSES = { blue: { bg: 'bg-blue-500/20', border: 'border-blue-500/30', text: 'text-blue-400', glow: 'shadow-blue-500/20', }, cyan: { bg: 'bg-cyan-500/20', border: 'border-cyan-500/30', text: 'text-cyan-400', glow: 'shadow-cyan-500/20', }, yellow: { bg: 'bg-yellow-500/20', border: 'border-yellow-500/30', text: 'text-yellow-400', glow: 'shadow-yellow-500/20', }, orange: { bg: 'bg-orange-500/20', border: 'border-orange-500/30', text: 'text-orange-400', glow: 'shadow-orange-500/20', }, green: { bg: 'bg-green-500/20', border: 'border-green-500/30', text: 'text-green-400', glow: 'shadow-green-500/20', }, purple: { bg: 'bg-purple-500/20', border: 'border-purple-500/30', text: 'text-purple-400', glow: 'shadow-purple-500/20', }, pink: { bg: 'bg-pink-500/20', border: 'border-pink-500/30', text: 'text-pink-400', glow: 'shadow-pink-500/20', }, indigo: { bg: 'bg-indigo-500/20', border: 'border-indigo-500/30', text: 'text-indigo-400', glow: 'shadow-indigo-500/20', }, red: { bg: 'bg-red-500/20', border: 'border-red-500/30', text: 'text-red-400', glow: 'shadow-red-500/20', }, }; // Note: PlanApprovalCard removed - ExitPlanMode approval now handled via PermissionDialog modal // Separate component for custom input - uses uncontrolled input for lag-free typing const CustomInputSection = memo(function CustomInputSection({ onSubmit, hasSelection }) { const textareaRef = useRef(null); const [hasText, setHasText] = useState(false); const canSubmit = hasSelection || hasText; const handleSubmit = useCallback(() => { const value = textareaRef.current?.value?.trim() || ''; if (value) { onSubmit(value); if (textareaRef.current) { textareaRef.current.value = ''; setHasText(false); } } else { onSubmit(null); // Signal to use selected options } }, [onSubmit]); // Only track whether there's text, not the actual content (for button state) const handleInput = useCallback(() => { const value = textareaRef.current?.value?.trim() || ''; setHasText(value.length > 0); }, []); return (
{/* Custom input field - uncontrolled for performance */}
{/* Submit button */}
); }); const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessage }) { const [showResult, setShowResult] = useState(false); const [selectedOptions, setSelectedOptions] = useState({}); const config = TOOL_CONFIG[tool] || { icon: Terminal, color: 'purple', label: tool, getSummary: () => 'executing...', }; const colors = COLOR_CLASSES[config.color] || COLOR_CLASSES.purple; const Icon = config.icon; const summary = config.getSummary(input); // Parse result content const resultData = useMemo(() => { if (!result) return null; let raw = ''; const content = result.content; if (content === undefined || content === null) { raw = '(no content)'; } else if (typeof content === 'string') { raw = content; } else if (Array.isArray(content)) { raw = content.map(c => { if (typeof c === 'string') return c; if (c?.type === 'text') return c.text || ''; return JSON.stringify(c); }).join('\n'); } else { raw = JSON.stringify(content, null, 2); } const parsed = parseSystemReminders(raw); const isSuccess = !result.isError; const chars = parsed.content.length; return { contentStr: parsed.content, systemReminders: parsed.reminders, isSuccess, chars, }; }, [result]); // Format input for display const formatInput = () => { if (typeof input === 'string') return input; return JSON.stringify(input, null, 2); }; // Render special input views for certain tools const renderSpecialInput = () => { if (tool === 'Edit' && input.old_string && input.new_string) { const oldLines = input.old_string.split('\n'); const newLines = input.new_string.split('\n'); const fileName = input.file_path?.split('/').pop() || 'file'; return (
{/* File header */}
File: {input.file_path}
{/* Diff view */}
{/* Removed lines */}
{oldLines.map((line, i) => (
{i + 1} - {line || ' '}
))}
{/* Added lines */}
{newLines.map((line, i) => (
{i + 1} + {line || ' '}
))}
{/* Stats */}
-{oldLines.length} lines +{newLines.length} lines
); } if (tool === 'Bash' && input.command) { return (
{input.command} {input.description && (
Description: {input.description}
)}
); } if (tool === 'Grep') { return (
Pattern: {input.pattern}
{input.path && (
Path: {input.path}
)} {input.glob && (
Glob: {input.glob}
)} {input.output_mode && (
Mode: {input.output_mode}
)}
); } if (tool === 'Glob') { return (
Pattern: {input.pattern}
{input.path && (
Path: {input.path}
)}
); } if (tool === 'Read') { return (
File: {input.file_path}
{(input.offset || input.limit) && (
{input.offset && (
Offset: {input.offset}
)} {input.limit && (
Limit: {input.limit}
)}
)}
); } if (tool === 'TodoWrite' && input.todos) { return (
{input.todos.map((todo, i) => (
{todo.content}
))}
); } if (tool === 'WebSearch') { return (
Query: {input.query}
{input.allowed_domains && input.allowed_domains.length > 0 && (
Domains: {input.allowed_domains.join(', ')}
)} {input.blocked_domains && input.blocked_domains.length > 0 && (
Blocked: {input.blocked_domains.join(', ')}
)}
); } if (tool === 'WebFetch') { return (
URL: {input.url}
{input.prompt && (
Prompt:
{input.prompt.length > 150 ? input.prompt.substring(0, 150) + '...' : input.prompt}
)}
); } if (tool === 'Task') { return (
{input.subagent_type && (
Agent: {input.subagent_type}
)} {input.description && (
Task: {input.description}
)} {input.prompt && (
Prompt:
{input.prompt.length > 300 ? input.prompt.substring(0, 300) + '...' : input.prompt}
)} {input.model && (
Model: {input.model}
)}
); } if (tool === 'Write') { const fileName = input.file_path?.split('/').pop() || 'file'; const extension = fileName.split('.').pop() || ''; const langMap = { js: 'javascript', jsx: 'jsx', ts: 'typescript', tsx: 'tsx', py: 'python', rb: 'ruby', go: 'go', rs: 'rust', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown', css: 'css', scss: 'scss', html: 'html', sh: 'bash', }; const language = langMap[extension] || 'text'; const lines = (input.content || '').split('\n').length; return (
File: {input.file_path} • {lines} lines
{input.content && ( {input.content} )}
); } if (tool === 'Skill') { return (
{input.skill}
); } if (tool === 'SlashCommand') { return (
{input.command}
); } if (tool === 'AskUserQuestion') { const questions = input.questions || []; // Only consider it "answered" if there's a successful result (not an error) const hasResult = result && !result.isError; // Handle option selection const handleOptionClick = (qIdx, optIdx, isMulti) => { if (hasResult) return; // Already answered setSelectedOptions(prev => { const key = `q${qIdx}`; if (isMulti) { const current = prev[key] || []; if (current.includes(optIdx)) { return { ...prev, [key]: current.filter(i => i !== optIdx) }; } return { ...prev, [key]: [...current, optIdx] }; } return { ...prev, [key]: [optIdx] }; }); }; const isOptionSelected = (qIdx, optIdx) => { return (selectedOptions[`q${qIdx}`] || []).includes(optIdx); }; const hasSelection = Object.values(selectedOptions).some(arr => arr.length > 0); // Handle submit from CustomInputSection const handleCustomSubmit = useCallback((customText) => { if (!onSendMessage || hasResult) return; if (customText) { // Custom text provided onSendMessage(customText); } else { // Use selected options const answers = questions.map((q, qIdx) => { const selected = selectedOptions[`q${qIdx}`] || []; const labels = selected.map(idx => q.options[idx]?.label).filter(Boolean); return labels.join(', ') || ''; }).filter(Boolean); if (answers.length > 0) { onSendMessage(answers.join('\n')); } } }, [onSendMessage, hasResult, questions, selectedOptions]); return (
{questions.map((q, qIdx) => (
{/* Question header */}
{q.header} {q.multiSelect && ( (select multiple) )}
{/* Question text */}

{q.question}

{/* Options as grid for better layout */}
{q.options?.map((opt, optIdx) => { const isSelected = isOptionSelected(qIdx, optIdx); return ( ); })}
))} {/* Custom input + Submit section - isolated to prevent re-renders */} {!hasResult && onSendMessage && ( )} {hasResult && (
Response submitted
)}
); } // Default JSON view return ( {formatInput()} ); }; // Detect if result content looks like code const resultLooksLikeCode = resultData?.contentStr && ( resultData.contentStr.includes('function ') || resultData.contentStr.includes('const ') || resultData.contentStr.includes('import ') || resultData.contentStr.includes('export ') || resultData.contentStr.startsWith('{') || resultData.contentStr.startsWith('[') ); // Interactive tools like AskUserQuestion need more space const isInteractiveTool = config.isInteractive; const cardWidthClass = isInteractiveTool ? 'w-full max-w-2xl' : 'inline-block max-w-[85%]'; const contentHeightClass = isInteractiveTool ? '' : 'max-h-48 overflow-y-auto'; return (
{/* Header */}
{/* Icon */}
{/* Tool info */}
{config.label} • {summary} {/* Result badge - hide for interactive tools since they handle their own state */} {resultData && !isInteractiveTool && ( <> • {resultData.isSuccess ? ( ) : ( )} {resultData.isSuccess ? 'OK' : 'Error'} )}
{/* Content - interactive tools get full height, others are capped */}
{renderSpecialInput()}
{/* Collapsible Result Section - hide for interactive tools */} {resultData && !isInteractiveTool && (
{showResult && (
{/* System Reminders */} {resultData.systemReminders.length > 0 && (
{resultData.systemReminders.map((reminder, idx) => (
Hint {idx + 1}:
{reminder.length > 200 ? reminder.substring(0, 200) + '...' : reminder}
))}
)} {/* Result Content */}
{resultLooksLikeCode ? ( {resultData.contentStr} ) : (
{resultData.contentStr}
)}
)}
)}
); }); const ToolResultCard = memo(function ToolResultCard({ content, isSuccess = true }) { const [showSystemInfo, setShowSystemInfo] = useState(false); // Memoize parsed content const { rawContent, contentStr, systemReminders } = useMemo(() => { let raw = ''; if (content === undefined || content === null) { raw = '(no content)'; } else if (typeof content === 'string') { raw = content; } else if (Array.isArray(content)) { raw = content.map(c => { if (typeof c === 'string') return c; if (c?.type === 'text') return c.text || ''; return JSON.stringify(c); }).join('\n'); } else { raw = JSON.stringify(content, null, 2); } const parsed = parseSystemReminders(raw); return { rawContent: raw, contentStr: parsed.content, systemReminders: parsed.reminders }; }, [content]); const lines = contentStr.split('\n').length; const chars = contentStr.length; // Detect if content looks like code or command output const looksLikeCode = contentStr.includes('function ') || contentStr.includes('const ') || contentStr.includes('import ') || contentStr.includes('export ') || contentStr.startsWith('{') || contentStr.startsWith('['); return (
{/* Header */}
{isSuccess ? ( ) : ( )}
{isSuccess ? 'Success' : 'Error'} • {lines} lines, {chars} chars {systemReminders.length > 0 && ( <> • )}
{/* System Reminders - collapsible */} {showSystemInfo && systemReminders.length > 0 && (
{systemReminders.map((reminder, idx) => (
System Hint {idx + 1}:
{reminder}
))}
)} {/* Content - always visible with max height and scroll */} {contentStr && (
{looksLikeCode ? ( {contentStr} ) : (
{contentStr}
)}
)}
); });