diff --git a/frontend/src/components/A2UIRenderer.tsx b/frontend/src/components/A2UIRenderer.tsx index 34d5b5e..dfdcd7d 100644 --- a/frontend/src/components/A2UIRenderer.tsx +++ b/frontend/src/components/A2UIRenderer.tsx @@ -1,5 +1,5 @@ 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' interface A2UIRendererProps { diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx index 16e256e..37d23c7 100644 --- a/frontend/src/components/ChatPanel.tsx +++ b/frontend/src/components/ChatPanel.tsx @@ -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 + 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) @@ -62,6 +193,24 @@ export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPane {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 ? ( ) ?? {}, + }, + }, + '*' + ) + } 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( - { jsonrpc: '2.0', method: 'tool/input', params: data }, + { jsonrpc: '2.0', method: 'render', params: msg.params ?? {} }, '*' ) break diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 32635cb..d2c40e5 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -1,4 +1,6 @@ import { useState } from 'react' +import { BrowserPanel } from './BrowserPanel' +import { ArtifactsPanel } from './ArtifactsPanel' interface RightPanelProps { sessionId: string | null @@ -7,16 +9,6 @@ interface RightPanelProps { export function RightPanel({ sessionId }: RightPanelProps) { 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 (
@@ -34,7 +26,11 @@ export function RightPanel({ sessionId }: RightPanelProps) {
- {renderContent()} + {activeTab === 'browser' ? ( + + ) : ( + + )}
) diff --git a/frontend/src/test/BrowserPanel.test.tsx b/frontend/src/test/BrowserPanel.test.tsx index 41a4d1d..00c2c49 100644 --- a/frontend/src/test/BrowserPanel.test.tsx +++ b/frontend/src/test/BrowserPanel.test.tsx @@ -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 { BrowserPanel } from '../components/BrowserPanel' diff --git a/frontend/src/test/ChatPanel.test.tsx b/frontend/src/test/ChatPanel.test.tsx index 7f23bc1..b6509b7 100644 --- a/frontend/src/test/ChatPanel.test.tsx +++ b/frontend/src/test/ChatPanel.test.tsx @@ -15,9 +15,51 @@ vi.mock('react-markdown', () => ({ })) // Mock MCPAppRenderer to avoid fetch/iframe in test environment +// Also captures data-runtime for visual-block assertions vi.mock('../components/MCPAppRenderer', () => ({ - MCPAppRenderer: ({ resourceUri }: { resourceUri: string }) => ( -
+ MCPAppRenderer: ({ + resourceUri, + data, + }: { + resourceUri: string + data: Record + }) => ( +
+ ), +})) + +// Mock A2UIRenderer module (component + parseA2UIBlocks) +vi.mock('../components/A2UIRenderer', () => ({ + parseA2UIBlocks: vi.fn((content: string) => { + const blocks: Array> = [] + 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 + if (parsed && parsed.type) blocks.push(parsed) + } catch { + // ignore bad JSON + } + } + return blocks + }), + A2UIRenderer: ({ + block, + onAction, + }: { + block: Record + onAction?: (action: string, data: Record) => void + }) => ( +
onAction?.('test_action', { foo: 'bar' })} + /> ), })) @@ -281,4 +323,119 @@ describe('ChatPanel', () => { 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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":"
hi
","data":{}}\n```' + const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }] + render( + + ) + 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( + + ) + 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( + + ) + expect(screen.queryByText('console.log(42)')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: /view source/i })) + expect(screen.getByText('console.log(42)')).toBeTruthy() + }) + }) }) diff --git a/frontend/src/test/RightPanel.test.tsx b/frontend/src/test/RightPanel.test.tsx index e7f2301..c03248e 100644 --- a/frontend/src/test/RightPanel.test.tsx +++ b/frontend/src/test/RightPanel.test.tsx @@ -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 userEvent from '@testing-library/user-event' + +// Hoisted mocks — must appear before component imports +vi.mock('../components/BrowserPanel', () => ({ + BrowserPanel: ({ sessionId }: { sessionId: string | null }) => ( +
+ ), +})) + +vi.mock('../components/ArtifactsPanel', () => ({ + ArtifactsPanel: ({ sessionId }: { sessionId: string | null }) => ( +
+ ), +})) + import { RightPanel } from '../components/RightPanel' describe('RightPanel', () => { @@ -33,28 +47,6 @@ describe('RightPanel', () => { expect(artifactsBtn.classList.contains('active')).toBe(false) }) - it('shows "Select a session to view the browser/artifacts" when sessionId is null', () => { - render() - 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() - 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() - 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 () => { render() await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) @@ -72,11 +64,51 @@ describe('RightPanel', () => { 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() + expect(screen.getByTestId('browser-panel')).toBeTruthy() + }) + + it('passes sessionId to BrowserPanel', () => { + render() + expect(screen.getByTestId('browser-panel').getAttribute('data-session-id')).toBe('session-xyz') + }) + + it('passes null sessionId to BrowserPanel when sessionId is null', () => { + render() + expect(screen.getByTestId('browser-panel').getAttribute('data-session-id')).toBe('null') + }) + + it('does not render ArtifactsPanel when browser tab is active', () => { + render() + expect(screen.queryByTestId('artifacts-panel')).toBeNull() + }) + + // — ArtifactsPanel integration — + + it('renders ArtifactsPanel when artifacts tab is active', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) + expect(screen.getByTestId('artifacts-panel')).toBeTruthy() + }) + + it('passes sessionId to ArtifactsPanel', async () => { + render() + 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() await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) - expect( - screen.getByText('Select a session to view the browser/artifacts') - ).toBeTruthy() + expect(screen.getByTestId('artifacts-panel').getAttribute('data-session-id')).toBe('null') + }) + + it('does not render BrowserPanel when artifacts tab is active', async () => { + render() + await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) + expect(screen.queryByTestId('browser-panel')).toBeNull() }) })