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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -62,10 +62,33 @@ 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(
|
iframeRef.current.contentWindow.postMessage(
|
||||||
{ jsonrpc: '2.0', method: 'tool/input', params: data },
|
{ 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: 'render', params: msg.params ?? {} },
|
||||||
|
'*'
|
||||||
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'tools/call':
|
case 'tools/call':
|
||||||
|
|||||||
@@ -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,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'
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user