import { AgentMessage, AgentMessageType } from "@vertesia/common"; import { Button, cn, useToast } from "@vertesia/ui/core"; import { useUserSession } from "@vertesia/ui/session"; import { MarkdownRenderer } from "@vertesia/ui/widgets"; import dayjs from "dayjs"; import { Bot, ChevronDown, ChevronRight, CopyIcon, CheckCircle, AlertCircle, AlertTriangle } from "lucide-react"; import { useState, memo, useEffect, useRef } from "react"; import { useUITranslation } from '../../../../i18n/index.js'; import { PulsatingCircle } from "../AnimatedThinkingDots"; import { useImageLightbox } from "../ImageLightbox"; import { useArtifactUrlCache, getArtifactCacheKey } from "../useArtifactUrlCache.js"; import { ToolExecutionStatus } from "./utils"; /** Keys that are internal metadata and not interesting to display */ const META_KEYS = new Set([ 'tool', 'tool_run_id', 'activity_group_id', 'event_class', 'tool_iteration', 'tool_status', 'tools', 'streamed', 'files', 'outputFiles', 'display_role', 'observation', ]); /** Filter out internal metadata keys, return user-facing detail entries */ function extractInterestingDetails( details: Record | undefined ): Array<[string, unknown]> { if (!details) return []; return Object.entries(details).filter( ([key, value]) => !META_KEYS.has(key) && value !== undefined && value !== null && value !== '' ); } /** Convert snake_case or camelCase key to a readable label */ function formatDetailKey(key: string): string { return key .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/^./, c => c.toUpperCase()); } /** Badge color per status */ function statusBadgeClass(status?: ToolExecutionStatus): string { switch (status) { case 'completed': return 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'; case 'running': return 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'; case 'error': return 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800'; case 'warning': return 'bg-yellow-50 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800'; default: return ''; } } export interface ToolCallGroupProps { messages: AgentMessage[]; showPulsatingCircle?: boolean; toolRunId?: string; toolStatus?: ToolExecutionStatus; /** LLM reasoning text from the preceding message, shown as the group header */ preambleText?: string; /** The preceding AgentMessage that was consumed as preamble (for key/timestamp) */ preambleMessage?: AgentMessage; /** Additional className for the root container */ rootClassName?: string; /** Additional className for the header row */ headerClassName?: string; /** Additional className for the sender label */ senderClassName?: string; /** Additional className for the tool summary text */ toolSummaryClassName?: string; /** Additional className for the tool name badge */ toolBadgeClassName?: string; /** Additional className for individual item wrappers */ itemClassName?: string; /** Additional className for item header rows */ itemHeaderClassName?: string; /** Additional className for expanded item content */ itemContentClassName?: string; } /** className overrides for ToolCallGroup — subset of ToolCallGroupProps containing only className props. */ export type ToolCallGroupClassNames = Partial>; /** * Merge messages within a tool group by tool_run_id. * Running + completed messages for the same tool_run_id are collapsed into * a single visual item: the running message provides display text (message_to_human), * while the completed message provides status, files, and observation. */ function mergeByToolRunId(messages: AgentMessage[]): AgentMessage[] { const byRunId = new Map(); const result: AgentMessage[] = []; for (const msg of messages) { const runId = msg.details?.tool_run_id as string | undefined; if (runId) { if (!byRunId.has(runId)) { byRunId.set(runId, []); } byRunId.get(runId)?.push(msg); } else { result.push(msg); } } // For each tool_run_id group, merge into one message for (const [_runId, msgs] of byRunId) { if (msgs.length <= 1) { result.push(...msgs); continue; } // Sort by timestamp: earliest first msgs.sort((a, b) => { const ta = typeof a.timestamp === 'number' ? a.timestamp : new Date(a.timestamp).getTime(); const tb = typeof b.timestamp === 'number' ? b.timestamp : new Date(b.timestamp).getTime(); return ta - tb; }); // Take the last message as the base (has final status), but prefer // message text from the running message (message_to_human) if the // completed message has no text or empty text. const base = msgs[msgs.length - 1]; const runningMsg = msgs.find(m => (m.details as any)?.tool_status === 'running'); if (runningMsg && (!base.message || base.message.trim() === '') && runningMsg.message) { // Merge: use running message text with completed message details const merged: AgentMessage = { ...base, message: runningMsg.message, }; result.push(merged); } else { result.push(base); } } // Re-sort by timestamp to maintain chronological order result.sort((a, b) => { const ta = typeof a.timestamp === 'number' ? a.timestamp : new Date(a.timestamp).getTime(); const tb = typeof b.timestamp === 'number' ? b.timestamp : new Date(b.timestamp).getTime(); return ta - tb; }); return result; } interface ToolCallItemClassNames { toolBadgeClassName?: string; itemClassName?: string; itemHeaderClassName?: string; itemContentClassName?: string; } interface ToolCallItemProps { message: AgentMessage; isExpanded: boolean; onToggle: () => void; artifactRunId?: string; classNames?: ToolCallItemClassNames; } // Helper to check if URL is an image const isImageUrl = (url: string) => /\.(png|jpg|jpeg|gif|webp|svg)(\?|$)/i.test(url); // Component to render files (images inline, others as links) function FileDisplay({ files, className: fileClassName }: { files: string[]; className?: string }) { const { t } = useUITranslation(); const { openImage } = useImageLightbox(); if (!files || files.length === 0) return null; return (
{files.map((file, idx) => { const fileName = file.split('/').pop()?.split('?')[0] || 'file'; if (isImageUrl(file)) { return (
openImage(file, fileName)} title={t('agent.clickToEnlarge')} > {fileName}
); } return ( 📎 {fileName} ); })}
); } // Helper to get files from tool details (checks both files and outputFiles) const getFilesFromDetails = (details: { files?: string[]; outputFiles?: string[]; [key: string]: unknown } | undefined): string[] | undefined => { if (!details) return undefined; const files = details.files ?? details.outputFiles; return Array.isArray(files) ? files : undefined; }; const TOOL_BADGE_CLASS = "text-[10px] px-1.5 py-0.5 rounded-md bg-purple-50 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800 font-medium"; const ASSISTANT_BADGE_CLASS = "text-[10px] px-1.5 py-0.5 rounded-md bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 font-medium"; const isToolPreambleMessage = (message: AgentMessage): boolean => { const details = message.details as { display_role?: string } | undefined; return message.type === AgentMessageType.THOUGHT && details?.display_role === "tool_preamble"; }; const getMessageActivityLabel = (message: AgentMessage): string => { const details = message.details as { tool?: string } | undefined; if (isToolPreambleMessage(message)) return "assistant"; if (details?.tool) return details.tool; switch (message.type) { case AgentMessageType.UPDATE: return "update"; case AgentMessageType.WARNING: return "warning"; case AgentMessageType.ERROR: return "error"; case AgentMessageType.SYSTEM: return "system"; default: return "activity"; } }; const getMessageBadgeClass = (message: AgentMessage): string => { return isToolPreambleMessage(message) ? ASSISTANT_BADGE_CLASS : TOOL_BADGE_CLASS; }; function ToolCallItem({ message, isExpanded, onToggle, artifactRunId, classNames = {} }: ToolCallItemProps) { const { t } = useUITranslation(); const [resolvedFiles, setResolvedFiles] = useState([]); const toast = useToast(); const { client } = useUserSession(); const urlCache = useArtifactUrlCache(); // PERFORMANCE: Use refs to avoid triggering effect re-runs when these stable values change identity const clientRef = useRef(client); clientRef.current = client; const urlCacheRef = useRef(urlCache); urlCacheRef.current = urlCache; const details = message.details as { tool?: string; files?: string[]; outputFiles?: string[]; [key: string]: unknown } | undefined; const toolName = getMessageActivityLabel(message); const badgeClass = getMessageBadgeClass(message); const files = getFilesFromDetails(details); const messageContent = typeof message.message === "string" ? message.message : ""; // Resolve artifact paths to signed URLs useEffect(() => { if (!files || files.length === 0 || !artifactRunId) { setResolvedFiles([]); return; } let cancelled = false; const resolveFiles = async () => { const currentClient = clientRef.current; const currentUrlCache = urlCacheRef.current; const resolved = await Promise.all( files.map(async (file) => { if (!file || typeof file !== "string") return null; // If it's already a full URL, return as-is if (file.startsWith("http://") || file.startsWith("https://")) { return file; } // Strip artifact: protocol prefix to get the artifact-relative path const artifactPath = file.startsWith("artifact:") ? file.slice(9) : file; const ext = artifactPath.split(".").pop()?.toLowerCase() || ""; const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]); const isImage = imageExtensions.has(ext); const disposition = isImage ? "inline" : "attachment"; try { const cacheKey = getArtifactCacheKey(artifactRunId, artifactPath, disposition); if (currentUrlCache) { return await currentUrlCache.getOrFetch(cacheKey, async () => { const result = await currentClient.files.getArtifactDownloadUrl(artifactRunId, artifactPath, disposition); return result.url; }); } else { const result = await currentClient.files.getArtifactDownloadUrl(artifactRunId, artifactPath, disposition); return result.url; } } catch (err) { console.error(`Failed to resolve artifact URL for ${artifactPath}`, err); return null; } }) ); if (!cancelled) { setResolvedFiles(resolved.filter((f): f is string => !!f)); } }; resolveFiles(); return () => { cancelled = true; }; }, [files, artifactRunId]); // Separate image files from other files for inline preview const imageFiles = resolvedFiles.filter(f => isImageUrl(f)); const nonImageFiles = resolvedFiles.filter(f => !isImageUrl(f)); const copyToClipboard = () => { const textToCopy = [ messageContent, details ? "\n\nDetails:\n" + JSON.stringify(details, null, 2) : "" ].join("").trim(); navigator.clipboard.writeText(textToCopy).then(() => { toast({ status: "success", title: t('agent.copiedToClipboard'), duration: 2000, }); }); }; return (
{/* Collapsed header - always visible */}
{isExpanded ? ( ) : ( )}
{/* Primary: Message text */} {messageContent ? ( {messageContent} ) : ( Activity: {toolName} )}
{/* Tool name badge on the right */} {!isExpanded && ( {toolName} )}
{/* Always show images inline, regardless of expanded state */} {imageFiles.length > 0 && (
)} {/* Expanded content — shows tool metadata, key params, then raw details */} {isExpanded && (() => { const toolStatusValue = (details as Record | undefined)?.tool_status as ToolExecutionStatus | undefined; const interestingDetails = extractInterestingDetails(details as Record | undefined); return (
{/* Badges row: tool name + status + timestamp */}
{toolName} {toolStatusValue && ( {toolStatusValue} )} {dayjs(message.timestamp).format("HH:mm:ss")}
{/* Key parameters */} {interestingDetails.length > 0 && (
{interestingDetails.map(([key, value]) => (
{formatDetailKey(key)}: {typeof value === 'object' ? JSON.stringify(value) : String(value)}
))}
)} {/* Show observation from details if available and different from header text */} {(() => { const observation = (details as any)?.observation as string | undefined; if (observation && observation !== messageContent) { return (
{observation}
); } return null; })()} {/* Non-image files display */} {nonImageFiles.length > 0 && }
); })()}
); } // Component for resolving and displaying non-image files from a collapsed tool message // Note: Images are shown at the group level by GroupImageDisplay, so this only shows other files function CollapsedItemFiles({ files, artifactRunId }: { files: string[] | undefined; artifactRunId?: string }) { const [resolvedFiles, setResolvedFiles] = useState([]); const { client } = useUserSession(); const urlCache = useArtifactUrlCache(); const clientRef = useRef(client); clientRef.current = client; const urlCacheRef = useRef(urlCache); urlCacheRef.current = urlCache; useEffect(() => { if (!files || files.length === 0 || !artifactRunId) { setResolvedFiles([]); return; } let cancelled = false; const resolveFiles = async () => { const currentClient = clientRef.current; const currentUrlCache = urlCacheRef.current; const resolved = await Promise.all( files.map(async (file) => { if (!file || typeof file !== "string") return null; // Strip artifact: protocol prefix to get the artifact-relative path const artifactPath = file.startsWith("artifact:") ? file.slice(9) : file; // Skip image files - they're shown at the group level const ext = artifactPath.split(".").pop()?.toLowerCase() || ""; const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]); if (imageExtensions.has(ext)) return null; if (artifactPath.startsWith("http://") || artifactPath.startsWith("https://")) { return artifactPath; } try { const cacheKey = getArtifactCacheKey(artifactRunId, artifactPath, "attachment"); if (currentUrlCache) { return await currentUrlCache.getOrFetch(cacheKey, async () => { const result = await currentClient.files.getArtifactDownloadUrl(artifactRunId, artifactPath, "attachment"); return result.url; }); } else { const result = await currentClient.files.getArtifactDownloadUrl(artifactRunId, artifactPath, "attachment"); return result.url; } } catch (err) { console.error(`Failed to resolve artifact URL for ${artifactPath}`, err); return null; } }) ); if (!cancelled) { setResolvedFiles(resolved.filter((f): f is string => !!f)); } }; resolveFiles(); return () => { cancelled = true; }; }, [files, artifactRunId]); // Only show non-image files (images are shown at group level) if (resolvedFiles.length === 0) return null; return (
); } // Component to show images from the most recent tool call prominently at the top function GroupImageDisplay({ messages, artifactRunId }: { messages: AgentMessage[]; artifactRunId?: string }) { const [resolvedImages, setResolvedImages] = useState([]); const { client } = useUserSession(); const urlCache = useArtifactUrlCache(); const clientRef = useRef(client); clientRef.current = client; const urlCacheRef = useRef(urlCache); urlCacheRef.current = urlCache; // Only show files from the last (most recent) message in the group useEffect(() => { if (!artifactRunId || messages.length === 0) { setResolvedImages([]); return; } // Get files from only the last message (most recent tool output) const lastMessage = messages[messages.length - 1]; const details = lastMessage.details as { files?: string[]; outputFiles?: string[] } | undefined; const files = getFilesFromDetails(details); if (!files || files.length === 0) { setResolvedImages([]); return; } let cancelled = false; const resolveFiles = async () => { const currentClient = clientRef.current; const currentUrlCache = urlCacheRef.current; const resolved = await Promise.all( files.map(async (file) => { if (!file || typeof file !== "string") return null; // Strip artifact: protocol prefix to get the artifact-relative path const artifactPath = file.startsWith("artifact:") ? file.slice(9) : file; // Check if it's an image file const ext = artifactPath.split(".").pop()?.toLowerCase() || ""; const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]); if (!imageExtensions.has(ext)) return null; // If it's already a full URL, return as-is if (artifactPath.startsWith("http://") || artifactPath.startsWith("https://")) { return artifactPath; } try { const cacheKey = getArtifactCacheKey(artifactRunId, artifactPath, "inline"); if (currentUrlCache) { return await currentUrlCache.getOrFetch(cacheKey, async () => { const result = await currentClient.files.getArtifactDownloadUrl(artifactRunId, artifactPath, "inline"); return result.url; }); } else { const result = await currentClient.files.getArtifactDownloadUrl(artifactRunId, artifactPath, "inline"); return result.url; } } catch (err) { console.error(`Failed to resolve artifact URL for ${artifactPath}`, err); return null; } }) ); if (!cancelled) { setResolvedImages(resolved.filter((f): f is string => !!f)); } }; resolveFiles(); return () => { cancelled = true; }; }, [messages, artifactRunId]); if (resolvedImages.length === 0) return null; return (
); } function ToolCallGroupComponent({ messages: rawMessages, showPulsatingCircle = false, toolRunId: _toolRunId, toolStatus, preambleText, preambleMessage: _preambleMessage, rootClassName, headerClassName, senderClassName, toolSummaryClassName, toolBadgeClassName, itemClassName, itemHeaderClassName, itemContentClassName, }: ToolCallGroupProps) { // Merge messages sharing the same tool_run_id into single visual items const messages = mergeByToolRunId(rawMessages); const [isCollapsed, setIsCollapsed] = useState(true); const [expandedItems, setExpandedItems] = useState>(new Set()); const [animatingIndices, setAnimatingIndices] = useState>(new Set()); const prevCountRef = useRef(messages.length); const { t } = useUITranslation(); const toast = useToast(); // Extract workflow_run_id from messages (any message in the group should have it) const artifactRunId = messages.find(m => (m as any).workflow_run_id)?.workflow_run_id as string | undefined ?? (messages[0] as any)?.workflow_run_id; // Render status indicator based on tool execution status const renderStatusIndicator = () => { // Only pulse for the currently active/latest group if (showPulsatingCircle) { return ; } if (toolStatus === "running") { return ; } if (toolStatus === "completed") { return ; } if (toolStatus === "error") { return ; } if (toolStatus === "warning") { return ; } return ; }; // Get border color based on status const getBorderColor = () => { if (toolStatus === "completed") return "border-l-success"; if (toolStatus === "error") return "border-l-destructive"; if (toolStatus === "warning") return "border-l-attention"; return "border-l-purple-500"; }; // Animate new messages when they're added useEffect(() => { const prevCount = prevCountRef.current; const currentCount = messages.length; if (currentCount > prevCount) { // New messages added - animate them const newIndices = new Set(); for (let i = prevCount; i < currentCount; i++) { newIndices.add(i); } setAnimatingIndices(newIndices); // Clear animation after it completes const timer = setTimeout(() => { setAnimatingIndices(new Set()); }, 500); prevCountRef.current = currentCount; return () => clearTimeout(timer); } prevCountRef.current = currentCount; }, [messages.length]); const firstMessage = messages[0]; const lastMessage = messages[messages.length - 1]; const firstTimestamp = firstMessage.timestamp; const lastTimestamp = lastMessage.timestamp; // Get tool names for summary const toolNames = messages.map((m) => getMessageActivityLabel(m)); const uniqueToolCount = new Set(toolNames).size; const toolSummary = uniqueToolCount === 1 ? `${messages.length}× ${toolNames[0]}` : `${messages.length} activity updates`; const toggleItem = (index: number) => { setExpandedItems(prev => { const next = new Set(prev); if (next.has(index)) { next.delete(index); } else { next.add(index); } return next; }); }; const copyAllToClipboard = () => { const allContent = messages.map(m => { const details = m.details as { tool?: string; [key: string]: unknown } | undefined; return `[${getMessageActivityLabel(m)}] ${m.message || ""}\n${details ? JSON.stringify(details, null, 2) : ""}`; }).join("\n\n---\n\n"); navigator.clipboard.writeText(allContent).then(() => { toast({ status: "success", title: t('agent.copiedAllToolCalls'), duration: 2000, }); }); }; return (
{/* Compact header */}
setIsCollapsed(!isCollapsed)} >
{renderStatusIndicator()} {preambleText ? ( {preambleText} ) : ( <> Agent {toolSummary} )} {isCollapsed ? ( ) : ( )}
{preambleText && ( {toolSummary} )} {dayjs(firstTimestamp).format("HH:mm:ss")} {messages.length > 1 && ` - ${dayjs(lastTimestamp).format("HH:mm:ss")}`}
{/* Show all images from the group prominently at the top */} {/* Collapsed summary - show tool calls as single-line rows with expand option */} {isCollapsed && (
{messages.map((m, idx) => { const details = m.details as { tool?: string; files?: string[]; outputFiles?: string[] } | undefined; const toolName = getMessageActivityLabel(m); const badgeClass = getMessageBadgeClass(m); const fullMessage = typeof m.message === "string" ? m.message : ""; const isAnimating = animatingIndices.has(idx); const isItemExpanded = expandedItems.has(idx); const files = getFilesFromDetails(details); return (
{/* Row header - clickable to expand */}
toggleItem(idx)} title={fullMessage} >
{isItemExpanded ? ( ) : ( )}
{/* Primary: Message text */} {fullMessage ? ( {fullMessage} ) : ( Activity: {toolName} )}
{/* Tool name badge on the right */} {!isItemExpanded && ( {toolName} )}
{/* Always show images inline with resolved URLs */} {/* Expanded content — tool metadata + key params + raw details */} {isItemExpanded && (() => { const toolStatusValue = (details as Record | undefined)?.tool_status as ToolExecutionStatus | undefined; const interestingDetails = extractInterestingDetails(details as Record | undefined); return (
{/* Badges row: tool name + status + timestamp */}
{toolName} {toolStatusValue && ( {toolStatusValue} )} {dayjs(m.timestamp).format("HH:mm:ss")}
{/* Key parameters */} {interestingDetails.length > 0 && (
{interestingDetails.map(([key, value]) => (
{formatDetailKey(key)}: {typeof value === 'object' ? JSON.stringify(value) : String(value)}
))}
)} {/* Show observation from details if different from header text */} {(() => { const observation = (details as any)?.observation as string | undefined; if (observation && observation !== fullMessage) { return (
{observation}
); } return null; })()}
); })()}
); })}
)} {/* Keyframes for slide-in animation */} {/* Expanded view - individual tool calls */} {!isCollapsed && (
{messages.map((message, index) => ( toggleItem(index)} artifactRunId={artifactRunId} classNames={{ toolBadgeClassName, itemClassName, itemHeaderClassName, itemContentClassName }} /> ))}
)}
); } // Memoize to prevent unnecessary re-renders const ToolCallGroup = memo(ToolCallGroupComponent, (prevProps, nextProps) => { if (prevProps.messages.length !== nextProps.messages.length) return false; if (prevProps.showPulsatingCircle !== nextProps.showPulsatingCircle) return false; if (prevProps.toolRunId !== nextProps.toolRunId) return false; if (prevProps.toolStatus !== nextProps.toolStatus) return false; if (prevProps.preambleText !== nextProps.preambleText) return false; if (prevProps.rootClassName !== nextProps.rootClassName) return false; if (prevProps.headerClassName !== nextProps.headerClassName) return false; if (prevProps.senderClassName !== nextProps.senderClassName) return false; if (prevProps.toolSummaryClassName !== nextProps.toolSummaryClassName) return false; if (prevProps.toolBadgeClassName !== nextProps.toolBadgeClassName) return false; if (prevProps.itemClassName !== nextProps.itemClassName) return false; if (prevProps.itemHeaderClassName !== nextProps.itemHeaderClassName) return false; if (prevProps.itemContentClassName !== nextProps.itemContentClassName) return false; // Compare first and last timestamps as a proxy for content changes return ( prevProps.messages[0]?.timestamp === nextProps.messages[0]?.timestamp && prevProps.messages[prevProps.messages.length - 1]?.timestamp === nextProps.messages[nextProps.messages.length - 1]?.timestamp ); }); export default ToolCallGroup;