import React, { useRef, useState, useCallback } from 'react'; import { ArrowDown, CircleDashed } 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 { FileCache } from '@/hooks/use-cached-file'; 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'; // 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' ]); // Helper function to render attachments export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string) => void, sandboxId?: string, project?: Project) { if (!attachments || attachments.length === 0) return null; // Preload attachments into cache if we have a sandboxId if (sandboxId) { // Check if we can access localStorage and if there's a valid auth session before trying to preload let hasValidSession = false; let token = null; try { const sessionData = localStorage.getItem('auth'); if (sessionData) { const session = JSON.parse(sessionData); token = session?.access_token; hasValidSession = !!token; } } catch (err) { // Silent catch - localStorage might be unavailable in some contexts } // Only attempt to preload if we have a valid session if (hasValidSession && token) { // Use setTimeout to do this asynchronously without blocking rendering setTimeout(() => { FileCache.preload(sandboxId, attachments, token); }, 0); } } 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) => void, sandboxId?: string, project?: Project, debugMode?: boolean ) { // If in debug mode, just display raw content in a pre tag if (debugMode) { return (
                {content}
            
); } 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) => 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 } export const ThreadContent: React.FC = ({ messages, streamingTextContent = "", streamingToolCall, agentStatus, handleToolClick, handleOpenFileViewer, readOnly = false, visibleMessages, streamingText = "", isStreamingText = false, currentToolCall, streamHookStatus = "idle", sandboxId, project, debugMode = false }) => { 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(); // 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); } } }); // Only attempt to preload if we have attachments AND a valid token if (allAttachments.length > 0 && session?.access_token) { // Preload files in background with authentication token FileCache.preload(sandboxId, allAttachments, session.access_token); } }, [displayMessages, sandboxId, session?.access_token]); return ( <>
{displayMessages.length === 0 && !streamingTextContent && !streamingToolCall && !streamingText && !currentToolCall && agentStatus === 'idle' ? (
{readOnly ? "No messages to display." : "Send a message to start."}
) : (
{(() => { type MessageGroup = { type: 'user' | 'assistant_group'; messages: UnifiedMessage[]; key: string; }; const groupedMessages: MessageGroup[] = []; let currentGroup: MessageGroup | null = null; displayMessages.forEach((message, index) => { const messageType = message.type; const key = message.message_id || `msg-${index}`; if (messageType === 'user') { if (currentGroup) { groupedMessages.push(currentGroup); } groupedMessages.push({ type: 'user', messages: [message], key }); currentGroup = null; } else if (messageType === 'assistant' || messageType === 'tool' || messageType === 'browser_state') { if (currentGroup && currentGroup.type === 'assistant_group') { currentGroup.messages.push(message); } else { if (currentGroup) { groupedMessages.push(currentGroup); } currentGroup = { type: 'assistant_group', messages: [message], key }; } } else if (messageType !== 'status') { if (currentGroup) { groupedMessages.push(currentGroup); currentGroup = null; } } }); if (currentGroup) { groupedMessages.push(currentGroup); } if(streamingTextContent) { const lastMessages = groupedMessages.at(-1) if(lastMessages.type === 'user'){ groupedMessages.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: 'streamingTextContent'}); } else { lastMessages.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 groupedMessages.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 (
{(() => { // 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[] = []; 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-2" : ""}>
{renderedContent}
); } }); return elements; })()} {groupIndex === groupedMessages.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) { 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 = getToolIcon(detectedTag); return ( <> {textBeforeTag && ( {textBeforeTag} )} {showCursor && ( )} {detectedTag && (
)} {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 === groupedMessages.length - 1 && isStreamingText && (
{(() => { let detectedTag: string | null = null; let tagStartIndex = -1; if (streamingText) { 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')) && (
)} {/* For playback mode - Show tool call animation if active */} {readOnly && currentToolCall && (
{currentToolCall.name || 'Using Tool'}
)} {/* For playback mode - Show streaming indicator if no messages yet */} {readOnly && visibleMessages && visibleMessages.length === 0 && isStreamingText && (
)}
)}
{/* Scroll to bottom button */} {showScrollButton && ( )} ); }; export default ThreadContent;