'use client' import { useEffect, useRef, useMemo, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import { useThread, parseAttachments } from '@standardagents/react' import type { AttachmentRef, GroupedThreadMessage, Message, StatusMessage, SubagentBlockMessage, SubagentGroupBlockMessage, WorkMessage, } from '@standardagents/react' import { apiUrl } from '../lib/api' import { Markdown } from './Markdown' function isWorkMessage(msg: GroupedThreadMessage): msg is WorkMessage { return 'type' in msg && msg.type === 'workblock' } function isSubagentBlock(msg: GroupedThreadMessage): msg is SubagentBlockMessage { return 'type' in msg && msg.type === 'subagent_block' } function isSubagentGroup(msg: GroupedThreadMessage): msg is SubagentGroupBlockMessage { return 'type' in msg && msg.type === 'subagent_group_block' } function isStatusMessage(msg: GroupedThreadMessage): msg is StatusMessage { return 'type' in msg && msg.type === 'status' } function isChatMessage(msg: GroupedThreadMessage): msg is Message { return !('type' in msg) } // Type for OpenAI-style multipart content interface TextContent { type: 'text' text: string } interface ImageUrlContent { type: 'image_url' image_url: { url: string detail?: 'low' | 'high' | 'auto' } } type ContentPart = TextContent | ImageUrlContent // Parse message content - handles both string and multipart array formats function parseMessageContent(content: string | null): { text: string | null; images: string[] } { if (!content) return { text: null, images: [] } // Try to parse as JSON array (multipart content - OpenAI format) try { const parsed = JSON.parse(content) if (Array.isArray(parsed)) { const texts: string[] = [] const images: string[] = [] for (const part of parsed as ContentPart[]) { if (part.type === 'text' && 'text' in part) { texts.push(part.text) } else if (part.type === 'image_url' && 'image_url' in part) { images.push(part.image_url.url) } } return { text: texts.length > 0 ? texts.join('\n') : null, images } } } catch { // Not JSON, continue with text parsing } // Check for inline base64 images or image URLs in the text const images: string[] = [] let textContent = content // Extract markdown images: ![alt](url) const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g let match while ((match = markdownImageRegex.exec(content)) !== null) { images.push(match[2]) } // Remove markdown images from text textContent = textContent.replace(markdownImageRegex, '') // Extract standalone image URLs (common image extensions) const imageUrlRegex = /(?:^|\s)(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s]*)?)/gi while ((match = imageUrlRegex.exec(content)) !== null) { if (!images.includes(match[1])) { images.push(match[1]) } } // Extract base64 data URLs const base64Regex = /(data:image\/[^;]+;base64,[^\s]+)/g while ((match = base64Regex.exec(content)) !== null) { images.push(match[1]) } // Clean up text - trim excess whitespace from removed images textContent = textContent.replace(/\n{3,}/g, '\n\n').trim() return { text: textContent || null, images } } // Icon components for workblocks function CheckIcon({ className }: { className?: string }) { return ( ) } function ErrorIcon({ className }: { className?: string }) { return ( ) } function SpinnerIcon({ className }: { className?: string }) { return ( ) } // Action icons for message actions (smooth outline style) function CopyIcon({ className }: { className?: string }) { return ( ) } function TrashIcon({ className }: { className?: string }) { return ( ) } function formatToolName(name: string | undefined): string { if (!name) return 'Running tool' // Convert snake_case or camelCase to Title Case return name .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, c => c.toUpperCase()) } // Get status indicator for a work item function WorkItemStatus({ status }: { status: 'pending' | 'success' | 'error' | null | undefined }) { if (status === 'success') { return } if (status === 'error') { return } return } // Chevron icon for collapse/expand function ChevronIcon({ className, expanded }: { className?: string; expanded: boolean }) { return ( ) } // Workblock component function Workblock({ workblock, isLast, threadLoading }: { workblock: WorkMessage; isLast?: boolean; threadLoading?: boolean }) { const [isExpanded, setIsExpanded] = useState(false) const { text } = parseMessageContent(workblock.content) // Group work items by tool_call_id to pair calls with results const toolCalls = workblock.workItems.filter(item => item.type === 'tool_call') const toolResults = workblock.workItems.filter(item => item.type === 'tool_result') // The SDK marks a workblock completed once the assistant message carrying // the tool calls is saved; a call without a result is still executing // while the run is live. const isPending = workblock.status === 'pending' || (!!threadLoading && toolCalls.some(item => item.status === 'pending')) // Count completed vs total and check for errors const completedCount = toolCalls.filter(item => item.status === 'success' || item.status === 'error').length const errorCount = toolCalls.filter(item => item.status === 'error').length const totalCount = toolCalls.length const hasErrors = errorCount > 0 // Get most recent info for the badge (last tool result or last tool call name) const lastResult = toolResults[toolResults.length - 1] const lastToolCall = toolCalls[toolCalls.length - 1] const badgeText = lastResult?.content ? (lastResult.content.length > 40 ? lastResult.content.slice(0, 40) + '...' : lastResult.content) : lastToolCall?.name ? formatToolName(lastToolCall.name) : null // Determine status label and color const statusLabel = isPending ? 'Working' : hasErrors ? 'Completed with errors' : 'Completed' const statusColor = isPending ? 'text-violet-500' : hasErrors ? 'text-amber-500' : 'text-emerald-500' // Show loader if this is the last workblock and thread is still loading const showLoader = isLast && threadLoading && !isPending return (
{/* Workblock panel */}
{/* Header - clickable to toggle */} {/* Collapsible content */} {isExpanded && ( <> {/* Text content (thinking/reasoning) inside the workblock */} {text && text.trim() && (

{text.trim()}

)} {/* Tool calls list */}
{toolCalls.map((item, index) => (
{formatToolName(item.name)}
))}
)}
) } // Image attachment component function ImageAttachment({ url, name, width, height, onLoad }: { url: string; name?: string; width?: number; height?: number; onLoad?: () => void }) { // Use aspect-ratio to reserve space and prevent layout shift const style: React.CSSProperties = {} if (width && height) { style.aspectRatio = `${width} / ${height}` // Cap the width to max-w-sm (384px) while maintaining aspect ratio style.width = Math.min(width, 384) } return (
{name window.open(url, '_blank')} onLoad={onLoad} />
) } // File attachment component (for non-image files) function FileAttachment({ name, url }: { name: string; url: string }) { return ( {name} ) } // Optimistic previews come from the SDK; committed files are served from the // thread filesystem (same-origin, so the session cookie authorizes the image). function getAttachmentUrl(threadId: string, attachment: AttachmentRef): string { if (attachment.localPreviewUrl) return attachment.localPreviewUrl const cleanPath = attachment.path.replace(/^\/+/, '') return apiUrl(`/api/threads/${threadId}/fs/${cleanPath}`) } function AttachmentList({ attachments, threadId, onImageLoad }: { attachments: AttachmentRef[]; threadId: string; onImageLoad?: () => void }) { const imageAttachments = attachments.filter(a => a.mimeType.startsWith('image/')) const otherAttachments = attachments.filter(a => !a.mimeType.startsWith('image/')) return (
{imageAttachments.length > 0 && (
{imageAttachments.map((attachment) => ( ))}
)} {otherAttachments.length > 0 && (
{otherAttachments.map((attachment) => ( ))}
)}
) } function MessageActions({ message }: { message: Message }) { const { deleteMessage } = useThread() const [copied, setCopied] = useState(false) const [showDeleteWarning, setShowDeleteWarning] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const handleCopy = useCallback(async () => { const { text } = parseMessageContent(message.content) if (text) { await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } }, [message.content]) const confirmDelete = useCallback(async () => { setShowDeleteWarning(false) setIsDeleting(true) try { // The SDK removes the message locally; the live stream confirms it. await deleteMessage(message.id) } catch (error) { console.error('Failed to delete message:', error) } finally { setIsDeleting(false) } }, [deleteMessage, message.id]) const iconClass = "w-[18px] h-[18px]" const buttonClass = "p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors disabled:opacity-50" return (
{showDeleteWarning && createPortal(

Delete Message

Are you sure you want to delete this message? This action cannot be undone.

, document.body )}
) } function MessageBubble({ message, threadId, onImageLoad }: { message: Message; threadId: string; onImageLoad?: () => void }) { const isUser = message.role === 'user' const { text, images: inlineImages } = parseMessageContent(message.content) const attachments = parseAttachments(message) const hasAttachments = attachments.length > 0 return (
{isUser && hasAttachments && (
)} {isUser ? (
{text && (
{text}
)} {inlineImages.length > 0 && (
{inlineImages.map((url, index) => ( ))}
)}
) : (
{text && ( {text} )} {inlineImages.length > 0 && (
{inlineImages.map((url, index) => ( ))}
)} {hasAttachments && (
)} {message.status !== 'pending' && }
)}
) } /** * Subagent registry status, read the way the builder UI reads it: no status * yet means the child is still working; `idle`/`terminated` mean it settled. */ function subagentStatusLabel(status: string | null | undefined): { label: string; color: string; active: boolean } { switch ((status || 'running').toLowerCase()) { case 'pending': case 'queued': return { label: 'Queued', color: 'text-[var(--text-tertiary)]', active: true } case 'awaiting_human': return { label: 'Needs input', color: 'text-amber-500', active: false } case 'failed': case 'error': return { label: 'Failed', color: 'text-red-500', active: false } case 'idle': case 'terminated': return { label: 'Done', color: 'text-emerald-500', active: false } default: return { label: 'Running', color: 'text-violet-500', active: true } } } function subagentTitle(block: SubagentBlockMessage): string { return block.subagent_thread_name || block.subagent_title || block.subagent_relationship || block.subagent_name || 'Subagent' } // A subagent's conversation, collapsed into a card inside the parent thread. function SubagentBlock({ block, threadId }: { block: SubagentBlockMessage; threadId: string }) { const [isExpanded, setIsExpanded] = useState(false) const { label, color, active } = subagentStatusLabel(block.subagent_status) const visible = block.messages.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content) const lastText = [...visible].reverse().find(m => m.role === 'assistant') const preview = lastText ? parseMessageContent(lastText.content).text : null return (
{isExpanded && (
{block.subagent_description && (

{block.subagent_description}

)} {visible.length === 0 ? (

No messages yet

) : visible.map(m => (
{m.silent ? 'Result' : m.role === 'user' ? 'Task' : 'Response'}
{parseMessageContent(m.content).text || ''} {parseAttachments(m).length > 0 && (
)}
))}
)}
) } function SubagentGroup({ group, threadId }: { group: SubagentGroupBlockMessage; threadId: string }) { const running = group.subagents.filter(b => subagentStatusLabel(b.subagent_status).active).length return (
{group.subagents.length} subagents{running > 0 ? ` · ${running} running` : ''}
{group.subagents.map(block => ( ))}
) } /** * Files produced during the run (tool results such as generate_image, and * silent runtime messages that carry an asset) belong in the conversation, * not hidden inside a collapsed workblock. Each path is shown once, at its * first appearance. */ function useSurfacedAttachments(messages: GroupedThreadMessage[]): Map { const { messages: rawMessages } = useThread() return useMemo(() => { const byToolCall = new Map() for (const m of rawMessages) { if (m.role === 'tool' && m.tool_call_id) { const refs = parseAttachments(m) if (refs.length > 0) byToolCall.set(m.tool_call_id, refs) } } const seen = new Set() const surfaced = new Map() const take = (id: string, refs: AttachmentRef[]) => { const fresh = refs.filter(ref => !seen.has(ref.path)) fresh.forEach(ref => seen.add(ref.path)) if (fresh.length > 0) surfaced.set(id, fresh) } for (const msg of messages) { if (isWorkMessage(msg)) { take(msg.id, msg.workItems.flatMap(item => item.type === 'tool_result' && item.tool_call_id ? byToolCall.get(item.tool_call_id) ?? [] : [] )) } else if (isChatMessage(msg)) { const refs = parseAttachments(msg) if (msg.silent) { take(msg.id, refs) } else { // Visible messages render their own attachments. refs.forEach(ref => seen.add(ref.path)) } } } return surfaced }, [messages, rawMessages]) } // Runtime lifecycle markers (stops, subagent starts) — informational, not chat. function StatusLine({ status }: { status: StatusMessage }) { return (
{status.content}
) } function ThinkingIndicator() { return (
Thinking...
) } function RunFailed({ reason }: { reason?: string | null }) { return (
The agent stopped with an error{reason ? ` (${reason})` : ''}. Send another message to try again.
) } export function MessageList() { const { groupedMessages: messages, loading, threadId, runStatus } = useThread() const surfacedAttachments = useSurfacedAttachments(messages) const bottomRef = useRef(null) const scrollToBottom = useCallback(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, []) useEffect(() => { scrollToBottom() }, [messages, runStatus, scrollToBottom]) // Run status is pushed by the runtime (snapshot on connect, then each change). const isRunning = runStatus?.state === 'running' const somethingStreaming = useMemo(() => messages.some(m => (isWorkMessage(m) && m.status === 'pending') || (isChatMessage(m) && m.role === 'assistant' && m.status === 'pending' && !!m.content) ), [messages]) if (messages.length === 0 && !loading && !isRunning) { return (

Send a message to start the conversation

) } return (
{messages.map((msg, index) => { const surfaced = surfacedAttachments.get(msg.id) const surfacedList = surfaced && (
) if (isWorkMessage(msg)) { return (
{surfacedList}
) } if (isSubagentBlock(msg)) { return
} if (isSubagentGroup(msg)) { return
} if (isStatusMessage(msg)) { return } // Silent messages stay hidden except for any files they carry. if (msg.silent) { return surfacedList ?
{surfacedList}
: null } // Tool messages are shown in workblocks; system messages are not chat. if (msg.role === 'system' || msg.role === 'tool') { return null } return })} {isRunning && !somethingStreaming && } {runStatus?.state === 'failed' && }
) }