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>
1601 lines
58 KiB
JavaScript
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>
|
|
);
|
|
});
|