389a8fb655
- Extract parseA2UIBlocks() from A2UIRenderer.tsx into its own frontend/src/lib/a2ui.ts module to satisfy react-refresh/only-export- components ESLint rule (A2UIRenderer should only export React components) - Update ChatPanel.tsx and test files to import from lib/a2ui - Split ChatPanel.test.tsx mock: separate vi.mock for lib/a2ui (parseA2UIBlocks) and A2UIRenderer (component only) - Remove unused 'import re' from tests/test_runtime_html.py (ruff F401) All 216 frontend tests and 242 bash tests still pass. TypeScript and Vite builds are clean. Remaining ESLint warnings (react-hooks/set-state- in-effect in MCPAppRenderer and useSessions) are pre-existing patterns not introduced by Phase 3.
234 lines
7.7 KiB
TypeScript
234 lines
7.7 KiB
TypeScript
import { useState } from 'react'
|
|
import { IconFilter, IconTable, IconForms } from '@tabler/icons-react'
|
|
import type { A2UIBlock } from '../lib/types'
|
|
|
|
interface A2UIRendererProps {
|
|
block: A2UIBlock
|
|
onAction?: (action: string, data: Record<string, unknown>) => void
|
|
}
|
|
|
|
// ─── Filters ─────────────────────────────────────────────────────────────────
|
|
|
|
function FiltersRenderer({ block, onAction }: A2UIRendererProps & { block: Extract<A2UIBlock, { type: 'filters' }> }) {
|
|
const [activeLabels, setActiveLabels] = useState<Set<string>>(new Set())
|
|
|
|
function handleToggle(label: string) {
|
|
const isActive = activeLabels.has(label)
|
|
setActiveLabels(prev => {
|
|
const next = new Set(prev)
|
|
if (isActive) next.delete(label)
|
|
else next.add(label)
|
|
return next
|
|
})
|
|
onAction?.('filter_toggle', { label, active: !isActive })
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className='tool-call-card'
|
|
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}
|
|
>
|
|
<IconFilter size={16} color='#7aa2f7' style={{ marginRight: 4 }} />
|
|
{block.filters.map(option => {
|
|
const active = activeLabels.has(option.label)
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => handleToggle(option.label)}
|
|
style={{
|
|
padding: '0.3rem 0.6rem',
|
|
borderRadius: '12px',
|
|
border: '1px solid',
|
|
borderColor: active ? '#5a7aba' : '#3a3a5a',
|
|
background: active ? '#2a3a6a' : 'transparent',
|
|
color: active ? '#ffffff' : '#8888aa',
|
|
fontSize: '0.8rem',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Table ───────────────────────────────────────────────────────────────────
|
|
|
|
function TableRenderer({ block }: { block: Extract<A2UIBlock, { type: 'table' }> }) {
|
|
return (
|
|
<div className='tool-call-card' style={{ overflowX: 'auto' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
|
|
<IconTable size={16} color='#7aa2f7' />
|
|
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Data Table</span>
|
|
</div>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
|
<thead>
|
|
<tr>
|
|
{block.headers.map((header, i) => (
|
|
<th
|
|
key={i}
|
|
style={{
|
|
textAlign: 'left',
|
|
padding: '0.3rem 0.5rem',
|
|
borderBottom: '1px solid #2a2a4a',
|
|
color: '#aaaacc',
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{block.rows.map((row, ri) => (
|
|
<tr key={ri}>
|
|
{row.map((cell, ci) => (
|
|
<td
|
|
key={ci}
|
|
style={{
|
|
padding: '0.3rem 0.5rem',
|
|
borderBottom: '1px solid #1a1a2e',
|
|
color: '#c0c0d0',
|
|
}}
|
|
>
|
|
{cell}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Cards ───────────────────────────────────────────────────────────────────
|
|
|
|
function CardsRenderer({ block }: { block: Extract<A2UIBlock, { type: 'cards' }> }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
|
gap: '0.5rem',
|
|
margin: '0.5rem 0',
|
|
}}
|
|
>
|
|
{block.items.map((item, i) => (
|
|
<div
|
|
key={i}
|
|
className='tool-call-card'
|
|
style={{ cursor: item.url ? 'pointer' : undefined }}
|
|
onClick={item.url ? () => window.open(item.url, '_blank') : undefined}
|
|
>
|
|
<div style={{ fontWeight: 600, color: '#e0e0e0' }}>{item.title}</div>
|
|
{item.price != null && (
|
|
<div style={{ color: '#7af7a2', fontWeight: 600, fontSize: '0.9rem' }}>{item.price}</div>
|
|
)}
|
|
{item.description && (
|
|
<div style={{ color: '#8888aa', fontSize: '0.75rem', marginTop: 4 }}>{item.description}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Form ────────────────────────────────────────────────────────────────────
|
|
|
|
function FormRenderer({
|
|
block,
|
|
onAction,
|
|
}: {
|
|
block: Extract<A2UIBlock, { type: 'form' }>
|
|
onAction?: (action: string, data: Record<string, unknown>) => void
|
|
}) {
|
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
const formData = new FormData(e.currentTarget)
|
|
const data: Record<string, unknown> = {}
|
|
formData.forEach((value, key) => {
|
|
data[key] = value
|
|
})
|
|
onAction?.('form_submit', data)
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
padding: '0.3rem',
|
|
background: '#1e2a4a',
|
|
border: '1px solid #3a3a5a',
|
|
borderRadius: '4px',
|
|
color: '#e0e0e0',
|
|
fontSize: '0.8rem',
|
|
boxSizing: 'border-box',
|
|
}
|
|
|
|
return (
|
|
<div className='tool-call-card'>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
|
|
<IconForms size={16} color='#7aa2f7' />
|
|
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Input Form</span>
|
|
</div>
|
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
|
{block.fields.map(field => (
|
|
<div key={field.name}>
|
|
<label style={{ fontSize: '0.75rem', color: '#8888aa', display: 'block' }}>
|
|
{field.label || field.name}
|
|
</label>
|
|
{field.type === 'select' ? (
|
|
<select name={field.name} style={inputStyle}>
|
|
{field.options?.map(opt => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input name={field.name} type={field.type} style={inputStyle} />
|
|
)}
|
|
</div>
|
|
))}
|
|
<button
|
|
type='submit'
|
|
style={{
|
|
padding: '0.4rem 0.8rem',
|
|
background: '#3a5aba',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '0.8rem',
|
|
marginTop: '0.3rem',
|
|
alignSelf: 'flex-start',
|
|
}}
|
|
>
|
|
{block.submitLabel || 'Submit'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main Renderer ───────────────────────────────────────────────────────────
|
|
|
|
export function A2UIRenderer({ block, onAction }: A2UIRendererProps) {
|
|
switch (block.type) {
|
|
case 'filters':
|
|
return <FiltersRenderer block={block} onAction={onAction} />
|
|
case 'table':
|
|
return <TableRenderer block={block} />
|
|
case 'cards':
|
|
return <CardsRenderer block={block} />
|
|
case 'form':
|
|
return <FormRenderer block={block} onAction={onAction} />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
|