import React, { useRef, useState, useCallback } from 'react'; import { ArrowDown, CircleDashed, CheckCircle, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; import { UnifiedMessage, ParsedContent, ParsedMetadata } from '@/components/thread/types'; import { FileAttachmentGrid } from '@/components/thread/file-attachment'; import { useFilePreloader, FileCache } from '@/hooks/react-query/files'; import { useAuth } from '@/components/AuthProvider'; import { Project } from '@/lib/api'; import { extractPrimaryParam, getToolIcon, getUserFriendlyToolName, safeJsonParse, } from '@/components/thread/utils'; import { KortixLogo } from '@/components/sidebar/kortix-logo'; import { AgentLoader } from './loader'; import { parseXmlToolCalls, isNewXmlFormat, extractToolNameFromStream } from '@/components/thread/tool-views/xml-parser'; import { parseToolResult } from '@/components/thread/tool-views/tool-result-parser'; // Define the set of tags whose raw XML should be hidden during streaming const HIDE_STREAMING_XML_TAGS = new Set([ 'execute-command', 'create-file', 'delete-file', 'full-file-rewrite', 'str-replace', 'browser-click-element', 'browser-close-tab', 'browser-drag-drop', 'browser-get-dropdown-options', 'browser-go-back', 'browser-input-text', 'browser-navigate-to', 'browser-scroll-down', 'browser-scroll-to-text', 'browser-scroll-up', 'browser-select-dropdown-option', 'browser-send-keys', 'browser-switch-tab', 'browser-wait', 'deploy', 'ask', 'complete', 'crawl-webpage', 'web-search', 'see-image', 'call-mcp-tool', 'execute_data_provider_call', 'execute_data_provider_endpoint', 'execute-data-provider-call', 'execute-data-provider-endpoint', ]); // Helper function to render attachments (keeping original implementation for now) export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void, sandboxId?: string, project?: Project) { if (!attachments || attachments.length === 0) return null; // Note: Preloading is now handled by React Query in the main ThreadContent component // to avoid duplicate requests with different content types return ; } // Render Markdown content while preserving XML tags that should be displayed as tool calls export function renderMarkdownContent( content: string, handleToolClick: (assistantMessageId: string | null, toolName: string) => void, messageId: string | null, fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void, sandboxId?: string, project?: Project, debugMode?: boolean ) { // If in debug mode, just display raw content in a pre tag if (debugMode) { return (
                {content}
            
); } // Check if content contains the new Cursor-style format if (isNewXmlFormat(content)) { const contentParts: React.ReactNode[] = []; let lastIndex = 0; // Find all function_calls blocks const functionCallsRegex = /([\s\S]*?)<\/function_calls>/gi; let match; while ((match = functionCallsRegex.exec(content)) !== null) { // Add text before the function_calls block if (match.index > lastIndex) { const textBeforeBlock = content.substring(lastIndex, match.index); if (textBeforeBlock.trim()) { contentParts.push( {textBeforeBlock} ); } } // Parse the tool calls in this block const toolCalls = parseXmlToolCalls(match[0]); toolCalls.forEach((toolCall, index) => { const toolName = toolCall.functionName.replace(/_/g, '-'); if (toolName === 'ask') { // Handle ask tool specially - extract text and attachments const askText = toolCall.parameters.text || ''; const attachments = toolCall.parameters.attachments || []; // Convert single attachment to array for consistent handling const attachmentArray = Array.isArray(attachments) ? attachments : (typeof attachments === 'string' ? attachments.split(',').map(a => a.trim()) : []); // Render ask tool content with attachment UI contentParts.push(
{askText} {renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
); } else { const IconComponent = getToolIcon(toolName); // Extract primary parameter for display let paramDisplay = ''; if (toolCall.parameters.file_path) { paramDisplay = toolCall.parameters.file_path; } else if (toolCall.parameters.command) { paramDisplay = toolCall.parameters.command; } else if (toolCall.parameters.query) { paramDisplay = toolCall.parameters.query; } else if (toolCall.parameters.url) { paramDisplay = toolCall.parameters.url; } contentParts.push(
); } }); lastIndex = match.index + match[0].length; } // Add any remaining text after the last function_calls block if (lastIndex < content.length) { const remainingText = content.substring(lastIndex); if (remainingText.trim()) { contentParts.push( {remainingText} ); } } return contentParts.length > 0 ? contentParts : {content}; } // Fall back to old XML format handling const xmlRegex = /<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; let lastIndex = 0; const contentParts: React.ReactNode[] = []; let match; // If no XML tags found, just return the full content as markdown if (!content.match(xmlRegex)) { return {content}; } while ((match = xmlRegex.exec(content)) !== null) { // Add text before the tag as markdown if (match.index > lastIndex) { const textBeforeTag = content.substring(lastIndex, match.index); contentParts.push( {textBeforeTag} ); } const rawXml = match[0]; const toolName = match[1] || match[2]; const toolCallKey = `tool-${match.index}`; if (toolName === 'ask') { // Extract attachments from the XML attributes const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i); const attachments = attachmentsMatch ? attachmentsMatch[1].split(',').map(a => a.trim()) : []; // Extract content from the ask tag const contentMatch = rawXml.match(/]*>([\s\S]*?)<\/ask>/i); const askContent = contentMatch ? contentMatch[1] : ''; // Render tag content with attachment UI (using the helper) contentParts.push(
{askContent} {renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
); } else { const IconComponent = getToolIcon(toolName); const paramDisplay = extractPrimaryParam(toolName, rawXml); // Render tool button as a clickable element contentParts.push(
); } lastIndex = xmlRegex.lastIndex; } // Add text after the last tag if (lastIndex < content.length) { contentParts.push( {content.substring(lastIndex)} ); } return contentParts; } export interface ThreadContentProps { messages: UnifiedMessage[]; streamingTextContent?: string; streamingToolCall?: any; agentStatus: 'idle' | 'running' | 'connecting' | 'error'; handleToolClick: (assistantMessageId: string | null, toolName: string) => void; handleOpenFileViewer: (filePath?: string, filePathList?: string[]) => void; readOnly?: boolean; visibleMessages?: UnifiedMessage[]; // For playback mode streamingText?: string; // For playback mode isStreamingText?: boolean; // For playback mode currentToolCall?: any; // For playback mode streamHookStatus?: string; // Add this prop sandboxId?: string; // Add sandboxId prop project?: Project; // Add project prop debugMode?: boolean; // Add debug mode parameter isPreviewMode?: boolean; agentName?: string; agentAvatar?: React.ReactNode; emptyStateComponent?: React.ReactNode; // Add custom empty state component prop } export const ThreadContent: React.FC = ({ messages, streamingTextContent = "", streamingToolCall, agentStatus, handleToolClick, handleOpenFileViewer, readOnly = false, visibleMessages, streamingText = "", isStreamingText = false, currentToolCall, streamHookStatus = "idle", sandboxId, project, debugMode = false, isPreviewMode = false, agentName = 'Suna', agentAvatar = , emptyStateComponent, }) => { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const latestMessageRef = useRef(null); const [showScrollButton, setShowScrollButton] = useState(false); const [userHasScrolled, setUserHasScrolled] = useState(false); const { session } = useAuth(); // React Query file preloader const { preloadFiles } = useFilePreloader(); const containerClassName = isPreviewMode ? "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-secondary/0 scrollbar-thumb-primary/10 scrollbar-thumb-rounded-full hover:scrollbar-thumb-primary/10 px-6 py-4 pb-72" : "flex-1 overflow-y-auto scrollbar-thin scrollbar-track-secondary/0 scrollbar-thumb-primary/10 scrollbar-thumb-rounded-full hover:scrollbar-thumb-primary/10 px-6 py-4 pb-72 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"; // In playback mode, we use visibleMessages instead of messages const displayMessages = readOnly && visibleMessages ? visibleMessages : messages; const handleScroll = () => { if (!messagesContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; const isScrolledUp = scrollHeight - scrollTop - clientHeight > 100; setShowScrollButton(isScrolledUp); setUserHasScrolled(isScrolledUp); }; const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { messagesEndRef.current?.scrollIntoView({ behavior }); }, []); // Preload all message attachments when messages change or sandboxId is provided React.useEffect(() => { if (!sandboxId) return; // Extract all file attachments from messages const allAttachments: string[] = []; displayMessages.forEach(message => { if (message.type === 'user') { try { const content = typeof message.content === 'string' ? message.content : ''; const attachmentsMatch = content.match(/\[Uploaded File: (.*?)\]/g); if (attachmentsMatch) { attachmentsMatch.forEach(match => { const pathMatch = match.match(/\[Uploaded File: (.*?)\]/); if (pathMatch && pathMatch[1]) { allAttachments.push(pathMatch[1]); } }); } } catch (e) { console.error('Error parsing message attachments:', e); } } }); // Use React Query preloading if we have attachments AND a valid token if (allAttachments.length > 0 && session?.access_token) { // Preload files with React Query in background preloadFiles(sandboxId, allAttachments).catch(err => { console.error('React Query preload failed:', err); }); } }, [displayMessages, sandboxId, session?.access_token, preloadFiles]); return ( <> {displayMessages.length === 0 && !streamingTextContent && !streamingToolCall && !streamingText && !currentToolCall && agentStatus === 'idle' ? ( // Render empty state outside scrollable container
{emptyStateComponent || (
{readOnly ? "No messages to display." : "Send a message to start."}
)}
) : ( // Render scrollable content container
{(() => { type MessageGroup = { type: 'user' | 'assistant_group'; messages: UnifiedMessage[]; key: string; }; const groupedMessages: MessageGroup[] = []; let currentGroup: MessageGroup | null = null; let assistantGroupCounter = 0; // Counter for assistant groups displayMessages.forEach((message, index) => { const messageType = message.type; const key = message.message_id || `msg-${index}`; if (messageType === 'user') { // Finalize any existing assistant group if (currentGroup) { groupedMessages.push(currentGroup); currentGroup = null; } // Create a new user message group groupedMessages.push({ type: 'user', messages: [message], key }); } else if (messageType === 'assistant' || messageType === 'tool' || messageType === 'browser_state') { if (currentGroup && currentGroup.type === 'assistant_group') { // Add to existing assistant group currentGroup.messages.push(message); } else { // Finalize any existing group if (currentGroup) { groupedMessages.push(currentGroup); } // Create a new assistant group with a group-level key assistantGroupCounter++; currentGroup = { type: 'assistant_group', messages: [message], key: `assistant-group-${assistantGroupCounter}` }; } } else if (messageType !== 'status') { // For any other message types, finalize current group if (currentGroup) { groupedMessages.push(currentGroup); currentGroup = null; } } }); // Finalize any remaining group if (currentGroup) { groupedMessages.push(currentGroup); } // Merge consecutive assistant groups const mergedGroups: MessageGroup[] = []; let currentMergedGroup: MessageGroup | null = null; groupedMessages.forEach((group, index) => { if (group.type === 'assistant_group') { if (currentMergedGroup && currentMergedGroup.type === 'assistant_group') { // Merge with the current group currentMergedGroup.messages.push(...group.messages); } else { // Finalize previous group if it exists if (currentMergedGroup) { mergedGroups.push(currentMergedGroup); } // Start new merged group currentMergedGroup = { ...group }; } } else { // Finalize current merged group if it exists if (currentMergedGroup) { mergedGroups.push(currentMergedGroup); currentMergedGroup = null; } // Add non-assistant group as-is mergedGroups.push(group); } }); // Finalize any remaining merged group if (currentMergedGroup) { mergedGroups.push(currentMergedGroup); } // Use merged groups instead of original grouped messages const finalGroupedMessages = mergedGroups; // Handle streaming content - only add to existing group or create new one if needed if (streamingTextContent) { const lastGroup = finalGroupedMessages.at(-1); if (!lastGroup || lastGroup.type === 'user') { // Create new assistant group for streaming content assistantGroupCounter++; finalGroupedMessages.push({ type: 'assistant_group', messages: [{ content: streamingTextContent, type: 'assistant', message_id: 'streamingTextContent', metadata: 'streamingTextContent', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), is_llm_message: true, thread_id: 'streamingTextContent', sequence: Infinity, }], key: `assistant-group-${assistantGroupCounter}-streaming` }); } else if (lastGroup.type === 'assistant_group') { // Only add streaming content if it's not already represented in the last message const lastMessage = lastGroup.messages[lastGroup.messages.length - 1]; if (lastMessage.message_id !== 'streamingTextContent') { lastGroup.messages.push({ content: streamingTextContent, type: 'assistant', message_id: 'streamingTextContent', metadata: 'streamingTextContent', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), is_llm_message: true, thread_id: 'streamingTextContent', sequence: Infinity, }); } } } return finalGroupedMessages.map((group, groupIndex) => { if (group.type === 'user') { const message = group.messages[0]; const messageContent = (() => { try { const parsed = safeJsonParse(message.content, { content: message.content }); return parsed.content || message.content; } catch { return message.content; } })(); // In debug mode, display raw message content if (debugMode) { return (
                                                            {message.content}
                                                        
); } // Extract attachments from the message content const attachmentsMatch = messageContent.match(/\[Uploaded File: (.*?)\]/g); const attachments = attachmentsMatch ? attachmentsMatch.map(match => { const pathMatch = match.match(/\[Uploaded File: (.*?)\]/); return pathMatch ? pathMatch[1] : null; }).filter(Boolean) : []; // Remove attachment info from the message content const cleanContent = messageContent.replace(/\[Uploaded File: .*?\]/g, '').trim(); return (
{cleanContent && ( {cleanContent} )} {/* Use the helper function to render user attachments */} {renderAttachments(attachments as string[], handleOpenFileViewer, sandboxId, project)}
); } else if (group.type === 'assistant_group') { return (
{/* Logo positioned above the message content - ONLY ONCE PER GROUP */}
{agentAvatar}

{agentName ? agentName : 'Suna'}

{/* Message content - ALL messages in the group */}
{(() => { // In debug mode, just show raw messages content if (debugMode) { return group.messages.map((message, msgIndex) => { const msgKey = message.message_id || `raw-msg-${msgIndex}`; return (
Type: {message.type} | ID: {message.message_id || 'no-id'}
                                                                                    {message.content}
                                                                                
{message.metadata && message.metadata !== '{}' && (
Metadata:
                                                                                            {message.metadata}
                                                                                        
)}
); }); } const toolResultsMap = new Map(); group.messages.forEach(msg => { if (msg.type === 'tool') { const meta = safeJsonParse(msg.metadata, {}); const assistantId = meta.assistant_message_id || null; if (!toolResultsMap.has(assistantId)) { toolResultsMap.set(assistantId, []); } toolResultsMap.get(assistantId)?.push(msg); } }); const renderedToolResultIds = new Set(); const elements: React.ReactNode[] = []; let assistantMessageCount = 0; // Move this outside the loop group.messages.forEach((message, msgIndex) => { if (message.type === 'assistant') { const parsedContent = safeJsonParse(message.content, {}); const msgKey = message.message_id || `submsg-assistant-${msgIndex}`; if (!parsedContent.content) return; const renderedContent = renderMarkdownContent( parsedContent.content, handleToolClick, message.message_id, handleOpenFileViewer, sandboxId, project, debugMode ); elements.push(
0 ? "mt-4" : ""}>
{renderedContent}
); assistantMessageCount++; // Increment after adding the element } }); return elements; })()} {groupIndex === finalGroupedMessages.length - 1 && !readOnly && (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && (
{(() => { // In debug mode, show raw streaming content if (debugMode && streamingTextContent) { return (
                                                                                    {streamingTextContent}
                                                                                
); } let detectedTag: string | null = null; let tagStartIndex = -1; if (streamingTextContent) { // First check for new format const functionCallsIndex = streamingTextContent.indexOf(''); if (functionCallsIndex !== -1) { detectedTag = 'function_calls'; tagStartIndex = functionCallsIndex; } else { // Fall back to old format detection for (const tag of HIDE_STREAMING_XML_TAGS) { const openingTagPattern = `<${tag}`; const index = streamingTextContent.indexOf(openingTagPattern); if (index !== -1) { detectedTag = tag; tagStartIndex = index; break; } } } } const textToRender = streamingTextContent || ''; const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; const showCursor = (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && !detectedTag; const IconComponent = detectedTag && detectedTag !== 'function_calls' ? getToolIcon(detectedTag) : null; return ( <> {textBeforeTag && ( {textBeforeTag} )} {showCursor && ( )} {detectedTag && detectedTag !== 'function_calls' && (
)} {detectedTag === 'function_calls' && (
)} {streamingToolCall && !detectedTag && (
{(() => { const toolName = streamingToolCall.name || streamingToolCall.xml_tag_name || 'Tool'; const IconComponent = getToolIcon(toolName); const paramDisplay = extractPrimaryParam(toolName, streamingToolCall.arguments || ''); return ( ); })()}
)} ); })()}
)} {/* For playback mode, show streaming text and tool calls */} {readOnly && groupIndex === finalGroupedMessages.length - 1 && isStreamingText && (
{(() => { let detectedTag: string | null = null; let tagStartIndex = -1; if (streamingText) { // First check for new format const functionCallsIndex = streamingText.indexOf(''); if (functionCallsIndex !== -1) { detectedTag = 'function_calls'; tagStartIndex = functionCallsIndex; } else { // Fall back to old format detection for (const tag of HIDE_STREAMING_XML_TAGS) { const openingTagPattern = `<${tag}`; const index = streamingText.indexOf(openingTagPattern); if (index !== -1) { detectedTag = tag; tagStartIndex = index; break; } } } } const textToRender = streamingText || ''; const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; const showCursor = isStreamingText && !detectedTag; return ( <> {/* In debug mode, show raw streaming content */} {debugMode && streamingText ? (
                                                                                        {streamingText}
                                                                                    
) : ( <> {textBeforeTag && ( {textBeforeTag} )} {showCursor && ( )} {detectedTag && (
)} )} ); })()}
)}
); } return null; }); })()} {((agentStatus === 'running' || agentStatus === 'connecting') && !streamingTextContent && !readOnly && (messages.length === 0 || messages[messages.length - 1].type === 'user')) && (
{/* Logo positioned above the loader */}
{agentAvatar}

{agentName}

{/* Loader content */}
)} {/* For playback mode - Show tool call animation if active */} {readOnly && currentToolCall && (
{/* Logo positioned above the tool call */}
{agentAvatar}

{agentName}

{/* Tool call content */}
{currentToolCall.name || 'Using Tool'}
)} {/* For playback mode - Show streaming indicator if no messages yet */} {readOnly && visibleMessages && visibleMessages.length === 0 && isStreamingText && (
{/* Logo positioned above the streaming indicator */}
{agentAvatar}

{agentName}

{/* Streaming indicator content */}
)}
)} {/* Scroll to bottom button */} {showScrollButton && ( )} ); }; export default ThreadContent;