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
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { IconFilter, IconTable, IconCards, IconForms } from '@tabler/icons-react' import { IconFilter, IconTable, IconForms } from '@tabler/icons-react'
import type { A2UIBlock } from '../lib/types' import type { A2UIBlock } from '../lib/types'
interface A2UIRendererProps { interface A2UIRendererProps {
+149
View File
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
import { IconSend } from '@tabler/icons-react' import { IconSend } from '@tabler/icons-react'
import { ToolCallCard } from './ToolCallCard' import { ToolCallCard } from './ToolCallCard'
import { MCPAppRenderer } from './MCPAppRenderer' import { MCPAppRenderer } from './MCPAppRenderer'
import { A2UIRenderer, parseA2UIBlocks } from './A2UIRenderer'
import type { ChatMessage } from '../hooks/useChat' import type { ChatMessage } from '../hooks/useChat'
interface ChatPanelProps { interface ChatPanelProps {
@@ -13,6 +14,136 @@ interface ChatPanelProps {
sessionId: string | null 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) { export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -62,6 +193,24 @@ export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPane
{message.role === 'assistant' ? ( {message.role === 'assistant' ? (
<div> <div>
<ReactMarkdown>{message.content}</ReactMarkdown> <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 => {message.toolCalls?.map(tc =>
tc._meta?.ui?.resourceUri ? ( tc._meta?.ui?.resourceUri ? (
<MCPAppRenderer <MCPAppRenderer
+24 -1
View File
@@ -62,8 +62,31 @@ export function MCPAppRenderer({
switch (msg.method) { switch (msg.method) {
case 'ui/initialize': case 'ui/initialize':
// Visual artifacts receive a `render` dispatch; all other apps get `tool/input`
if (data.runtime === 'visual-artifact') {
iframeRef.current.contentWindow.postMessage(
{
jsonrpc: '2.0',
method: 'render',
params: {
code: (data.code as string) ?? '',
data: (data.data as Record<string, unknown>) ?? {},
},
},
'*'
)
} else {
iframeRef.current.contentWindow.postMessage(
{ jsonrpc: '2.0', method: 'tool/input', params: data },
'*'
)
}
break
case 'render':
// Forward render requests from parent context to iframe
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ jsonrpc: '2.0', method: 'tool/input', params: data }, { jsonrpc: '2.0', method: 'render', params: msg.params ?? {} },
'*' '*'
) )
break break
+7 -11
View File
@@ -1,4 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { BrowserPanel } from './BrowserPanel'
import { ArtifactsPanel } from './ArtifactsPanel'
interface RightPanelProps { interface RightPanelProps {
sessionId: string | null sessionId: string | null
@@ -7,16 +9,6 @@ interface RightPanelProps {
export function RightPanel({ sessionId }: RightPanelProps) { export function RightPanel({ sessionId }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<'browser' | 'artifacts'>('browser') const [activeTab, setActiveTab] = useState<'browser' | 'artifacts'>('browser')
function renderContent() {
if (!sessionId) {
return 'Select a session to view the browser/artifacts'
}
if (activeTab === 'browser') {
return 'Browser panel (noVNC) — implemented in Phase 3'
}
return 'Artifacts panel — implemented in Phase 3'
}
return ( return (
<div className='right-panel'> <div className='right-panel'>
<div className='right-panel-tabs'> <div className='right-panel-tabs'>
@@ -34,7 +26,11 @@ export function RightPanel({ sessionId }: RightPanelProps) {
</button> </button>
</div> </div>
<div className='right-panel-content'> <div className='right-panel-content'>
{renderContent()} {activeTab === 'browser' ? (
<BrowserPanel sessionId={sessionId} />
) : (
<ArtifactsPanel sessionId={sessionId} />
)}
</div> </div>
</div> </div>
) )
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { BrowserPanel } from '../components/BrowserPanel' import { BrowserPanel } from '../components/BrowserPanel'
+159 -2
View File
@@ -15,9 +15,51 @@ vi.mock('react-markdown', () => ({
})) }))
// Mock MCPAppRenderer to avoid fetch/iframe in test environment // Mock MCPAppRenderer to avoid fetch/iframe in test environment
// Also captures data-runtime for visual-block assertions
vi.mock('../components/MCPAppRenderer', () => ({ vi.mock('../components/MCPAppRenderer', () => ({
MCPAppRenderer: ({ resourceUri }: { resourceUri: string }) => ( MCPAppRenderer: ({
<div data-testid='mcp-app-renderer' data-resource-uri={resourceUri} /> resourceUri,
data,
}: {
resourceUri: string
data: Record<string, unknown>
}) => (
<div
data-testid='mcp-app-renderer'
data-resource-uri={resourceUri}
data-runtime={(data?.runtime as string) ?? ''}
/>
),
}))
// Mock A2UIRenderer module (component + parseA2UIBlocks)
vi.mock('../components/A2UIRenderer', () => ({
parseA2UIBlocks: vi.fn((content: string) => {
const blocks: Array<Record<string, unknown>> = []
const regex = /```a2ui\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]) as Record<string, unknown>
if (parsed && parsed.type) blocks.push(parsed)
} catch {
// ignore bad JSON
}
}
return blocks
}),
A2UIRenderer: ({
block,
onAction,
}: {
block: Record<string, unknown>
onAction?: (action: string, data: Record<string, unknown>) => void
}) => (
<div
data-testid='a2ui-renderer'
data-block-type={block.type as string}
onClick={() => onAction?.('test_action', { foo: 'bar' })}
/>
), ),
})) }))
@@ -281,4 +323,119 @@ describe('ChatPanel', () => {
expect(onSend).not.toHaveBeenCalled() expect(onSend).not.toHaveBeenCalled()
}) })
}) })
// — A2UI rendering —
describe('A2UI block rendering', () => {
const sessionId = 'session-a2ui'
it('renders A2UIRenderer for assistant message containing an a2ui block', () => {
const content = '```a2ui\n{"type":"table","headers":["Name"],"rows":[["Alice"]]}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.getByTestId('a2ui-renderer')).toBeTruthy()
})
it('does not render A2UIRenderer when assistant message has no a2ui block', () => {
const messages: ChatMessage[] = [
{ id: '1', role: 'assistant', content: 'Plain text response' },
]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.queryByTestId('a2ui-renderer')).toBeNull()
})
it('passes block type from parsed a2ui content to A2UIRenderer', () => {
const content = '```a2ui\n{"type":"filters","filters":[]}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
const renderer = screen.getByTestId('a2ui-renderer')
expect(renderer.getAttribute('data-block-type')).toBe('filters')
})
it('calls onSend with [action] {json} format when A2UI onAction fires', async () => {
const onSend = vi.fn()
const content = '```a2ui\n{"type":"table","headers":["Name"],"rows":[["Alice"]]}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={onSend} sessionId={sessionId} />
)
await userEvent.click(screen.getByTestId('a2ui-renderer'))
expect(onSend).toHaveBeenCalledWith('[test_action] {"foo":"bar"}')
})
})
// — Visual block rendering —
describe('visual block rendering', () => {
const sessionId = 'session-visual'
it('renders MCPAppRenderer with runtime=visual-artifact for a visual block', () => {
const content =
'```visual\n{"title":"Test Chart","code":"console.log(1)","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
const renderers = screen.getAllByTestId('mcp-app-renderer')
const visualRenderer = renderers.find(
r =>
r.getAttribute('data-resource-uri') === 'app://runtime' &&
r.getAttribute('data-runtime') === 'visual-artifact'
)
expect(visualRenderer).toBeTruthy()
})
it('does not render visual MCPAppRenderer when assistant message has no visual block', () => {
const messages: ChatMessage[] = [
{ id: '1', role: 'assistant', content: 'No visual here' },
]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
const renderers = screen.queryAllByTestId('mcp-app-renderer')
const visualRenderer = renderers.find(
r => r.getAttribute('data-runtime') === 'visual-artifact'
)
expect(visualRenderer).toBeUndefined()
})
it('renders view-source and pop-out buttons in visual block header', () => {
const content =
'```visual\n{"title":"My Visual","code":"<div>hi</div>","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.getByRole('button', { name: /view source/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /pop out/i })).toBeTruthy()
})
it('shows the visual block title', () => {
const content =
'```visual\n{"title":"Price Chart","code":"console.log(1)","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.getByText('Price Chart')).toBeTruthy()
})
it('toggles source display when View Source button clicked', async () => {
const content =
'```visual\n{"title":"Test","code":"console.log(42)","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.queryByText('console.log(42)')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /view source/i }))
expect(screen.getByText('console.log(42)')).toBeTruthy()
})
})
}) })
+59 -27
View File
@@ -1,6 +1,20 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
// Hoisted mocks — must appear before component imports
vi.mock('../components/BrowserPanel', () => ({
BrowserPanel: ({ sessionId }: { sessionId: string | null }) => (
<div data-testid='browser-panel' data-session-id={sessionId ?? 'null'} />
),
}))
vi.mock('../components/ArtifactsPanel', () => ({
ArtifactsPanel: ({ sessionId }: { sessionId: string | null }) => (
<div data-testid='artifacts-panel' data-session-id={sessionId ?? 'null'} />
),
}))
import { RightPanel } from '../components/RightPanel' import { RightPanel } from '../components/RightPanel'
describe('RightPanel', () => { describe('RightPanel', () => {
@@ -33,28 +47,6 @@ describe('RightPanel', () => {
expect(artifactsBtn.classList.contains('active')).toBe(false) expect(artifactsBtn.classList.contains('active')).toBe(false)
}) })
it('shows "Select a session to view the browser/artifacts" when sessionId is null', () => {
render(<RightPanel sessionId={null} />)
expect(
screen.getByText('Select a session to view the browser/artifacts')
).toBeTruthy()
})
it('shows browser placeholder when sessionId is set and browser tab is active', () => {
render(<RightPanel sessionId='session-123' />)
expect(
screen.getByText('Browser panel (noVNC) — implemented in Phase 3')
).toBeTruthy()
})
it('shows artifacts placeholder when sessionId is set and artifacts tab is active', async () => {
render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(
screen.getByText('Artifacts panel — implemented in Phase 3')
).toBeTruthy()
})
it('switches active class to Artifacts tab when clicked', async () => { it('switches active class to Artifacts tab when clicked', async () => {
render(<RightPanel sessionId='session-123' />) render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
@@ -72,11 +64,51 @@ describe('RightPanel', () => {
expect(browserBtn.classList.contains('active')).toBe(true) expect(browserBtn.classList.contains('active')).toBe(true)
}) })
it('still shows no-session placeholder on artifacts tab when sessionId is null', async () => { // — BrowserPanel integration —
it('renders BrowserPanel when browser tab is active', () => {
render(<RightPanel sessionId='session-123' />)
expect(screen.getByTestId('browser-panel')).toBeTruthy()
})
it('passes sessionId to BrowserPanel', () => {
render(<RightPanel sessionId='session-xyz' />)
expect(screen.getByTestId('browser-panel').getAttribute('data-session-id')).toBe('session-xyz')
})
it('passes null sessionId to BrowserPanel when sessionId is null', () => {
render(<RightPanel sessionId={null} />)
expect(screen.getByTestId('browser-panel').getAttribute('data-session-id')).toBe('null')
})
it('does not render ArtifactsPanel when browser tab is active', () => {
render(<RightPanel sessionId='session-123' />)
expect(screen.queryByTestId('artifacts-panel')).toBeNull()
})
// — ArtifactsPanel integration —
it('renders ArtifactsPanel when artifacts tab is active', async () => {
render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(screen.getByTestId('artifacts-panel')).toBeTruthy()
})
it('passes sessionId to ArtifactsPanel', async () => {
render(<RightPanel sessionId='session-xyz' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(screen.getByTestId('artifacts-panel').getAttribute('data-session-id')).toBe('session-xyz')
})
it('passes null sessionId to ArtifactsPanel when sessionId is null', async () => {
render(<RightPanel sessionId={null} />) render(<RightPanel sessionId={null} />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect( expect(screen.getByTestId('artifacts-panel').getAttribute('data-session-id')).toBe('null')
screen.getByText('Select a session to view the browser/artifacts') })
).toBeTruthy()
it('does not render BrowserPanel when artifacts tab is active', async () => {
render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(screen.queryByTestId('browser-panel')).toBeNull()
}) })
}) })