mirror of https://github.com/kortix-ai/suna.git
1058 lines
66 KiB
TypeScript
1058 lines
66 KiB
TypeScript
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
import { CircleDashed, CheckCircle, AlertTriangle } from 'lucide-react';
|
|
import { UnifiedMessage, ParsedContent, ParsedMetadata } from '@/components/thread/types';
|
|
import { FileAttachmentGrid } from '@/components/thread/file-attachment';
|
|
import { useFilePreloader } 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 { AgentAvatar, AgentName } from './agent-avatar';
|
|
import { parseXmlToolCalls, isNewXmlFormat } from '@/components/thread/tool-views/xml-parser';
|
|
import { ShowToolStream } from './ShowToolStream';
|
|
import { ComposioUrlDetector } from './composio-url-detector';
|
|
import { HIDE_STREAMING_XML_TAGS } from '@/components/thread/utils';
|
|
|
|
|
|
// 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;
|
|
|
|
// Filter out empty strings and check if we have any valid attachments
|
|
const validAttachments = attachments.filter(attachment => attachment && attachment.trim() !== '');
|
|
if (validAttachments.length === 0) return null;
|
|
|
|
return <FileAttachmentGrid
|
|
attachments={validAttachments}
|
|
onFileClick={fileViewerHandler}
|
|
showPreviews={true}
|
|
sandboxId={sandboxId}
|
|
project={project}
|
|
/>;
|
|
}
|
|
|
|
// 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 (
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30 text-foreground">
|
|
{content}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
if (isNewXmlFormat(content)) {
|
|
const contentParts: React.ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
|
|
// Find all function_calls blocks
|
|
const functionCallsRegex = /<function_calls>([\s\S]*?)<\/function_calls>/gi;
|
|
let match: RegExpExecArray | null = null;
|
|
|
|
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(
|
|
<ComposioUrlDetector key={`md-${lastIndex}`} content={textBeforeBlock} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words" />
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
<div key={`ask-${match.index}-${index}`} className="space-y-3">
|
|
<ComposioUrlDetector content={askText} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3" />
|
|
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
|
|
</div>
|
|
);
|
|
} else if (toolName === 'complete') {
|
|
// Handle complete tool specially - extract text and attachments
|
|
const completeText = 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 complete tool content with attachment UI
|
|
contentParts.push(
|
|
<div key={`complete-${match.index}-${index}`} className="space-y-3">
|
|
<ComposioUrlDetector content={completeText} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3" />
|
|
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
|
|
</div>
|
|
);
|
|
} 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(
|
|
<div
|
|
key={`tool-${match.index}-${index}`}
|
|
className="my-1"
|
|
>
|
|
<button
|
|
onClick={() => handleToolClick(messageId, toolName)}
|
|
className="inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs text-muted-foreground bg-muted hover:bg-muted/80 rounded-lg transition-colors cursor-pointer border border-neutral-200 dark:border-neutral-700/50"
|
|
>
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'>
|
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<span className="font-mono text-xs text-foreground">{getUserFriendlyToolName(toolName)}</span>
|
|
{paramDisplay && <span className="ml-1 text-muted-foreground truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
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(
|
|
<ComposioUrlDetector key={`md-${lastIndex}`} content={remainingText} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words" />
|
|
);
|
|
}
|
|
}
|
|
|
|
return contentParts.length > 0 ? contentParts : <ComposioUrlDetector content={content} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words" />;
|
|
}
|
|
|
|
// 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: RegExpExecArray | null = null;
|
|
|
|
// If no XML tags found, just return the full content as markdown
|
|
if (!content.match(xmlRegex)) {
|
|
return <ComposioUrlDetector content={content} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words" />;
|
|
}
|
|
|
|
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(
|
|
<ComposioUrlDetector key={`md-${lastIndex}`} content={textBeforeTag} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none inline-block mr-1 break-words" />
|
|
);
|
|
}
|
|
|
|
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(/<ask[^>]*>([\s\S]*?)<\/ask>/i);
|
|
const askContent = contentMatch ? contentMatch[1] : '';
|
|
|
|
// Render <ask> tag content with attachment UI (using the helper)
|
|
contentParts.push(
|
|
<div key={`ask-${match.index}`} className="space-y-3">
|
|
<ComposioUrlDetector content={askContent} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3" />
|
|
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
|
|
</div>
|
|
);
|
|
} else if (toolName === 'complete') {
|
|
// 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 complete tag
|
|
const contentMatch = rawXml.match(/<complete[^>]*>([\s\S]*?)<\/complete>/i);
|
|
const completeContent = contentMatch ? contentMatch[1] : '';
|
|
|
|
// Render <complete> tag content with attachment UI (using the helper)
|
|
contentParts.push(
|
|
<div key={`complete-${match.index}`} className="space-y-3">
|
|
<ComposioUrlDetector content={completeContent} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3" />
|
|
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
|
|
</div>
|
|
);
|
|
} else {
|
|
const IconComponent = getToolIcon(toolName);
|
|
const paramDisplay = extractPrimaryParam(toolName, rawXml);
|
|
|
|
// Render tool button as a clickable element
|
|
contentParts.push(
|
|
<div
|
|
key={toolCallKey}
|
|
className="my-1"
|
|
>
|
|
<button
|
|
onClick={() => handleToolClick(messageId, toolName)}
|
|
className="inline-flex items-center gap-1.5 py-1 px-1 pr-1.5 text-xs text-muted-foreground bg-muted hover:bg-muted/80 rounded-lg transition-colors cursor-pointer border border-neutral-200 dark:border-neutral-700/50"
|
|
>
|
|
<div className='border-2 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800 flex items-center justify-center p-0.5 rounded-sm border-neutral-400/20 dark:border-neutral-600'>
|
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
|
</div>
|
|
<span className="font-mono text-xs text-foreground">{getUserFriendlyToolName(toolName)}</span>
|
|
{paramDisplay && <span className="ml-1 text-muted-foreground truncate max-w-[200px]" title={paramDisplay}>{paramDisplay}</span>}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
lastIndex = xmlRegex.lastIndex;
|
|
}
|
|
|
|
// Add text after the last tag
|
|
if (lastIndex < content.length) {
|
|
contentParts.push(
|
|
<ComposioUrlDetector key={`md-${lastIndex}`} content={content.substring(lastIndex)} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words" />
|
|
);
|
|
}
|
|
|
|
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
|
|
threadMetadata?: any; // Add thread metadata prop
|
|
scrollContainerRef?: React.RefObject<HTMLDivElement>; // Add scroll container ref prop
|
|
agentMetadata?: any; // Add agent metadata prop
|
|
agentData?: any; // Add full agent data prop
|
|
}
|
|
|
|
export const ThreadContent: React.FC<ThreadContentProps> = ({
|
|
messages,
|
|
streamingTextContent = "",
|
|
streamingToolCall,
|
|
agentStatus,
|
|
handleToolClick,
|
|
handleOpenFileViewer,
|
|
readOnly = false,
|
|
visibleMessages,
|
|
streamingText = "",
|
|
isStreamingText = false,
|
|
currentToolCall,
|
|
streamHookStatus = "idle",
|
|
sandboxId,
|
|
project,
|
|
debugMode = false,
|
|
isPreviewMode = false,
|
|
agentName = 'Suna',
|
|
agentAvatar = <KortixLogo size={16} />,
|
|
emptyStateComponent,
|
|
threadMetadata,
|
|
scrollContainerRef,
|
|
agentMetadata,
|
|
agentData,
|
|
}) => {
|
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
const latestMessageRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [shouldJustifyToTop, setShouldJustifyToTop] = 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-0"
|
|
: "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-0 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;
|
|
|
|
// Helper function to get agent info robustly
|
|
const getAgentInfo = useCallback(() => {
|
|
// First check thread metadata for is_agent_builder flag
|
|
if (threadMetadata?.is_agent_builder) {
|
|
return {
|
|
name: 'Agent Builder',
|
|
avatar: (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<span className="text-lg">🤖</span>
|
|
</div>
|
|
)
|
|
};
|
|
}
|
|
|
|
// Check if this is a Suna default agent from metadata
|
|
const isSunaDefaultAgent = agentMetadata?.is_suna_default || false;
|
|
|
|
// Then check recent messages for agent info
|
|
const recentAssistantWithAgent = [...displayMessages].reverse().find(msg =>
|
|
msg.type === 'assistant' && (msg.agents?.avatar || msg.agents?.avatar_color || msg.agents?.name)
|
|
);
|
|
|
|
if (recentAssistantWithAgent?.agents?.name === 'Agent Builder') {
|
|
return {
|
|
name: 'Agent Builder',
|
|
avatar: (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<span className="text-lg">🤖</span>
|
|
</div>
|
|
)
|
|
};
|
|
}
|
|
|
|
if (agentData && !isSunaDefaultAgent) {
|
|
const profileUrl = agentData.profile_image_url;
|
|
const avatar = profileUrl ? (
|
|
<img src={profileUrl} alt={agentData.name || agentName} className="h-5 w-5 rounded object-cover" />
|
|
) : agentData.avatar ? (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<span className="text-lg">{agentData.avatar}</span>
|
|
</div>
|
|
) : (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<KortixLogo size={16} />
|
|
</div>
|
|
);
|
|
return {
|
|
name: agentData.name || agentName,
|
|
avatar
|
|
};
|
|
}
|
|
|
|
if (recentAssistantWithAgent?.agents?.name) {
|
|
const isSunaAgent = recentAssistantWithAgent.agents.name === 'Suna' || isSunaDefaultAgent;
|
|
// Prefer profile image if available on the agent payload
|
|
const profileUrl = (recentAssistantWithAgent as any)?.agents?.profile_image_url;
|
|
const avatar = profileUrl && !isSunaDefaultAgent ? (
|
|
<img src={profileUrl} alt={recentAssistantWithAgent.agents.name} className="h-5 w-5 rounded object-cover" />
|
|
) : recentAssistantWithAgent.agents.avatar && !isSunaDefaultAgent ? (
|
|
<>
|
|
{isSunaAgent ? (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<KortixLogo size={16} />
|
|
</div>
|
|
) : (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<span className="text-lg">{recentAssistantWithAgent.agents.avatar}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<KortixLogo size={16} />
|
|
</div>
|
|
);
|
|
return {
|
|
name: recentAssistantWithAgent.agents.name,
|
|
avatar
|
|
};
|
|
}
|
|
|
|
// Fallback: if this is a Suna default agent, always show KortixLogo
|
|
if (isSunaDefaultAgent) {
|
|
return {
|
|
name: agentName || 'Suna',
|
|
avatar: (
|
|
<div className="h-5 w-5 flex items-center justify-center rounded text-xs">
|
|
<KortixLogo size={16} />
|
|
</div>
|
|
)
|
|
};
|
|
}
|
|
|
|
return {
|
|
name: agentName || 'Suna',
|
|
avatar: agentAvatar
|
|
};
|
|
}, [threadMetadata, displayMessages, agentName, agentAvatar, agentMetadata, agentData]);
|
|
|
|
// Simplified scroll handler - flex-column-reverse handles positioning
|
|
const handleScroll = useCallback(() => {
|
|
// No scroll logic needed with flex-column-reverse
|
|
}, []);
|
|
|
|
// No scroll-to-bottom needed with flex-column-reverse
|
|
|
|
// No auto-scroll needed with flex-column-reverse - CSS handles it
|
|
|
|
// Smart justify-content based on content height
|
|
useEffect(() => {
|
|
const checkContentHeight = () => {
|
|
const container = (scrollContainerRef || messagesContainerRef).current;
|
|
const content = contentRef.current;
|
|
if (!container || !content) return;
|
|
|
|
const containerHeight = container.clientHeight;
|
|
const contentHeight = content.scrollHeight;
|
|
setShouldJustifyToTop(contentHeight <= containerHeight);
|
|
};
|
|
|
|
checkContentHeight();
|
|
const resizeObserver = new ResizeObserver(checkContentHeight);
|
|
if (contentRef.current) resizeObserver.observe(contentRef.current);
|
|
const containerRef = (scrollContainerRef || messagesContainerRef).current;
|
|
if (containerRef) resizeObserver.observe(containerRef);
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, [displayMessages, streamingTextContent, agentStatus, scrollContainerRef]);
|
|
|
|
// 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
|
|
<div className="flex-1 min-h-[60vh] flex items-center justify-center">
|
|
{emptyStateComponent || (
|
|
<div className="text-center text-muted-foreground">
|
|
{readOnly ? "No messages to display." : "Send a message to start."}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
// Render scrollable content container with column-reverse
|
|
<div
|
|
ref={scrollContainerRef || messagesContainerRef}
|
|
className={`${containerClassName} flex flex-col-reverse ${shouldJustifyToTop ? 'justify-end min-h-full' : ''}`}
|
|
onScroll={handleScroll}
|
|
>
|
|
<div ref={contentRef} className="mx-auto max-w-3xl md:px-8 min-w-0 w-full">
|
|
<div className="space-y-8 min-w-0">
|
|
{(() => {
|
|
|
|
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') {
|
|
// Check if we can add to existing assistant group (same agent)
|
|
const canAddToExistingGroup = currentGroup &&
|
|
currentGroup.type === 'assistant_group' &&
|
|
(() => {
|
|
// For assistant messages, check if agent matches
|
|
if (messageType === 'assistant') {
|
|
const lastAssistantMsg = currentGroup.messages.findLast(m => m.type === 'assistant');
|
|
if (!lastAssistantMsg) return true; // No assistant message yet, can add
|
|
|
|
// Compare agent info - both null/undefined should be treated as same (default agent)
|
|
const currentAgentId = message.agent_id;
|
|
const lastAgentId = lastAssistantMsg.agent_id;
|
|
return currentAgentId === lastAgentId;
|
|
}
|
|
// For tool/browser_state messages, always add to current group
|
|
return true;
|
|
})();
|
|
|
|
if (canAddToExistingGroup) {
|
|
// 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) => {
|
|
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;
|
|
|
|
|
|
// Helper function to add streaming content to groups
|
|
const appendStreamingContent = (content: string, isPlayback: boolean = false) => {
|
|
const messageId = isPlayback ? 'playbackStreamingText' : 'streamingTextContent';
|
|
const metadata = isPlayback ? 'playbackStreamingText' : 'streamingTextContent';
|
|
const keySuffix = isPlayback ? 'playback-streaming' : 'streaming';
|
|
|
|
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,
|
|
type: 'assistant',
|
|
message_id: messageId,
|
|
metadata,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
is_llm_message: true,
|
|
thread_id: messageId,
|
|
sequence: Infinity,
|
|
}],
|
|
key: `assistant-group-${assistantGroupCounter}-${keySuffix}`
|
|
});
|
|
} 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 !== messageId) {
|
|
lastGroup.messages.push({
|
|
content,
|
|
type: 'assistant',
|
|
message_id: messageId,
|
|
metadata,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
is_llm_message: true,
|
|
thread_id: messageId,
|
|
sequence: Infinity,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle streaming content - only add to existing group or create new one if needed
|
|
if (streamingTextContent) {
|
|
appendStreamingContent(streamingTextContent, false);
|
|
}
|
|
|
|
// Handle playback mode streaming text
|
|
if (readOnly && streamingText && isStreamingText) {
|
|
appendStreamingContent(streamingText, true);
|
|
}
|
|
|
|
return finalGroupedMessages.map((group, groupIndex) => {
|
|
if (group.type === 'user') {
|
|
const message = group.messages[0];
|
|
const messageContent = (() => {
|
|
try {
|
|
const parsed = safeJsonParse<ParsedContent>(message.content, { content: message.content });
|
|
return parsed.content || message.content;
|
|
} catch {
|
|
return message.content;
|
|
}
|
|
})();
|
|
|
|
// In debug mode, display raw message content
|
|
if (debugMode) {
|
|
return (
|
|
<div key={group.key} className="flex justify-end">
|
|
<div className="flex max-w-[85%] rounded-2xl bg-card px-4 py-3 break-words overflow-hidden">
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto min-w-0 flex-1">
|
|
{message.content}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Extract attachments from the message content
|
|
const attachmentsMatch = messageContent.match(/\[Uploaded File: (.*?)\]/g);
|
|
const attachments = attachmentsMatch
|
|
? attachmentsMatch.map((match: string) => {
|
|
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 (
|
|
<div key={group.key} className="flex justify-end">
|
|
<div className="flex max-w-[85%] rounded-3xl rounded-br-lg bg-card border px-4 py-3 break-words overflow-hidden">
|
|
<div className="space-y-3 min-w-0 flex-1">
|
|
{cleanContent && (
|
|
<ComposioUrlDetector content={cleanContent} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere" />
|
|
)}
|
|
|
|
{/* Use the helper function to render user attachments */}
|
|
{renderAttachments(attachments as string[], handleOpenFileViewer, sandboxId, project)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else if (group.type === 'assistant_group') {
|
|
// Get agent_id from the first assistant message in this group
|
|
const firstAssistantMsg = group.messages.find(m => m.type === 'assistant');
|
|
const groupAgentId = firstAssistantMsg?.agent_id;
|
|
|
|
return (
|
|
<div key={group.key} ref={groupIndex === groupedMessages.length - 1 ? latestMessageRef : null}>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center">
|
|
<div className="rounded-md flex items-center justify-center relative">
|
|
{groupAgentId ? (
|
|
<AgentAvatar agentId={groupAgentId} size={20} className="h-5 w-5" />
|
|
) : (
|
|
getAgentInfo().avatar
|
|
)}
|
|
</div>
|
|
<p className='ml-2 text-sm text-muted-foreground'>
|
|
{groupAgentId ? (
|
|
<AgentName agentId={groupAgentId} fallback={getAgentInfo().name} />
|
|
) : (
|
|
getAgentInfo().name
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Message content - ALL messages in the group */}
|
|
<div className="flex max-w-[90%] text-sm break-words overflow-hidden">
|
|
<div className="space-y-2 min-w-0 flex-1">
|
|
{(() => {
|
|
// 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 (
|
|
<div key={msgKey} className="mb-4">
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
|
Type: {message.type} | ID: {message.message_id || 'no-id'}
|
|
</div>
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30">
|
|
{JSON.stringify(message.content, null, 2)}
|
|
</pre>
|
|
{message.metadata && message.metadata !== '{}' && (
|
|
<div className="mt-2">
|
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
|
Metadata:
|
|
</div>
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30">
|
|
{JSON.stringify(message.metadata, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
const toolResultsMap = new Map<string | null, UnifiedMessage[]>();
|
|
group.messages.forEach(msg => {
|
|
if (msg.type === 'tool') {
|
|
const meta = safeJsonParse<ParsedMetadata>(msg.metadata, {});
|
|
const assistantId = meta.assistant_message_id || null;
|
|
if (!toolResultsMap.has(assistantId)) {
|
|
toolResultsMap.set(assistantId, []);
|
|
}
|
|
toolResultsMap.get(assistantId)?.push(msg);
|
|
}
|
|
});
|
|
|
|
const elements: React.ReactNode[] = [];
|
|
let assistantMessageCount = 0; // Move this outside the loop
|
|
|
|
group.messages.forEach((message, msgIndex) => {
|
|
if (message.type === 'assistant') {
|
|
const parsedContent = safeJsonParse<ParsedContent>(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(
|
|
<div key={msgKey} className={assistantMessageCount > 0 ? "mt-4" : ""}>
|
|
<div className="prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-hidden">
|
|
{renderedContent}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
assistantMessageCount++; // Increment after adding the element
|
|
}
|
|
});
|
|
|
|
return elements;
|
|
})()}
|
|
|
|
{groupIndex === finalGroupedMessages.length - 1 && !readOnly && (streamHookStatus === 'streaming' || streamHookStatus === 'connecting') && (
|
|
<div className="mt-2">
|
|
{(() => {
|
|
// In debug mode, show raw streaming content
|
|
if (debugMode && streamingTextContent) {
|
|
return (
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30">
|
|
{streamingTextContent}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
let detectedTag: string | null = null;
|
|
let tagStartIndex = -1;
|
|
if (streamingTextContent) {
|
|
// First check for new format
|
|
const functionCallsIndex = streamingTextContent.indexOf('<function_calls>');
|
|
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;
|
|
|
|
return (
|
|
<>
|
|
{textBeforeTag && (
|
|
<ComposioUrlDetector content={textBeforeTag} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere" />
|
|
)}
|
|
{showCursor && (
|
|
<span className="inline-block h-4 w-0.5 bg-primary ml-0.5 -mb-1 animate-pulse" />
|
|
)}
|
|
|
|
{detectedTag && (
|
|
<ShowToolStream
|
|
content={textToRender.substring(tagStartIndex)}
|
|
messageId={visibleMessages && visibleMessages.length > 0 ? visibleMessages[visibleMessages.length - 1].message_id : "playback-streaming"}
|
|
onToolClick={handleToolClick}
|
|
showExpanded={true}
|
|
startTime={Date.now()}
|
|
/>
|
|
)}
|
|
|
|
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* For playback mode, show streaming text and tool calls */}
|
|
{readOnly && groupIndex === finalGroupedMessages.length - 1 && isStreamingText && (
|
|
<div className="mt-2">
|
|
{(() => {
|
|
let detectedTag: string | null = null;
|
|
let tagStartIndex = -1;
|
|
if (streamingText) {
|
|
// First check for new format
|
|
const functionCallsIndex = streamingText.indexOf('<function_calls>');
|
|
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 ? (
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto p-2 border border-border rounded-md bg-muted/30">
|
|
{streamingText}
|
|
</pre>
|
|
) : (
|
|
<>
|
|
{textBeforeTag && (
|
|
<ComposioUrlDetector content={textBeforeTag} className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3 break-words overflow-wrap-anywhere" />
|
|
)}
|
|
{showCursor && (
|
|
<span className="inline-block h-4 w-0.5 bg-primary ml-0.5 -mb-1 animate-pulse" />
|
|
)}
|
|
|
|
{detectedTag && (
|
|
<ShowToolStream
|
|
content={textToRender.substring(tagStartIndex)}
|
|
messageId="streamingTextContent"
|
|
onToolClick={handleToolClick}
|
|
showExpanded={true}
|
|
startTime={Date.now()} // Tool just started now
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
})()}
|
|
{((agentStatus === 'running' || agentStatus === 'connecting') && !streamingTextContent &&
|
|
!readOnly &&
|
|
(messages.length === 0 || messages[messages.length - 1].type === 'user')) && (
|
|
<div ref={latestMessageRef} className='w-full h-22 rounded'>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Logo positioned above the loader */}
|
|
<div className="flex items-center">
|
|
<div className="rounded-md flex items-center justify-center">
|
|
{getAgentInfo().avatar}
|
|
</div>
|
|
<p className='ml-2 text-sm text-muted-foreground'>
|
|
{getAgentInfo().name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Loader content */}
|
|
<div className="space-y-2 w-full h-12">
|
|
<AgentLoader />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{readOnly && currentToolCall && (
|
|
<div ref={latestMessageRef}>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Logo positioned above the tool call */}
|
|
<div className="flex justify-start">
|
|
<div className="rounded-md flex items-center justify-center">
|
|
{getAgentInfo().avatar}
|
|
</div>
|
|
<p className='ml-2 text-sm text-muted-foreground'>
|
|
{getAgentInfo().name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tool call content */}
|
|
<div className="space-y-2">
|
|
<div className="animate-shimmer inline-flex items-center gap-1.5 py-1.5 px-3 text-xs font-medium text-primary bg-primary/10 rounded-md border border-primary/20">
|
|
<CircleDashed className="h-3.5 w-3.5 text-primary flex-shrink-0 animate-spin animation-duration-2000" />
|
|
<span className="font-mono text-xs text-primary">
|
|
{currentToolCall.name || 'Using Tool'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* For playback mode - Show streaming indicator if no messages yet */}
|
|
{readOnly && visibleMessages && visibleMessages.length === 0 && isStreamingText && (
|
|
<div ref={latestMessageRef}>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Logo positioned above the streaming indicator */}
|
|
<div className="flex justify-start">
|
|
<div className="rounded-md flex items-center justify-center">
|
|
{getAgentInfo().avatar}
|
|
</div>
|
|
<p className='ml-2 text-sm text-muted-foreground'>
|
|
{getAgentInfo().name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Streaming indicator content */}
|
|
<div className="max-w-[90%] px-4 py-3 text-sm">
|
|
<div className="flex items-center gap-1.5 py-1">
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse" />
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse delay-150" />
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/50 animate-pulse delay-300" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="!h-48" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* No scroll button needed with flex-column-reverse */}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ThreadContent;
|