feat: wire RightPanel with Browser + Artifacts, add A2UI rendering to chat

- RightPanel: replace placeholder content with BrowserPanel + ArtifactsPanel
  components, routing sessionId to whichever tab is active
- ChatPanel: import A2UIRenderer + parseA2UIBlocks; render A2UI blocks between
  markdown and tool-call cards in assistant messages; onAction sends
  '[action] {json}' back via onSend
- ChatPanel: add parseVisualBlocks() helper, VisualBlock wrapper component
  (loads runtime.html via MCPAppRenderer, view-source toggle, pop-out),
  rendered after A2UI blocks in assistant messages
- MCPAppRenderer: on ui/initialize route runtime='visual-artifact' data to a
  render dispatch instead of tool/input; add render forwarding case
- Fix pre-existing TS6133 unused-import errors in A2UIRenderer + BrowserPanel test
- All 216 tests pass; npm run build succeeds
This commit is contained in:
Ken
2026-05-26 20:43:28 +00:00
parent ab99259bcc
commit b895a1c94b
7 changed files with 400 additions and 43 deletions
+149
View File
@@ -4,6 +4,7 @@ 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 {
@@ -13,6 +14,136 @@ interface ChatPanelProps {
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()
})
.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)
@@ -62,6 +193,24 @@ export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPane
{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