Files
claude-web-ui/frontend/src/components/MessageList.jsx
Nikolas Syring 8d33294cc5 fix: Reset scroll state when switching between split/tabbed view
The "Back to bottom" button was incorrectly appearing after switching
from split view to tabbed view because scroll-related refs weren't
being reset when the session changed.

Added session change detection that resets:
- userScrolledAway ref
- showScrollButton state
- newMessageCount counter
- processedMessages cache

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:44:46 +01:00

1601 lines
58 KiB
JavaScript

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 (
<pre className="bg-dark-900 rounded-lg p-3 text-xs text-dark-300 font-mono overflow-x-auto animate-pulse">
<code>{children}</code>
</pre>
);
});
// Wrapper for lazy-loaded SyntaxHighlighter with Suspense
const LazyCodeBlock = memo(function LazyCodeBlock({ language, style, customStyle, children, ...props }) {
return (
<Suspense fallback={<CodeFallback>{children}</CodeFallback>}>
<SyntaxHighlighter
language={language}
style={style}
customStyle={customStyle}
{...props}
>
{children}
</SyntaxHighlighter>
</Suspense>
);
});
// Helper to extract and filter system-reminder tags
function parseSystemReminders(text) {
if (!text || typeof text !== 'string') return { content: text || '', reminders: [] };
const reminders = [];
const reminderRegex = /<system-reminder>([\s\S]*?)<\/system-reminder>/g;
let match;
while ((match = reminderRegex.exec(text)) !== null) {
reminders.push(match[1].trim());
}
const content = text.replace(/<system-reminder>[\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 = /<thinking>([\s\S]*?)<\/thinking>/g;
let match;
while ((match = thinkingRegex.exec(text)) !== null) {
thinking.push(match[1].trim());
}
const content = text.replace(/<thinking>[\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 (
<div className="mb-3">
<button
onClick={() => setShow(!show)}
className="flex items-center gap-2 text-xs text-purple-400 hover:text-purple-300 transition-colors"
>
<Brain className="w-3.5 h-3.5" />
<span>Thinking</span>
{show ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{show && (
<div className="mt-2 p-3 bg-purple-900/10 border border-purple-500/20 rounded-lg">
{thinking.map((thought, idx) => (
<div key={idx} className="text-sm text-purple-300/80 italic whitespace-pre-wrap">
{thought}
</div>
))}
</div>
)}
</div>
);
}
// Collapsible system hints component
function SystemHints({ reminders, inline = false }) {
const [show, setShow] = useState(false);
if (!reminders || reminders.length === 0) return null;
if (inline) {
return (
<span className="inline-flex items-center gap-1 ml-2">
<button
onClick={() => setShow(!show)}
className="inline-flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors"
>
<Info className="w-3 h-3" />
<span>{reminders.length} hint{reminders.length > 1 ? 's' : ''}</span>
{show ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
</span>
);
}
return (
<>
<button
onClick={() => setShow(!show)}
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors mt-2"
>
<Info className="w-3 h-3" />
<span>{reminders.length} system hint{reminders.length > 1 ? 's' : ''}</span>
{show ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{show && (
<div className="mt-2 border-t border-purple-500/20 bg-purple-900/10 rounded-lg p-2.5">
{reminders.map((reminder, idx) => (
<div key={idx} className="text-xs text-purple-300/70 font-mono whitespace-pre-wrap mb-2 last:mb-0">
<span className="text-purple-400 font-semibold">System Hint {idx + 1}:</span>
<div className="mt-1 pl-2 border-l-2 border-purple-500/30 max-h-32 overflow-y-auto">
{reminder}
</div>
</div>
))}
</div>
)}
</>
);
}
// 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 (
<div className="flex-1 min-h-0 flex items-center justify-center p-8">
<div className="text-center text-dark-500 max-w-md">
<Bot className="w-16 h-16 mx-auto mb-4 opacity-50" />
<h2 className="text-xl font-semibold mb-2">Welcome to Claude Web UI</h2>
<p className="text-sm">
Start a session and begin chatting with Claude.
All messages are streamed in real-time via JSON.
</p>
</div>
</div>
);
}
const items = virtualizer.getVirtualItems();
return (
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
onScroll={handleScroll}
className="h-full overflow-y-auto"
>
{/* Virtual list container */}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{items.map((virtualItem) => {
const isProcessingIndicator = virtualItem.index === processedMessages.length;
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
className="px-4 py-2"
>
{isProcessingIndicator ? (
// Processing indicator
<div className="flex items-center gap-3 text-dark-400">
<Bot className="w-6 h-6" />
<div className="flex gap-1">
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
</div>
</div>
) : (
<Message
message={processedMessages[virtualItem.index]}
onSendMessage={onSendMessage}
hostConfig={hostConfig}
/>
)}
</div>
);
})}
</div>
</div>
{/* Floating scroll-to-bottom button */}
{showScrollButton && (
<button
onClick={scrollToBottomVirtual}
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-full shadow-lg transition-all duration-200 hover:scale-105 z-10"
>
<ArrowDown className="w-4 h-4" />
<span className="text-sm font-medium">
{newMessageCount > 0 ? `${newMessageCount} new` : 'Back to bottom'}
</span>
</button>
)}
</div>
);
}, 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 (
<div className="flex gap-3 message-enter">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.name || 'You'}
className="w-8 h-8 rounded-lg flex-shrink-0 object-cover"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4" />
</div>
)}
<div className="max-w-[85%]">
<div className="text-xs text-dark-500 mb-1">{user?.name?.split(' ')[0] || 'You'}</div>
<div className="bg-dark-800 rounded-lg rounded-tl-none p-3 text-dark-100 inline-block">
{/* Attachment badge */}
{hasAttachments && (
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-dark-700">
<Paperclip className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-orange-400">
{imageCount > 0 && (
<span className="inline-flex items-center gap-1 mr-2">
<Image className="w-3 h-3" />
{imageCount} image{imageCount > 1 ? 's' : ''}
</span>
)}
{fileCount > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="w-3 h-3" />
{fileCount} file{fileCount > 1 ? 's' : ''}
</span>
)}
</span>
</div>
)}
<pre className="whitespace-pre-wrap font-sans text-sm">{content}</pre>
</div>
</div>
</div>
);
case 'assistant': {
const { content: cleanContent, reminders, thinking } = parsedContent;
return (
<div className="flex gap-3 message-enter">
{assistantAvatar?.startsWith('/') ? (
<img
src={assistantAvatar}
alt={assistantName}
className="w-8 h-8 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-orange-600 flex items-center justify-center flex-shrink-0 text-lg">
{assistantAvatar}
</div>
)}
<div className="max-w-[85%]">
<div className="text-xs text-dark-500 mb-1">{assistantName}</div>
<div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
<ThinkingBlock thinking={thinking} />
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<LazyCodeBlock
style={oneDark}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.75rem',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</LazyCodeBlock>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}}
>
{cleanContent}
</ReactMarkdown>
<SystemHints reminders={reminders} />
</div>
</div>
</div>
);
}
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 <ToolUseCard tool={tool} input={input} result={message._pairedResult} onSendMessage={onSendMessage} />;
case 'tool_result':
return <ToolResultCard content={content} isSuccess={!message.isError} />;
case 'system':
return (
<div className="flex items-center gap-2 justify-center message-enter">
<Info className="w-4 h-4 text-dark-500" />
<span className="text-xs text-dark-500">{content}</span>
</div>
);
case 'error':
return (
<div className="flex gap-3 message-enter">
<div className="w-8 h-8 rounded-lg bg-red-600/30 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-red-400 mb-1">Error</div>
<div className="bg-red-900/20 rounded-lg p-3 text-red-300 border border-red-900/50 text-sm">
{content}
</div>
</div>
</div>
);
default:
return (
<div className="text-xs text-dark-500 ml-11">
Unknown message type: {type}
</div>
);
}
};
return <div>{renderContent()}</div>;
});
// 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 (
<div className="space-y-3 pt-3 border-t border-dark-700">
{/* Custom input field - uncontrolled for performance */}
<div className="space-y-2">
<label className="text-xs text-dark-500 font-medium">Or type a custom response:</label>
<TextareaAutosize
ref={textareaRef}
defaultValue=""
onInput={handleInput}
placeholder="Enter your own answer..."
minRows={2}
maxRows={6}
cacheMeasurements
className="w-full px-3 py-2 bg-dark-800 border border-dark-700 rounded-lg text-sm text-dark-200
placeholder-dark-500 resize-none
focus:outline-none focus:border-yellow-500/50 focus:ring-1 focus:ring-yellow-500/20
transition-colors duration-200"
/>
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={`w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
canSubmit
? 'bg-yellow-500 text-dark-900 hover:bg-yellow-400 shadow-lg shadow-yellow-500/20 hover:shadow-yellow-500/30'
: 'bg-dark-700 text-dark-500 cursor-not-allowed'
}`}
>
Submit Answer
</button>
</div>
);
});
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 (
<div className="space-y-2">
{/* File header */}
<div className="flex items-center gap-2 text-xs">
<span className="text-dark-500">File:</span>
<code className="text-orange-400">{input.file_path}</code>
</div>
{/* Diff view */}
<div className="rounded overflow-hidden border border-dark-700 text-xs font-mono">
{/* Removed lines */}
<div className="bg-red-900/30">
{oldLines.map((line, i) => (
<div key={`old-${i}`} className="flex">
<span className="w-8 text-right pr-2 text-red-400/50 select-none border-r border-dark-700 bg-red-900/20">
{i + 1}
</span>
<span className="w-6 text-center text-red-400 select-none">-</span>
<span className="flex-1 text-red-300 pl-1 whitespace-pre-wrap break-all">{line || ' '}</span>
</div>
))}
</div>
{/* Added lines */}
<div className="bg-green-900/30 border-t border-dark-700">
{newLines.map((line, i) => (
<div key={`new-${i}`} className="flex">
<span className="w-8 text-right pr-2 text-green-400/50 select-none border-r border-dark-700 bg-green-900/20">
{i + 1}
</span>
<span className="w-6 text-center text-green-400 select-none">+</span>
<span className="flex-1 text-green-300 pl-1 whitespace-pre-wrap break-all">{line || ' '}</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-xs text-dark-500">
<span className="text-red-400">-{oldLines.length} lines</span>
<span className="text-green-400">+{newLines.length} lines</span>
</div>
</div>
);
}
if (tool === 'Bash' && input.command) {
return (
<div className="space-y-2">
<LazyCodeBlock
language="bash"
style={oneDark}
customStyle={{ margin: 0, padding: '8px', fontSize: '11px', borderRadius: '4px' }}
>
{input.command}
</LazyCodeBlock>
{input.description && (
<div className="text-xs text-dark-500">Description: {input.description}</div>
)}
</div>
);
}
if (tool === 'Grep') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Pattern:</span>
<code className="text-xs text-yellow-400 bg-dark-800 px-2 py-0.5 rounded">{input.pattern}</code>
</div>
{input.path && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Path:</span>
<code className="text-xs text-dark-300">{input.path}</code>
</div>
)}
{input.glob && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Glob:</span>
<code className="text-xs text-dark-300">{input.glob}</code>
</div>
)}
{input.output_mode && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Mode:</span>
<code className="text-xs text-dark-300">{input.output_mode}</code>
</div>
)}
</div>
);
}
if (tool === 'Glob') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Pattern:</span>
<code className="text-xs text-cyan-400 bg-dark-800 px-2 py-0.5 rounded">{input.pattern}</code>
</div>
{input.path && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Path:</span>
<code className="text-xs text-dark-300">{input.path}</code>
</div>
)}
</div>
);
}
if (tool === 'Read') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">File:</span>
<code className="text-xs text-blue-400">{input.file_path}</code>
</div>
{(input.offset || input.limit) && (
<div className="flex items-center gap-4">
{input.offset && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Offset:</span>
<code className="text-xs text-dark-300">{input.offset}</code>
</div>
)}
{input.limit && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Limit:</span>
<code className="text-xs text-dark-300">{input.limit}</code>
</div>
)}
</div>
)}
</div>
);
}
if (tool === 'TodoWrite' && input.todos) {
return (
<div className="space-y-1">
{input.todos.map((todo, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={`w-2 h-2 rounded-full ${
todo.status === 'completed' ? 'bg-green-500' :
todo.status === 'in_progress' ? 'bg-yellow-500' : 'bg-dark-500'
}`} />
<span className={todo.status === 'completed' ? 'text-dark-500 line-through' : 'text-dark-300'}>
{todo.content}
</span>
</div>
))}
</div>
);
}
if (tool === 'WebSearch') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Query:</span>
<code className="text-xs text-cyan-400 bg-dark-800 px-2 py-0.5 rounded">{input.query}</code>
</div>
{input.allowed_domains && input.allowed_domains.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Domains:</span>
<code className="text-xs text-green-400">{input.allowed_domains.join(', ')}</code>
</div>
)}
{input.blocked_domains && input.blocked_domains.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Blocked:</span>
<code className="text-xs text-red-400">{input.blocked_domains.join(', ')}</code>
</div>
)}
</div>
);
}
if (tool === 'WebFetch') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">URL:</span>
<code className="text-xs text-pink-400 bg-dark-800 px-2 py-0.5 rounded truncate max-w-xs">{input.url}</code>
</div>
{input.prompt && (
<div className="space-y-1">
<span className="text-xs text-dark-500">Prompt:</span>
<div className="text-xs text-dark-300 bg-dark-800 px-2 py-1 rounded">
{input.prompt.length > 150 ? input.prompt.substring(0, 150) + '...' : input.prompt}
</div>
</div>
)}
</div>
);
}
if (tool === 'Task') {
return (
<div className="space-y-2">
{input.subagent_type && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Agent:</span>
<code className="text-xs text-indigo-400 bg-dark-800 px-2 py-0.5 rounded">{input.subagent_type}</code>
</div>
)}
{input.description && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Task:</span>
<span className="text-xs text-dark-300">{input.description}</span>
</div>
)}
{input.prompt && (
<div className="space-y-1">
<span className="text-xs text-dark-500">Prompt:</span>
<div className="text-xs text-dark-300 bg-dark-800 px-2 py-1 rounded max-h-24 overflow-y-auto">
{input.prompt.length > 300 ? input.prompt.substring(0, 300) + '...' : input.prompt}
</div>
</div>
)}
{input.model && (
<div className="flex items-center gap-2">
<span className="text-xs text-dark-500">Model:</span>
<code className="text-xs text-purple-400">{input.model}</code>
</div>
)}
</div>
);
}
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 (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<span className="text-dark-500">File:</span>
<code className="text-green-400">{input.file_path}</code>
<span className="text-dark-600"></span>
<span className="text-dark-500">{lines} lines</span>
</div>
{input.content && (
<LazyCodeBlock
language={language}
style={oneDark}
customStyle={{ margin: 0, padding: '8px', fontSize: '11px', borderRadius: '4px', maxHeight: '200px' }}
showLineNumbers={true}
wrapLongLines={true}
>
{input.content}
</LazyCodeBlock>
)}
</div>
);
}
if (tool === 'Skill') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium text-purple-400">{input.skill}</span>
</div>
</div>
);
}
if (tool === 'SlashCommand') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Command className="w-4 h-4 text-cyan-400" />
<code className="text-sm text-cyan-400 bg-dark-800 px-2 py-0.5 rounded">{input.command}</code>
</div>
</div>
);
}
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 (
<div className="space-y-5">
{questions.map((q, qIdx) => (
<div key={qIdx} className="space-y-3">
{/* Question header */}
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 bg-yellow-500/20 text-yellow-400 text-xs font-medium rounded-md">
{q.header}
</span>
{q.multiSelect && (
<span className="text-xs text-dark-500">(select multiple)</span>
)}
</div>
{/* Question text */}
<p className="text-sm text-dark-200 leading-relaxed">{q.question}</p>
{/* Options as grid for better layout */}
<div className="grid gap-2">
{q.options?.map((opt, optIdx) => {
const isSelected = isOptionSelected(qIdx, optIdx);
return (
<button
key={optIdx}
onClick={() => 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]'
}`}
>
<div className="mt-0.5 transition-transform duration-200">
{q.multiSelect ? (
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all duration-200 ${
isSelected ? 'border-yellow-500 bg-yellow-500 scale-110' : 'border-dark-500 bg-dark-700'
}`}>
{isSelected && <CheckCircle className="w-3.5 h-3.5 text-dark-900" />}
</div>
) : (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all duration-200 ${
isSelected ? 'border-yellow-500 bg-yellow-500 scale-110' : 'border-dark-500 bg-dark-700'
}`}>
{isSelected && <div className="w-2.5 h-2.5 rounded-full bg-dark-900" />}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium transition-colors duration-200 ${isSelected ? 'text-yellow-300' : 'text-dark-200'}`}>
{opt.label}
</div>
{opt.description && (
<div className="text-xs text-dark-500 mt-1 leading-relaxed">{opt.description}</div>
)}
</div>
</button>
);
})}
</div>
</div>
))}
{/* Custom input + Submit section - isolated to prevent re-renders */}
{!hasResult && onSendMessage && (
<CustomInputSection onSubmit={handleCustomSubmit} hasSelection={hasSelection} />
)}
{hasResult && (
<div className="flex items-center gap-2 pt-3 border-t border-dark-700 text-sm text-green-400">
<CheckCircle className="w-4 h-4" />
<span>Response submitted</span>
</div>
)}
</div>
);
}
// Default JSON view
return (
<LazyCodeBlock
language="json"
style={oneDark}
customStyle={{ margin: 0, padding: '8px', fontSize: '11px', borderRadius: '4px' }}
>
{formatInput()}
</LazyCodeBlock>
);
};
// 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 (
<div className={`ml-11 message-enter rounded-lg border ${colors.border} ${colors.bg} overflow-hidden transition-all duration-200 hover:shadow-lg ${colors.glow} ${cardWidthClass}`}>
{/* Header */}
<div className="flex items-center gap-3 p-2.5">
{/* Icon */}
<div className={`w-7 h-7 rounded-md ${colors.bg} border ${colors.border} flex items-center justify-center flex-shrink-0`}>
<Icon className={`w-4 h-4 ${colors.text}`} />
</div>
{/* Tool info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${colors.text}`}>{config.label}</span>
<span className="text-dark-600"></span>
<code className="text-xs text-dark-300 truncate">{summary}</code>
{/* Result badge - hide for interactive tools since they handle their own state */}
{resultData && !isInteractiveTool && (
<>
<span className="text-dark-600"></span>
<span className={`inline-flex items-center gap-1 text-xs ${resultData.isSuccess ? 'text-green-400' : 'text-red-400'}`}>
{resultData.isSuccess ? (
<CheckCircle className="w-3 h-3" />
) : (
<AlertCircle className="w-3 h-3" />
)}
{resultData.isSuccess ? 'OK' : 'Error'}
</span>
</>
)}
</div>
</div>
</div>
{/* Content - interactive tools get full height, others are capped */}
<div className={`border-t border-dark-700/50 p-3 bg-dark-900/50 overflow-x-auto ${contentHeightClass}`}>
{renderSpecialInput()}
</div>
{/* Collapsible Result Section - hide for interactive tools */}
{resultData && !isInteractiveTool && (
<div className="border-t border-dark-700/50">
<button
onClick={() => 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 ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<span>Result</span>
<span className="text-dark-500">({resultData.chars} chars)</span>
{resultData.systemReminders.length > 0 && (
<span className="text-purple-400 ml-auto">{resultData.systemReminders.length} hint{resultData.systemReminders.length > 1 ? 's' : ''}</span>
)}
</button>
{showResult && (
<div className={`p-3 ${resultData.isSuccess ? 'bg-green-500/5' : 'bg-red-500/5'}`}>
{/* System Reminders */}
{resultData.systemReminders.length > 0 && (
<div className="mb-2 pb-2 border-b border-purple-500/20">
{resultData.systemReminders.map((reminder, idx) => (
<div key={idx} className="text-xs text-purple-300/70 font-mono whitespace-pre-wrap mb-1 last:mb-0">
<span className="text-purple-400 font-semibold">Hint {idx + 1}:</span>
<div className="mt-0.5 pl-2 border-l-2 border-purple-500/30 max-h-20 overflow-y-auto">
{reminder.length > 200 ? reminder.substring(0, 200) + '...' : reminder}
</div>
</div>
))}
</div>
)}
{/* Result Content */}
<div className="max-h-48 overflow-y-auto">
{resultLooksLikeCode ? (
<LazyCodeBlock
language="javascript"
style={oneDark}
customStyle={{ margin: 0, padding: '8px', fontSize: '11px', background: 'transparent' }}
wrapLongLines={true}
>
{resultData.contentStr}
</LazyCodeBlock>
) : (
<pre className="text-xs text-dark-400 whitespace-pre-wrap font-mono">{resultData.contentStr}</pre>
)}
</div>
</div>
)}
</div>
)}
</div>
);
});
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 (
<div className={`ml-11 message-enter rounded-lg border overflow-hidden transition-all duration-200 inline-block max-w-[85%]
${isSuccess ? 'border-green-500/30 bg-green-500/10' : 'border-red-500/30 bg-red-500/10'}`}>
{/* Header */}
<div className="flex items-center gap-3 p-2.5">
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0
${isSuccess ? 'bg-green-500/20 border border-green-500/30' : 'bg-red-500/20 border border-red-500/30'}`}>
{isSuccess ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<AlertCircle className="w-4 h-4 text-red-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${isSuccess ? 'text-green-400' : 'text-red-400'}`}>
{isSuccess ? 'Success' : 'Error'}
</span>
<span className="text-dark-600"></span>
<span className="text-xs text-dark-500">{lines} lines, {chars} chars</span>
{systemReminders.length > 0 && (
<>
<span className="text-dark-600"></span>
<button
onClick={() => setShowSystemInfo(!showSystemInfo)}
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors"
>
<Info className="w-3 h-3" />
<span>{systemReminders.length} system hint{systemReminders.length > 1 ? 's' : ''}</span>
{showSystemInfo ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
</>
)}
</div>
</div>
</div>
{/* System Reminders - collapsible */}
{showSystemInfo && systemReminders.length > 0 && (
<div className="border-t border-purple-500/20 bg-purple-900/10 p-2.5">
{systemReminders.map((reminder, idx) => (
<div key={idx} className="text-xs text-purple-300/70 font-mono whitespace-pre-wrap mb-2 last:mb-0">
<span className="text-purple-400 font-semibold">System Hint {idx + 1}:</span>
<div className="mt-1 pl-2 border-l-2 border-purple-500/30 max-h-32 overflow-y-auto">
{reminder}
</div>
</div>
))}
</div>
)}
{/* Content - always visible with max height and scroll */}
{contentStr && (
<div className="border-t border-dark-700/50 bg-dark-900/50 max-h-48 overflow-y-auto">
{looksLikeCode ? (
<LazyCodeBlock
language="javascript"
style={oneDark}
customStyle={{ margin: 0, padding: '12px', fontSize: '11px', background: 'transparent' }}
wrapLongLines={true}
>
{contentStr}
</LazyCodeBlock>
) : (
<pre className="text-xs text-dark-400 whitespace-pre-wrap p-3 font-mono">{contentStr}</pre>
)}
</div>
)}
</div>
);
});