import { useState, useRef, useEffect } from 'react' import type { KeyboardEvent } from 'react' import ReactMarkdown from 'react-markdown' import { IconSend } from '@tabler/icons-react' import { ToolCallCard } from './ToolCallCard' import { MCPAppRenderer } from './MCPAppRenderer' import { A2UIRenderer, parseA2UIBlocks } from './A2UIRenderer' import type { ChatMessage } from '../hooks/useChat' interface ChatPanelProps { messages: ChatMessage[] isStreaming: boolean onSend: (content: string) => void sessionId: string | null } // ─── Visual Block Types ──────────────────────────────────────────────────────── interface VisualBlockPayload { title?: string code: string data?: Record height?: number } function parseVisualBlocks(content: string): VisualBlockPayload[] { const blocks: VisualBlockPayload[] = [] const regex = /```visual\s*\n([\s\S]*?)```/g let match: RegExpExecArray | null while ((match = regex.exec(content)) !== null) { try { const parsed = JSON.parse(match[1]) as VisualBlockPayload if (parsed && typeof parsed.code === 'string') { blocks.push(parsed) } } catch { // Silently skip invalid JSON } } return blocks } // ─── VisualBlock Component ───────────────────────────────────────────────────── const controlBtnStyle: React.CSSProperties = { background: 'none', border: '1px solid #3a3a5a', color: '#8888aa', borderRadius: '4px', padding: '0.15rem 0.4rem', fontSize: '0.75rem', cursor: 'pointer', } function VisualBlock({ payload, onSend, }: { payload: VisualBlockPayload onSend: (msg: string) => void }) { const [showSource, setShowSource] = useState(false) function handlePopOut() { const w = window.open( '', '_blank', `width=900,height=${(payload.height ?? 300) + 120}` ) if (w) { fetch('/api/apps/runtime') .then(r => r.text()) .then(html => { w.document.open() w.document.write(html) w.document.close() }) .catch(() => { w.close() }) } } return (
{/* Header bar */}
{payload.title ?? 'Visual'}
{/* Source viewer */} {showSource && (
          {payload.code}
        
)} {/* Runtime iframe via MCPAppRenderer */}
) } // ─── ChatPanel ───────────────────────────────────────────────────────────────── export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) { const [input, setInput] = useState('') const messagesEndRef = useRef(null) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) function handleSend() { if (input.trim() && !isStreaming) { onSend(input.trim()) setInput('') } } function handleKeyDown(e: KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } if (sessionId === null) { return (
Select or create a session to start researching
) } return (
{messages.map(message => (
{message.role === 'assistant' ? (
{message.content} {/* A2UI interactive blocks */} {parseA2UIBlocks(message.content).map((block, i) => ( onSend(`[${action}] ${JSON.stringify(data)}`) } /> ))} {/* Visual artifact blocks */} {parseVisualBlocks(message.content).map((payload, i) => ( ))} {/* Tool call cards / MCP app renderers */} {message.toolCalls?.map(tc => tc._meta?.ui?.resourceUri ? ( onSend(content)} /> ) : ( ) )}
) : (
{message.content}
)}
))} {isStreaming && messages.length > 0 &&
Researching...
}
setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={isStreaming} autoFocus />
) }