import React, { useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { Message, isToolResultError, isToolResultContent, isUserMessage, isAssistantMessage, isToolMessage, ErrorMessage, ToolResult, } from './hooks/useChat'; import { isTextPart, isImagePart, isAudioPart, isFilePart, isUIResourcePart } from '../types'; import type { TextPart, AudioPart, UIResourcePart } from '../types'; import { getFileMediaKind } from '@dexto/core'; import ErrorBanner from './ErrorBanner'; import { ChevronUp, Loader2, AlertTriangle, Info, File, FileAudio, ChevronDown, Brain, X, ZoomIn, FileVideo, } from 'lucide-react'; import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; import { MarkdownText } from './ui/markdown-text'; import { CopyButton } from './ui/copy-button'; import { SpeakButton } from './ui/speak-button'; import { UIResourceRendererWrapper } from './ui/ui-resource-renderer'; import { useResourceContent, type ResourceState, type NormalizedResourceItem, } from './hooks/useResourceContent'; import { useResources } from './hooks/useResources'; import type { ResourceMetadata } from '@dexto/core'; import { parseResourceReferences, resolveResourceReferences } from '@dexto/core'; import { type ApprovalEvent } from './ToolConfirmationHandler'; import { ToolCallTimeline } from './ToolCallTimeline'; import { TodoPanel } from './TodoPanel'; interface MessageListProps { messages: Message[]; processing?: boolean; /** Name of tool currently executing (for status indicator) */ currentToolName?: string | null; activeError?: ErrorMessage | null; onDismissError?: () => void; pendingApproval?: ApprovalEvent | null; onApprovalApprove?: (formData?: Record, rememberChoice?: boolean) => void; onApprovalDeny?: () => void; /** * Optional ref to the outer content container so parents can observe size * changes (for robust autoscroll). When provided, it is attached to the * top-level wrapping div around the list content. */ outerRef?: React.Ref; /** Session ID for todo panel */ sessionId?: string | null; } // Helper to format timestamp from createdAt const formatTimestamp = (timestamp: number) => { const date = new Date(timestamp); return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; // Helper to validate data URIs to prevent XSS function isValidDataUri(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean { const typePattern = expectedType ? `${expectedType}/` : '[a-z0-9.+-]+/'; const dataUriRegex = new RegExp( `^data:${typePattern}[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}$`, 'i' ); return dataUriRegex.test(src); } function isLikelyBase64(value: string): boolean { if (!value || value.length < 16) return false; if (value.startsWith('http://') || value.startsWith('https://')) return false; if (value.startsWith('data:') || value.startsWith('@blob:')) return false; return /^[A-Za-z0-9+/=\r\n]+$/.test(value); } // Helper to validate safe HTTP/HTTPS URLs for media function isSafeHttpUrl(src: string): boolean { try { const url = new URL(src); const hostname = url.hostname.toLowerCase(); // Check protocol if (url.protocol !== 'https:' && url.protocol !== 'http:') { return false; } // Block localhost and common local names if (hostname === 'localhost' || hostname === '::1') { return false; } // Check for IPv4 addresses const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; const ipv4Match = hostname.match(ipv4Regex); if (ipv4Match) { const [, a, b, c, d] = ipv4Match.map(Number); // Validate IP range (0-255) if (a > 255 || b > 255 || c > 255 || d > 255) { return false; } // Block loopback (127.0.0.0/8) if (a === 127) { return false; } // Block private networks (RFC 1918) // 10.0.0.0/8 if (a === 10) { return false; } // 172.16.0.0/12 if (a === 172 && b >= 16 && b <= 31) { return false; } // 192.168.0.0/16 if (a === 192 && b === 168) { return false; } // Block link-local (169.254.0.0/16) if (a === 169 && b === 254) { return false; } // Block 0.0.0.0 if (a === 0 && b === 0 && c === 0 && d === 0) { return false; } } // Check for IPv6 addresses if (hostname.includes(':')) { // Block IPv6 loopback if (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1') { return false; } // Block IPv6 unique-local (fc00::/7) if (hostname.startsWith('fc') || hostname.startsWith('fd')) { return false; } // Block IPv6 link-local (fe80::/10) if ( hostname.startsWith('fe8') || hostname.startsWith('fe9') || hostname.startsWith('fea') || hostname.startsWith('feb') ) { return false; } } return true; } catch { return false; } } // Helper to check if a URL is safe for media rendering function isSafeMediaUrl(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean { if (src.startsWith('blob:') || isSafeHttpUrl(src)) return true; if (src.startsWith('data:')) { return expectedType ? isValidDataUri(src, expectedType) : isValidDataUri(src); } return false; } // Helper to check if a URL is safe for audio rendering function isSafeAudioUrl(src: string): boolean { return isSafeMediaUrl(src, 'audio'); } function resolveMediaSrc( part: any, resourceStates?: Record ): string { if (!part) return ''; const mimeType: string | undefined = part?.mimeType; const dataCandidate: unknown = typeof part === 'string' ? part : (part?.data ?? part?.image ?? part?.audio ?? part?.video ?? part?.uri ?? part?.url); if (typeof dataCandidate === 'string') { if (dataCandidate.startsWith('@blob:')) { const uri = dataCandidate.substring(1); if (resourceStates && uri) { const state = resourceStates[uri]; if (state && state.status === 'loaded' && state.data) { const preferKinds: Array = []; if (part?.type === 'image') preferKinds.push('image'); if (part?.type === 'file') { const mediaKind = getFileMediaKind(part.mimeType); if (mediaKind === 'audio') preferKinds.push('audio'); else if (mediaKind === 'video') preferKinds.push('video'); } if (part?.mimeType?.startsWith('image/')) preferKinds.push('image'); if (part?.mimeType?.startsWith('audio/')) preferKinds.push('audio'); if (part?.mimeType?.startsWith('video/')) preferKinds.push('video'); const preferredItem = state.data.items.find((item) => preferKinds.includes(item.kind as any)) ?? state.data.items.find((item) => item.kind === 'image') ?? state.data.items.find((item) => item.kind === 'video') ?? state.data.items.find((item) => item.kind === 'audio') ?? state.data.items[0]; if ( preferredItem && 'src' in preferredItem && typeof preferredItem.src === 'string' ) { return preferredItem.src; } } } return ''; } if (dataCandidate.startsWith('data:')) { return dataCandidate; } if (mimeType && isLikelyBase64(dataCandidate)) { return `data:${mimeType};base64,${dataCandidate}`; } if (isSafeMediaUrl(dataCandidate)) { return dataCandidate; } } const urlSrc = part?.url ?? part?.image ?? part?.audio ?? part?.video ?? part?.uri; return typeof urlSrc === 'string' ? urlSrc : ''; } interface VideoInfo { src: string; filename?: string; mimeType?: string; } function getVideoInfo( part: unknown, resourceStates?: Record ): VideoInfo | null { if (!part || typeof part !== 'object') return null; const anyPart = part as Record; const mimeType = anyPart.mimeType || anyPart.mediaType; const filename = anyPart.filename || anyPart.name; const mediaKind = anyPart.type === 'file' ? getFileMediaKind(anyPart.mimeType) : null; const isVideo = mimeType?.startsWith('video/') || mediaKind === 'video' || anyPart.type === 'video' || filename?.match(/\.(mp4|webm|mov|m4v|avi|mkv)$/i); if (!isVideo) return null; const src = resolveMediaSrc(anyPart, resourceStates); return src && isSafeMediaUrl(src, 'video') ? { src, filename, mimeType } : null; } function ThinkingIndicator({ toolName }: { toolName?: string | null }) { return (
{/* Animated spinner */}
{/* Label */} {toolName ? ( Running{' '} {toolName .replace(/^(internal--|custom--|mcp--[^-]+--|mcp__[^_]+__)/, '') .replace(/^(internal__|custom__)/, '')} ) : ( Thinking )}
); } export default function MessageList({ messages, processing = false, currentToolName, activeError, onDismissError, outerRef, pendingApproval: _pendingApproval, onApprovalApprove, onApprovalDeny, sessionId, }: MessageListProps) { const endRef = useRef(null); const [manuallyExpanded, setManuallyExpanded] = useState>({}); const [reasoningExpanded, setReasoningExpanded] = useState>({}); const [imageModal, setImageModal] = useState<{ isOpen: boolean; src: string; alt: string }>({ isOpen: false, src: '', alt: '', }); const { resources: availableResources } = useResources(); const resourceSet = useMemo>(() => { const map: Record = {}; for (const resource of availableResources) { map[resource.uri] = { ...resource, }; } return map; }, [availableResources]); const toolResourceUris = useMemo(() => { const uris = new Set(); const addUri = (value: unknown) => { if (typeof value !== 'string') return; const trimmed = value.startsWith('@') ? value.substring(1) : value; if (trimmed.startsWith('blob:')) { uris.add(trimmed); } }; const collectFromPart = (part: unknown) => { if (!part) return; if (typeof part === 'string') { addUri(part); return; } if (typeof part !== 'object') return; const anyPart = part as Record; if (typeof anyPart.image === 'string') { addUri(anyPart.image as string); } if (typeof anyPart.data === 'string') { addUri(anyPart.data as string); } if (typeof anyPart.url === 'string') { addUri(anyPart.url as string); } if (typeof anyPart.audio === 'string') { addUri(anyPart.audio as string); } if (typeof anyPart.video === 'string') { addUri(anyPart.video as string); } }; for (const msg of messages) { if (!isToolMessage(msg)) continue; const toolResult = msg.toolResult; if (!toolResult) continue; if (isToolResultContent(toolResult)) { toolResult.resources?.forEach((res) => { if (res?.uri?.startsWith('blob:')) { uris.add(res.uri); } }); toolResult.content?.forEach((part) => collectFromPart(part)); } else if ((toolResult as any)?.content && Array.isArray((toolResult as any).content)) { (toolResult as any).content.forEach((part: unknown) => collectFromPart(part)); } } return Array.from(uris); }, [messages]); const toolResourceStates = useResourceContent(toolResourceUris); // Add CSS for audio controls overflow handling useEffect(() => { const style = document.createElement('style'); style.textContent = ` .audio-controls-container audio { max-width: 100% !important; width: 100% !important; height: auto !important; min-height: 32px; } .audio-controls-container audio::-webkit-media-controls-panel { max-width: 100% !important; } .audio-controls-container audio::-webkit-media-controls-timeline { max-width: 100% !important; } `; document.head.appendChild(style); return () => { document.head.removeChild(style); }; }, []); const openImageModal = (src: string, alt: string) => { setImageModal({ isOpen: true, src, alt }); }; const closeImageModal = () => { setImageModal({ isOpen: false, src: '', alt: '' }); }; // NOTE: Autoscroll is now delegated to the parent (ChatApp) which // observes size changes and maintains isAtBottom state. if (!messages || messages.length === 0) { return null; } // Helper function to extract plain text from message for copy functionality const getPlainTextFromMessage = (msg: Message): string => { if (typeof msg.content === 'string') return msg.content; if (Array.isArray(msg.content)) { return msg.content .map((p) => (isTextPart(p) ? p.text : '')) .filter(Boolean) .join('\n'); } if (msg.content && typeof msg.content === 'object') return JSON.stringify(msg.content, null, 2); return ''; }; // Helper: Find the start index of the run ending at endIdx const getRunStartIdx = (endIdx: number): number => { for (let i = endIdx - 1; i >= 0; i--) { const msg = messages[i]; if (msg && isUserMessage(msg)) { return i + 1; } } return 0; }; // Helper: Get all assistant text from a run ending at idx (for copy/speak aggregation) const getRunAssistantText = (endIdx: number): string => { const texts: string[] = []; const startIdx = getRunStartIdx(endIdx); // Collect all assistant message text from startIdx to endIdx for (let i = startIdx; i <= endIdx; i++) { const msg = messages[i]; if (msg && isAssistantMessage(msg)) { const text = getPlainTextFromMessage(msg); if (text.trim()) { texts.push(text); } } } return texts.join('\n\n'); }; // Helper: Get cumulative token usage for a run ending at idx const getRunTokenUsage = ( endIdx: number ): { inputTokens: number; outputTokens: number; reasoningTokens: number; totalTokens: number; } => { const startIdx = getRunStartIdx(endIdx); let inputTokens = 0; let outputTokens = 0; let reasoningTokens = 0; let totalTokens = 0; for (let i = startIdx; i <= endIdx; i++) { const msg = messages[i]; if (msg && isAssistantMessage(msg) && msg.tokenUsage) { inputTokens += msg.tokenUsage.inputTokens ?? 0; outputTokens += msg.tokenUsage.outputTokens ?? 0; reasoningTokens += msg.tokenUsage.reasoningTokens ?? 0; totalTokens += msg.tokenUsage.totalTokens ?? 0; } } return { inputTokens, outputTokens, reasoningTokens, totalTokens }; }; // Note: getToolResultCopyText was used for old tool box rendering, now handled by ToolCallTimeline const _getToolResultCopyText = (result: ToolResult | undefined): string => { if (!result) return ''; if (isToolResultError(result)) { return typeof result.error === 'object' ? JSON.stringify(result.error, null, 2) : String(result.error); } if (isToolResultContent(result)) { return result.content .map((part) => isTextPart(part) ? part.text : typeof part === 'object' ? '' : String(part) ) .filter(Boolean) .join('\n'); } return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result); }; // Helper: Check if this assistant message is the last one before a user message (end of a "run") const isLastAssistantInRun = (idx: number): boolean => { const msg = messages[idx]; if (!msg || !isAssistantMessage(msg)) return false; // Look ahead to find the next non-tool message for (let i = idx + 1; i < messages.length; i++) { const nextMsg = messages[i]; if (!nextMsg) continue; // Skip tool messages - they're part of the same run if (isToolMessage(nextMsg)) continue; // If next non-tool message is a user message, this is the last assistant in the run if (isUserMessage(nextMsg)) return true; // If next non-tool message is another assistant message, this is not the last if (isAssistantMessage(nextMsg)) return false; } // If we reach here, no user message follows - show metadata only if not processing return !processing; }; return (
{messages.map((msg, idx) => { const msgKey = msg.id ?? `msg-${idx}`; const isUser = isUserMessage(msg); const isAi = isAssistantMessage(msg); const isTool = isToolMessage(msg); const isLastMessage = idx === messages.length - 1; const isToolCall = isTool && !!(msg.toolName && msg.toolArgs); const isToolResult = isTool && !!(msg.toolName && msg.toolResult); const isToolRelated = isToolCall || isToolResult; // Only show metadata (tokens, model) on the last assistant message of a run const showAssistantMetadata = isAi && isLastAssistantInRun(idx); // Note: isExpanded was used for old tool box rendering, now handled by ToolCallTimeline const _isExpanded = (isToolRelated && isLastMessage) || !!manuallyExpanded[msgKey]; // Extract media parts from tool results for separate rendering const toolResultImages: Array<{ src: string; alt: string; index: number }> = []; const toolResultAudios: Array<{ src: string; filename?: string; index: number }> = []; const toolResultVideos: Array<{ src: string; filename?: string; mimeType?: string; index: number; }> = []; const toolResultUIResources: Array<{ resource: UIResourcePart; index: number }> = []; if (isToolMessage(msg) && msg.toolResult && isToolResultContent(msg.toolResult)) { msg.toolResult.content.forEach((part: unknown, index: number) => { // Handle UI resource parts (MCP-UI interactive content) if (isUIResourcePart(part)) { toolResultUIResources.push({ resource: part, index, }); } else if (isImagePart(part)) { const src = resolveMediaSrc(part, toolResourceStates); if (src && isSafeMediaUrl(src, 'image')) { toolResultImages.push({ src, alt: `Tool result image ${index + 1}`, index, }); } } else if (isAudioPart(part)) { const audio = part as AudioPart; const src = resolveMediaSrc(audio, toolResourceStates); if (src && isSafeMediaUrl(src, 'audio')) { toolResultAudios.push({ src, filename: audio.filename, index, }); } } else if ( isFilePart(part) && (getFileMediaKind(part.mimeType) === 'audio' || part.mimeType?.startsWith('audio/')) ) { const src = resolveMediaSrc(part, toolResourceStates); if (src && isSafeMediaUrl(src, 'audio')) { toolResultAudios.push({ src, filename: part.filename, index, }); } } else { const videoInfo = getVideoInfo(part, toolResourceStates); if (videoInfo) { toolResultVideos.push({ ...videoInfo, index, }); } } }); } // Note: toggleManualExpansion was used for old tool box rendering, now handled by ToolCallTimeline const _toggleManualExpansion = () => { if (isToolRelated) { setManuallyExpanded((prev) => ({ ...prev, [msgKey]: !prev[msgKey], })); } }; const messageContainerClass = 'w-full' + (isTool ? ' pl-2' : ''); // Tool messages get slight indent for timeline // Bubble styling: users get subtle bubble; AI and tools blend with background const bubbleSpecificClass = cn( isTool ? 'w-full max-w-[90%]' : isUser ? 'px-4 py-3 rounded-2xl w-fit max-w-[75%] bg-primary/15 text-foreground rounded-br-sm text-base break-words overflow-wrap-anywhere overflow-hidden' : isAi ? 'px-4 py-3 w-full max-w-[min(90%,calc(100vw-6rem))] text-base break-normal hyphens-none' : '' ); const contentWrapperClass = 'flex flex-col gap-2'; const timestampStr = formatTimestamp(msg.createdAt); const errorAnchoredHere = !!(activeError && activeError.anchorMessageId === msg.id); return (
{/* Reasoning panel (assistant only) - display at top */} {isAi && typeof msg.reasoning === 'string' && msg.reasoning.trim().length > 0 && (
{(reasoningExpanded[msgKey] ?? true) && (
                                                                    {msg.reasoning}
                                                                
)}
)} {isToolMessage(msg) && msg.toolName ? ( onApprovalApprove( formData, rememberChoice ) : undefined } onReject={ msg.requireApproval && msg.approvalStatus === 'pending' && onApprovalDeny ? () => onApprovalDeny() : undefined } /> ) : ( <> {typeof msg.content === 'string' && msg.content.trim() !== '' && (
)} {msg.content && typeof msg.content === 'object' && !Array.isArray(msg.content) && (
                                                                {JSON.stringify(
                                                                    msg.content,
                                                                    null,
                                                                    2
                                                                )}
                                                            
)} {Array.isArray(msg.content) && msg.content.map((part, partIdx) => { const partKey = `${msgKey}-part-${partIdx}`; if (part.type === 'text') { return ( ); } // Handle UI resource parts (MCP-UI interactive content) if (isUIResourcePart(part)) { return (
{ console.log( 'MCP-UI Action:', action ); }} />
); } // Handle image parts if (isImagePart(part)) { const src = resolveMediaSrc(part); if ( src && isSafeMediaUrl(src, 'image') ) { return ( Message attachment openImageModal( src, 'Message attachment' ) } /> ); } return null; } const videoInfo = getVideoInfo(part); if (videoInfo) { const { src, filename, mimeType } = videoInfo; return (
Video attachment
{(filename || mimeType) && (
{filename && ( {filename} )} {mimeType && ( {mimeType} )}
)}
); } if (isFilePart(part)) { const filePart = part; if ( filePart.mimeType.startsWith( 'audio/' ) ) { const src = resolveMediaSrc(filePart); return (
); } else { // Non-audio files (PDFs, etc.) return (
{filePart.filename || `${filePart.mimeType} file`} {filePart.mimeType}
); } } return null; })} )} {/* Display imageData attachments if not already in content array */} {isUserMessage(msg) && msg.imageData && !Array.isArray(msg.content) && (() => { const src = `data:${msg.imageData.mimeType};base64,${msg.imageData.image}`; if (!isValidDataUri(src, 'image')) { return null; } return ( attachment ); })()} {/* Display fileData attachments if not already in content array */} {isUserMessage(msg) && msg.fileData && !Array.isArray(msg.content) && (
{msg.fileData.mimeType.startsWith( 'video/' ) ? (
Video attachment
{(() => { const videoSrc = `data:${msg.fileData.mimeType};base64,${msg.fileData.data}`; return isValidDataUri( videoSrc, 'video' ) ? (
) : msg.fileData.mimeType.startsWith( 'audio/' ) ? (
{(() => { const audioSrc = `data:${msg.fileData.mimeType};base64,${msg.fileData.data}`; return isValidDataUri( audioSrc, 'audio' ) ? (
) : (
{msg.fileData.filename || `${msg.fileData.mimeType} file`} {msg.fileData.mimeType}
)}
)}
{/* Metadata bar: show for user messages always, for AI only on last message of run */} {!isToolRelated && (isUser || showAssistantMetadata) && (
{timestampStr} {(() => { if (!showAssistantMetadata) return null; const runTokens = getRunTokenUsage(idx); if (runTokens.totalTokens === 0) return null; return ( {runTokens.totalTokens} tokens
{runTokens.inputTokens > 0 && (
Input:{' '} {runTokens.inputTokens}
)} {runTokens.outputTokens > 0 && (
Output:{' '} {runTokens.outputTokens}
)} {runTokens.reasoningTokens > 0 && (
Reasoning:{' '} { runTokens.reasoningTokens }
)}
Total:{' '} {runTokens.totalTokens}
); })()} {showAssistantMetadata && msg.model && ( {msg.model}
Model: {msg.model}
{msg.provider && (
Provider: {msg.provider}
)}
)}
{/* Speak + Copy controls */}
)}
{/* Render tool result images inline */} {toolResultImages.map((image, imageIndex) => (
openImageModal(image.src, image.alt)} > {image.alt}
))} {/* Render tool result videos inline */} {toolResultVideos.map((video, videoIndex) => (
))} {/* Render tool result audio inline */} {toolResultAudios.map((audio, audioIndex) => (
))} {/* Render tool result UI resources (MCP-UI interactive content) */} {toolResultUIResources.map((uiResource, uiIndex) => (
{ // Log UI actions for debugging console.log('MCP-UI Action:', action); }} />
{timestampStr}
))} {errorAnchoredHere && (
{/* indent to align under bubbles */} {})} />
)}
); })} {/* Render todo panel when there are todos */} {sessionId && } {/* Show thinking indicator while processing */} {processing && } {/* Note: Approvals are now rendered inline within tool messages via ToolCallTimeline */}
{/* Image Modal */} {imageModal.isOpen && (
{imageModal.alt} e.stopPropagation()} />
)}
); } function extractResourceData( text: string, resourceSet: Record ): { cleanedText: string; uris: string[] } { if (!text) { return { cleanedText: '', uris: [] }; } // Parse references using core function const references = parseResourceReferences(text); // Resolve references using core function const resolved = resolveResourceReferences(references, resourceSet); // Extract URIs from resolved references (filter out unresolved ones and deduplicate) const resolvedUris = Array.from( new Set(resolved.filter((ref) => ref.resourceUri).map((ref) => ref.resourceUri!)) ); // Clean the text by removing ALL resolved reference formats using originalRef // This handles @, @name, and @server:resource patterns // Only clean resolved references - leave unresolved ones visible to user let cleanedText = text; for (const ref of resolved) { if (ref.resourceUri) { // Use split/join to avoid regex escaping issues and handle all occurrences cleanedText = cleanedText.split(ref.originalRef).join(''); } } // Unescape literal \n and \t strings to actual newlines/tabs cleanedText = cleanedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); // Clean up extra whitespace and newlines cleanedText = cleanedText .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') // Only collapse spaces/tabs, not newlines .trim(); return { cleanedText, uris: resolvedUris }; } function MessageContentWithResources({ text, isUser, onOpenImage, resourceSet, }: { text: string; isUser: boolean; onOpenImage: (src: string, alt: string) => void; resourceSet: Record; }) { const { cleanedText, uris } = useMemo( () => extractResourceData(text, resourceSet), [text, resourceSet] ); const resourceStates = useResourceContent(uris); const hasText = cleanedText.length > 0; return (
{hasText && (
{isUser ? (

{cleanedText}

) : ( {cleanedText} )}
)} {uris.map((uri) => ( ))}
); } function ResourceAttachment({ uri, state, onOpenImage, }: { uri: string; state?: ResourceState; onOpenImage: (src: string, alt: string) => void; }) { if (!state || state.status === 'loading') { return (
Loading resource…
); } if (state.status === 'error') { return (

Failed to load resource

{uri}

{state.error}

); } const data = state.data; if (!data || data.items.length === 0) { return (

{data?.name || uri}

No previewable content.

); } const primaryMime = data.items.find( (item): item is NormalizedResourceItem & { mimeType: string } => 'mimeType' in item && typeof item.mimeType === 'string' )?.mimeType; return (
{data.name || uri} {primaryMime && {primaryMime}}
{data.items.map((item, index) => renderNormalizedItem({ item, index, onOpenImage, }) )}
); } function renderNormalizedItem({ item, index, onOpenImage, }: { item: NormalizedResourceItem; index: number; onOpenImage: (src: string, alt: string) => void; }) { const key = `resource-item-${index}`; switch (item.kind) { case 'text': { if (item.mimeType && item.mimeType.includes('markdown')) { return (
{item.text}
); } return (
                    {item.text}
                
); } case 'image': { if (!isSafeMediaUrl(item.src)) { return (
Unsupported image source
); } return ( {item.alt onOpenImage(item.src, item.alt || 'Resource image')} className="max-h-60 w-full cursor-zoom-in rounded-lg border border-border object-contain" /> ); } case 'audio': { if (!isSafeAudioUrl(item.src)) { return (
Unsupported audio source
); } return (
); } case 'video': { if (!isSafeMediaUrl(item.src, 'video')) { return (
Unsupported video source
); } return (
Video {item.filename && ( {item.filename} )}
); } case 'file': { return (
{item.filename || 'Resource file'} {item.mimeType && ( {item.mimeType} )}
{item.src && ( Download )}
); } default: return null; } }