Files
research-workbench/frontend/src/components/ChatPanel.tsx
T
Ken 2c3cfdbedc fix: resolve code review issues — schema mismatch, test fixes, pop-out render
- Fix a2ui-format.md schema to match renderer: rename 'options' → 'filters' in
  filters block, replace 'active: boolean' with 'value: string' in filter options,
  update form select options from string[] to {label, value}[] objects (Critical #1)
- Fix 3 failing tests in test_researcher_agent.sh to use @mention paths without .md
  extension (Important #2)
- Complete pop-out feature in VisualBlock.handlePopOut() by posting render message
  to popup window after 500ms initialization delay (Important #3)
- Update test_context_files.sh to match new a2ui-format.md schema (filters array
  key check, value field check)
2026-05-26 20:54:58 +00:00

263 lines
7.6 KiB
TypeScript

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<string, unknown>
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()
// Give the iframe time to initialize, then send the render message
setTimeout(() => {
w.postMessage(
{
jsonrpc: '2.0',
method: 'render',
params: { code: payload.code, data: payload.data ?? {} },
},
'*'
)
}, 500)
})
.catch(() => {
w.close()
})
}
}
return (
<div className='visual-block' style={{ margin: '0.5rem 0' }}>
{/* Header bar */}
<div
style={{
padding: '0.3rem 0.6rem',
borderBottom: '1px solid #2a2a4a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: '#12192e',
borderRadius: '4px 4px 0 0',
border: '1px solid #2a2a4a',
}}
>
<span style={{ color: '#7aa2f7', fontSize: '0.8rem' }}>
{payload.title ?? 'Visual'}
</span>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
onClick={() => setShowSource(s => !s)}
style={controlBtnStyle}
>
{showSource ? 'Hide Source' : 'View Source'}
</button>
<button onClick={handlePopOut} style={controlBtnStyle}>
Pop Out
</button>
</div>
</div>
{/* Source viewer */}
{showSource && (
<pre
style={{
background: '#0a0a1a',
padding: '0.5rem',
fontSize: '0.75rem',
color: '#c0c0d0',
overflowX: 'auto',
margin: 0,
borderLeft: '1px solid #2a2a4a',
borderRight: '1px solid #2a2a4a',
}}
>
{payload.code}
</pre>
)}
{/* Runtime iframe via MCPAppRenderer */}
<MCPAppRenderer
resourceUri='app://runtime'
data={{ ...payload, runtime: 'visual-artifact' }}
onSendMessage={onSend}
/>
</div>
)
}
// ─── ChatPanel ─────────────────────────────────────────────────────────────────
export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) {
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
function handleSend() {
if (input.trim() && !isStreaming) {
onSend(input.trim())
setInput('')
}
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (sessionId === null) {
return (
<div className='chat-panel'>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
<span style={{ color: '#6a6a8a', fontSize: '1.1rem' }}>
Select or create a session to start researching
</span>
</div>
</div>
)
}
return (
<div className='chat-panel'>
<div className='chat-messages'>
{messages.map(message => (
<div key={message.id} className={`chat-message ${message.role}`}>
{message.role === 'assistant' ? (
<div>
<ReactMarkdown>{message.content}</ReactMarkdown>
{/* A2UI interactive blocks */}
{parseA2UIBlocks(message.content).map((block, i) => (
<A2UIRenderer
key={i}
block={block}
onAction={(action, data) =>
onSend(`[${action}] ${JSON.stringify(data)}`)
}
/>
))}
{/* Visual artifact blocks */}
{parseVisualBlocks(message.content).map((payload, i) => (
<VisualBlock key={i} payload={payload} onSend={onSend} />
))}
{/* Tool call cards / MCP app renderers */}
{message.toolCalls?.map(tc =>
tc._meta?.ui?.resourceUri ? (
<MCPAppRenderer
key={tc.id}
resourceUri={tc._meta.ui.resourceUri}
data={tc.structuredContent ?? {}}
csp={tc._meta.ui.csp}
onSendMessage={(content) => onSend(content)}
/>
) : (
<ToolCallCard key={tc.id} toolCall={tc} />
)
)}
</div>
) : (
<div>{message.content}</div>
)}
</div>
))}
{isStreaming && messages.length > 0 && <div>Researching...</div>}
<div ref={messagesEndRef} />
</div>
<div className='chat-input-area'>
<input
placeholder='Ask a research question...'
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isStreaming}
autoFocus
/>
<button onClick={handleSend} disabled={isStreaming || !input.trim()}>
<IconSend size={18} />
</button>
</div>
</div>
)
}