'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: 
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() && (
)}
{/* 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 (

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 (
)
}
function ThinkingIndicator() {
return (
)
}
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' &&
}
)
}