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 (
setShow(!show)}
className="flex items-center gap-2 text-xs text-purple-400 hover:text-purple-300 transition-colors"
>
Thinking
{show ? : }
{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 (
setShow(!show)}
className="inline-flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors"
>
{reminders.length} hint{reminders.length > 1 ? 's' : ''}
{show ? : }
);
}
return (
<>
setShow(!show)}
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors mt-2"
>
{reminders.length} system hint{reminders.length > 1 ? 's' : ''}
{show ? : }
{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 && (
{newMessageCount > 0 ? `${newMessageCount} new` : 'Back to bottom'}
)}
);
}, 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?.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('/') ? (
) : (
{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 (
);
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 */}
Or type a custom response:
{/* Submit button */}
Submit Answer
);
});
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 (
);
}
if (tool === 'SlashCommand') {
return (
);
}
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 (
handleOptionClick(qIdx, optIdx, q.multiSelect)}
disabled={hasResult}
className={`w-full flex items-start gap-3 p-3 rounded-lg border text-left
transition-all duration-200 ease-out
${hasResult
? 'bg-dark-800/30 border-dark-700 opacity-60 cursor-not-allowed'
: isSelected
? 'bg-yellow-500/20 border-yellow-500/50 shadow-lg shadow-yellow-500/10 scale-[1.01]'
: 'bg-dark-800/50 border-dark-700 hover:border-yellow-500/30 hover:bg-dark-800 hover:scale-[1.005]'
}`}
>
{q.multiSelect ? (
{isSelected && }
) : (
)}
{opt.label}
{opt.description && (
{opt.description}
)}
);
})}
))}
{/* 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 && (
setShowResult(!showResult)}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors
${resultData.isSuccess
? 'bg-green-500/5 hover:bg-green-500/10 text-green-400'
: 'bg-red-500/5 hover:bg-red-500/10 text-red-400'
}`}
>
{showResult ? : }
Result
({resultData.chars} chars)
{resultData.systemReminders.length > 0 && (
{resultData.systemReminders.length} hint{resultData.systemReminders.length > 1 ? 's' : ''}
)}
{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 ? 'Success' : 'Error'}
•
{lines} lines, {chars} chars
{systemReminders.length > 0 && (
<>
•
setShowSystemInfo(!showSystemInfo)}
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors"
>
{systemReminders.length} system hint{systemReminders.length > 1 ? 's' : ''}
{showSystemInfo ? : }
>
)}
{/* 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}
)}
)}
);
});