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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user