Compare commits

...

10 Commits

Author SHA1 Message Date
Ken 389a8fb655 refactor: extract parseA2UIBlocks to lib/a2ui.ts; remove unused import re
- 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.
2026-05-26 21:14:10 +00:00
Ken 2c3cfdbedc fix: resolve code review issues — schema mismatch, test fixes, pop-out render
- Fix a2ui-format.md schema to match renderer: rename 'options' → 'filters' in
  filters block, replace 'active: boolean' with 'value: string' in filter options,
  update form select options from string[] to {label, value}[] objects (Critical #1)
- Fix 3 failing tests in test_researcher_agent.sh to use @mention paths without .md
  extension (Important #2)
- Complete pop-out feature in VisualBlock.handlePopOut() by posting render message
  to popup window after 500ms initialization delay (Important #3)
- Update test_context_files.sh to match new a2ui-format.md schema (filters array
  key check, value field check)
2026-05-26 20:54:58 +00:00
Ken 92df88975c feat: docker-compose.yml for local development 2026-05-26 20:45:02 +00:00
Ken b895a1c94b 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
2026-05-26 20:43:28 +00:00
Ken ab99259bcc feat: A2UI renderer for filters, tables, cards, forms 2026-05-26 20:29:42 +00:00
Ken a3635a9eb7 feat: artifacts panel with tabbed markdown rendering 2026-05-26 20:24:12 +00:00
Ken c1533fe25c feat: browser panel with noVNC iframe 2026-05-26 20:19:39 +00:00
Ken 74b2861db5 feat: complete bundle with visual artifact runtime and context 2026-05-26 20:16:02 +00:00
Ken 6538d0aa25 feat: visual artifacts context file with 5 example patterns for AI-generated visuals 2026-05-26 20:13:31 +00:00
Ken 0751fbfa0a feat: visual artifact runtime for AI-generated inline visualizations
- Add bundle/apps/runtime.html: universal runtime that executes
  AI-generated visualization code in a sandboxed context
- Pre-loads CDN libraries: Tabler CSS, Leaflet CSS+JS, Chart.js v4,
  React 19 UMD, ReactDOM 19 UMD, Babel standalone 7
- Implements mcpBridge with JSON-RPC 2.0 protocol:
  sendToHost, callTool (with UUID ids + Promise), sendMessage, resize
- executeVisual detects JSX/HTML/plain-JS paths via regex
- Auto-resize via requestAnimationFrame + scrollHeight + 32, capped at 800
- Initial ui/initialize handshake with runtime='visual-artifact' v1.0.0
- Listens for 'render' and legacy 'tool/input' methods
- Add tests/test_runtime_html.py: 61 tests covering all spec requirements
2026-05-26 20:08:22 +00:00
25 changed files with 2828 additions and 59 deletions
View File
+3 -3
View File
@@ -150,7 +150,7 @@ You can generate inline UI controls — filter toggles, sortable tables, card gr
forms — that render directly in the chat. Use A2UI for simple interactive result forms — that render directly in the chat. Use A2UI for simple interactive result
presentation when the data is straightforward. presentation when the data is straightforward.
@mention bundle/context/a2ui-format.md @mention bundle/context/a2ui-format
## Artifacts ## Artifacts
@@ -158,7 +158,7 @@ For research worth saving — comparison guides, reports, curated lists — gene
persistent artifact documents. Artifacts appear in the right panel and survive persistent artifact documents. Artifacts appear in the right panel and survive
across sessions. across sessions.
@mention bundle/context/artifact-conventions.md @mention bundle/context/artifact-conventions
## Inline Visuals (Claude-style) ## Inline Visuals (Claude-style)
@@ -173,7 +173,7 @@ Good candidates for inline visual generation:
- **Scatter plots** for price vs. rating or mileage vs. price analysis - **Scatter plots** for price vs. rating or mileage vs. price analysis
- **Timeline charts** for flight options by departure time - **Timeline charts** for flight options by departure time
@mention bundle/context/visual-artifacts.md @mention bundle/context/visual-artifacts
## Research Workflow ## Research Workflow
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visual Artifact Runtime</title>
<!-- Tabler CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" />
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" />
<!-- Chart.js v4 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<!-- Leaflet JS -->
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js"></script>
<!-- React 19 production UMD -->
<script src="https://cdn.jsdelivr.net/npm/react@19/umd/react.production.min.js"></script>
<!-- ReactDOM 19 production UMD -->
<script src="https://cdn.jsdelivr.net/npm/react-dom@19/umd/react-dom.production.min.js"></script>
<!-- Babel standalone 7 for JSX transpilation -->
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<style>
html, body {
background: transparent;
color-scheme: dark;
color: #e0e0e0;
margin: 0;
padding: 16px;
font-family: system-ui, -apple-system, sans-serif;
}
.visual-error {
color: #ff4444;
background: #1a0000;
font-family: monospace;
padding: 12px 16px;
white-space: pre-wrap;
border-radius: 4px;
border: 1px solid #550000;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
// ─── MCP Bridge ───────────────────────────────────────────────────────────
const mcpBridge = {
_pending: new Map(),
sendToHost(msg) {
window.parent.postMessage(msg, "*");
},
callTool(name, args) {
const id = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
return new Promise((resolve, reject) => {
mcpBridge._pending.set(id, { resolve, reject });
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "tools/call",
id,
params: { name, arguments: args },
});
});
},
sendMessage(content) {
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "ui/sendMessage",
params: { role: "user", content },
});
},
resize(height) {
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "ui/resize",
params: { height: Math.min(height, 800) },
});
},
};
// ─── Execute Visual ───────────────────────────────────────────────────────
function executeVisual(code, data) {
const root = document.getElementById("root");
window.__VISUAL_DATA__ = data || {};
window.__MCP__ = mcpBridge;
try {
const isJSX = (
/<[A-Z]/.test(code) ||
/React\.createElement/.test(code) ||
/type="text\/babel"/.test(code)
);
const isHTML = !isJSX && /<[a-z]/.test(code);
if (isJSX) {
// JSX path — compile with Babel then render
const compiled = Babel.transform(code, {
presets: ["react"],
filename: "visual.jsx",
}).code;
const autoRender = `
if (typeof App !== 'undefined') {
ReactDOM.createRoot(root).render(React.createElement(App, { data, mcp }));
}
`;
const fn = new Function("React", "ReactDOM", "data", "mcp", "root", compiled + "\n" + autoRender);
fn(React, ReactDOM, window.__VISUAL_DATA__, mcpBridge, root);
} else if (isHTML) {
// HTML path — inject markup and re-execute inline scripts
root.innerHTML = code;
// Re-execute all script tags (browsers don't execute scripts set via innerHTML)
const scripts = root.querySelectorAll("script");
scripts.forEach((oldScript) => {
const newScript = document.createElement("script");
if (oldScript.src) {
// External script — preserve src
newScript.src = oldScript.src;
} else {
// Inline script — wrap with data/mcp context
const body = oldScript.textContent;
newScript.textContent = `(function(data, mcp) {\n${body}\n})(window.__VISUAL_DATA__, window.__MCP__);`;
}
oldScript.parentNode.replaceChild(newScript, oldScript);
});
} else {
// Plain JS path — execute with explicit arguments
const fn = new Function("data", "mcp", "root", "Chart", "L", code);
fn(window.__VISUAL_DATA__, mcpBridge, root, window.Chart, window.L);
}
// Auto-resize after render
requestAnimationFrame(() => {
mcpBridge.resize(Math.max(root.scrollHeight + 32, 100));
});
} catch (err) {
// Render error state
const errorDiv = document.createElement("div");
errorDiv.className = "visual-error";
errorDiv.textContent = String(err);
root.innerHTML = "";
root.appendChild(errorDiv);
mcpBridge.resize(120);
}
}
// ─── Message Listener ─────────────────────────────────────────────────────
window.addEventListener("message", (event) => {
const msg = event.data;
if (!msg) return;
// Response to a pending tools/call
if (msg.id && mcpBridge._pending.has(msg.id)) {
const { resolve } = mcpBridge._pending.get(msg.id);
mcpBridge._pending.delete(msg.id);
resolve(msg.result ?? msg);
return;
}
// Render command
if (msg.method === "render") {
const code = (msg.params && msg.params.code) || "";
const data = (msg.params && msg.params.data) || {};
executeVisual(code, data);
return;
}
// Legacy: tool/input with a code param treated as render
if (msg.method === "tool/input" && msg.params && msg.params.code) {
executeVisual(msg.params.code, msg.params.data || {});
return;
}
});
// ─── Initial Handshake ────────────────────────────────────────────────────
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "ui/initialize",
params: {
runtime: "visual-artifact",
version: "1.0.0",
},
});
</script>
</body>
</html>
View File
View File
+14 -10
View File
@@ -28,20 +28,20 @@ Renders a row of toggle chips. Use for quick category or attribute filtering.
```a2ui ```a2ui
{ {
"type": "filters", "type": "filters",
"options": [ "filters": [
{ "label": "Toyota", "active": true }, { "label": "Toyota", "value": "toyota" },
{ "label": "Honda", "active": false }, { "label": "Honda", "value": "honda" },
{ "label": "Hyundai", "active": false }, { "label": "Hyundai", "value": "hyundai" },
{ "label": "Under $5K", "active": false } { "label": "Under $5K", "value": "under_5k" }
] ]
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `options` | array | List of filter chips | | `filters` | array | List of filter chips |
| `options[].label` | string | Display text for the chip | | `filters[].label` | string | Display text for the chip |
| `options[].active` | boolean | Whether the chip is pre-selected | | `filters[].value` | string | Machine-readable identifier for the chip |
--- ---
@@ -113,7 +113,11 @@ configure a follow-up action.
"name": "fuel_type", "name": "fuel_type",
"type": "select", "type": "select",
"label": "Fuel Type", "label": "Fuel Type",
"options": ["hybrid", "electric", "any"] "options": [
{ "label": "Hybrid", "value": "hybrid" },
{ "label": "Electric", "value": "electric" },
{ "label": "Any", "value": "any" }
]
} }
], ],
"submitLabel": "Search" "submitLabel": "Search"
@@ -127,7 +131,7 @@ configure a follow-up action.
| `fields[].type` | string | `"number"`, `"text"`, `"select"` | | `fields[].type` | string | `"number"`, `"text"`, `"select"` |
| `fields[].label` | string | Human-readable label | | `fields[].label` | string | Human-readable label |
| `fields[].value` | any | Default value (for `number`/`text` fields) | | `fields[].value` | any | Default value (for `number`/`text` fields) |
| `fields[].options` | string[] | Choice list (for `select` fields) | | `fields[].options` | `{label, value}[]` | Choice list (for `select` fields); each item has a display `label` and machine-readable `value` |
| `submitLabel` | string | Text on the submit button | | `submitLabel` | string | Text on the submit button |
--- ---
+274
View File
@@ -0,0 +1,274 @@
# Visual Artifacts (Inline Visuals)
Visual artifacts let you generate rich, interactive charts, maps, tables, and dashboards
directly in the chat. Write a `visual` fenced code block containing JSON with a `code`
field, and the frontend renders it live inside a sandboxed iframe using pre-loaded libraries
— no installs, no API fetches, no user setup required.
Use visuals proactively. When data benefits from a chart, table, or map, generate the visual
without waiting to be asked.
---
## When to Generate a Visual
Generate a visual whenever the response includes structured data that communicates better
visually than as prose. Triggers:
| Situation | Visual type |
|---|---|
| Comparing options (cars, tools, services) | Comparison table, bar chart |
| Showing trends over time | Line chart |
| Displaying locations or dealers | Map with markers |
| Scoring or ranking items | Bar chart, sorted table |
| Budget breakdowns or proportions | Pie/doughnut chart |
| Dashboards summarizing research | React dashboard with stat cards |
| Decision-making with weighted criteria | Decision matrix with sliders |
**Be proactive.** Don't wait to be asked. If you're returning 4+ items with comparable
attributes, a visual table is almost always better than a markdown list.
---
## Available Libraries (pre-loaded in the runtime)
| Library | Version | Global | Capabilities |
|---|---|---|---|
| Tabler CSS | 1.0.0-beta20 | *(stylesheet)* | cards, tables, badges, buttons, forms, grid, dark theme |
| Chart.js | v4 | `Chart` | bar, line, pie, radar, doughnut, scatter, polar area |
| Leaflet.js | v1.9 | `L` | maps with markers, popups, tile layers (CARTO dark) |
| React | v19 | `React` | interactive components with state |
| ReactDOM | v19 | `ReactDOM` | DOM rendering for React components |
| Babel | v7 | *(automatic)* | JSX compilation when JSX syntax is detected |
---
## Output Format
Wrap your visual in a fenced code block with the `visual` language tag. The content must be
a JSON object:
````
```visual
{
"title": "Display title shown above the visual",
"height": 300,
"data": { "items": [] },
"code": "<div class='card'>...</div><script>...</script>"
}
```
````
### Fields
| Field | Required | Default | Max | Description |
|---|---|---|---|---|
| `title` | yes | — | — | Display header shown above the iframe |
| `code` | yes | — | — | Complete HTML, JSX, or plain JS to execute |
| `data` | no | `{}` | — | Structured object passed into code as `data` |
| `height` | no | 300 | 800 | Iframe height in pixels |
---
## Code Formats
The runtime auto-detects the code format and handles it appropriately:
### 1. React / JSX
**Detection:** code contains `<ComponentName` (uppercase tag) or `React.createElement`
Babel compiles the code, then the runtime renders it into `#root`. Define an `App`
component and it auto-renders:
```
function App() {
return <div className="card">Hello</div>;
}
// App is automatically rendered to #root
```
### 2. HTML
**Detection:** code contains `<div`, `<table`, `<canvas`, or similar lowercase HTML tags
The HTML is injected into `#root`. Any `<script>` tags execute with `data` and `mcp`
available in scope.
```
<div id="my-table"></div>
<script>
document.getElementById('my-table').innerHTML = data.items.map(i => i.name).join(', ');
</script>
```
### 3. Plain JS
**Detection:** code is pure JavaScript without HTML or JSX tags
Executed as a function receiving `(data, mcp, root, Chart, L)` as parameters:
```
const canvas = document.createElement('canvas');
root.appendChild(canvas);
new Chart(canvas, { type: 'bar', data: { labels: data.labels, datasets: [...] } });
```
---
## Available in Code Scope
| Variable | Type | Description |
|---|---|---|
| `data` | Object | The `data` field from your JSON (or `{}` if omitted) |
| `mcp` | Object | Bridge to the host: `sendMessage(text)`, `callTool(name, args)`, `resize(height)` |
| `root` | Element | The `#root` DOM element — your render target |
| `Chart` | Class | Chart.js v4 constructor |
| `L` | Object | Leaflet.js v1.9 namespace |
**`mcp.sendMessage(text)`** — sends a user message back to the AI, enabling click-to-explore.
**`mcp.callTool(name, args)`** — calls an MCP tool and returns a Promise.
**`mcp.resize(height)`** — resizes the iframe (max 800px).
---
## Example Patterns
### 1. Comparison Table (HTML + Tabler CSS)
````
```visual
{
"title": "Hybrid Cars Under $10K",
"height": 280,
"data": {
"items": [
{ "model": "Toyota Prius", "year": 2015, "price": "$8,500", "miles": "72,000", "mpg": 50, "source": "https://cargurus.com" },
{ "model": "Honda Civic Hybrid", "year": 2014, "price": "$7,200", "miles": "89,000", "mpg": 44, "source": "https://autotrader.com" },
{ "model": "Toyota Camry Hybrid", "year": 2013, "price": "$9,400", "miles": "95,000", "mpg": 41, "source": "https://cars.com" },
{ "model": "Ford Fusion Hybrid", "year": 2014, "price": "$8,100", "miles": "81,000", "mpg": 47, "source": "https://cargurus.com" }
]
},
"code": "<div class='theme-dark' style='background:transparent;padding:8px'><div class='card' style='background:#1a1f2e;border-color:#2d3561'><div class='card-body p-2'><div class='table-responsive'><table class='table table-dark table-hover table-sm mb-0'><thead><tr><th>Model</th><th>Year</th><th style='color:#7af7a2'>Price</th><th>Miles</th><th>MPG</th><th>Source</th></tr></thead><tbody id='rows'></tbody></table></div></div></div></div><script>var tbody=document.getElementById('rows');data.items.forEach(function(car){var tr=document.createElement('tr');tr.innerHTML='<td>'+car.model+'</td><td>'+car.year+'</td><td style=\"color:#7af7a2\">'+car.price+'</td><td>'+car.miles+'</td><td>'+car.mpg+'</td><td><a href=\"'+car.source+'\" style=\"color:#7aa2f7\" target=\"_blank\">link</a></td>';tbody.appendChild(tr);});</script>"
}
```
````
---
### 2. Bar Chart (Plain JS + Chart.js)
````
```visual
{
"title": "Price Comparison by Model",
"height": 380,
"data": {
"labels": ["Prius 2015", "Civic Hybrid 2014", "Camry Hybrid 2013", "Fusion Hybrid 2014"],
"values": [8500, 7200, 9400, 8100]
},
"code": "var canvas = document.createElement('canvas'); root.appendChild(canvas); new Chart(canvas, { type: 'bar', data: { labels: data.labels, datasets: [{ label: 'Price ($)', data: data.values, backgroundColor: 'rgba(122,162,247,0.7)', borderColor: 'rgba(122,162,247,1)', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { labels: { color: '#c0c0d0' } } }, scales: { x: { ticks: { color: '#8888aa' }, grid: { color: '#1e2a4a' } }, y: { ticks: { color: '#8888aa' }, grid: { color: '#1e2a4a' } } }, onClick: function(evt, elements) { if (elements.length > 0) { var label = data.labels[elements[0].index]; mcp.sendMessage('Tell me more about ' + label); } } } });"
}
```
````
---
### 3. Location Map (Plain JS + Leaflet)
````
```visual
{
"title": "Dealers Near Woodinville, WA",
"height": 380,
"data": {
"center": [47.754, -122.163],
"zoom": 11,
"markers": [
{ "lat": 47.754, "lng": -122.163, "title": "Woodinville Auto Center", "description": "Large hybrid selection", "price": "$7,500$11,000" },
{ "lat": 47.770, "lng": -122.205, "title": "Kirkland Motors", "description": "Certified pre-owned", "price": "$8,000$12,000" },
{ "lat": 47.720, "lng": -122.130, "title": "Redmond Auto Group", "description": "Best for Prius models", "price": "$7,200$10,500" }
]
},
"code": "root.style.height = '340px'; var map = L.map(root).setView(data.center, data.zoom); L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '&copy; CARTO', subdomains: 'abcd', maxZoom: 19 }).addTo(map); var bounds = []; data.markers.forEach(function(m) { var marker = L.marker([m.lat, m.lng]).addTo(map); marker.bindPopup('<b>' + m.title + '</b><br>' + m.description + '<br><span style=\"color:#7af7a2\">' + m.price + '</span>'); marker.on('click', function() { mcp.sendMessage('Tell me about ' + m.title); }); bounds.push([m.lat, m.lng]); }); if (bounds.length > 1) { map.fitBounds(bounds, { padding: [20, 20] }); }"
}
```
````
---
### 4. React Dashboard (JSX)
````
```visual
{
"title": "Research Summary Dashboard",
"height": 450,
"data": {
"cars": [
{ "model": "Toyota Prius 2015", "price": "$8,500", "priceNum": 8500, "miles": 72000, "mpg": 50 },
{ "model": "Honda Civic Hybrid 2014", "price": "$7,200", "priceNum": 7200, "miles": 89000, "mpg": 44 },
{ "model": "Toyota Camry Hybrid 2013", "price": "$9,400", "priceNum": 9400, "miles": 95000, "mpg": 41 },
{ "model": "Ford Fusion Hybrid 2014", "price": "$8,100", "priceNum": 8100, "miles": 81000, "mpg": 47 }
]
},
"code": "function StatCard({ label, value }) { return React.createElement('div', { className: 'col-4' }, React.createElement('div', { className: 'card', style: { background: '#1a1f2e', borderColor: '#2d3561' } }, React.createElement('div', { className: 'card-body text-center p-2' }, React.createElement('div', { style: { fontSize: '1.5rem', fontWeight: 'bold', color: '#7af7a2' } }, value), React.createElement('div', { style: { fontSize: '0.75rem', color: '#8888aa' } }, label)))); } function App() { var [sortKey, setSortKey] = React.useState('price'); var cars = data.cars.slice().sort(function(a, b) { return sortKey === 'price' ? a.priceNum - b.priceNum : a.miles - b.miles; }); var avgPrice = Math.round(data.cars.reduce(function(s, c) { return s + c.priceNum; }, 0) / data.cars.length); var minP = Math.min.apply(null, data.cars.map(function(c) { return c.priceNum; })); var maxP = Math.max.apply(null, data.cars.map(function(c) { return c.priceNum; })); return React.createElement('div', { className: 'theme-dark', style: { background: 'transparent', padding: '8px', color: '#e0e0e0' } }, React.createElement('div', { className: 'row g-2 mb-3' }, React.createElement(StatCard, { label: 'Options Found', value: data.cars.length }), React.createElement(StatCard, { label: 'Avg Price', value: '$' + avgPrice.toLocaleString() }), React.createElement(StatCard, { label: 'Price Range', value: '$' + (minP/1000).toFixed(1) + 'k$' + (maxP/1000).toFixed(1) + 'k' })), React.createElement('div', { className: 'mb-2' }, React.createElement('span', { style: { color: '#8888aa', marginRight: '8px' } }, 'Sort:'), React.createElement('button', { className: 'btn btn-sm ' + (sortKey === 'price' ? 'btn-primary' : 'btn-secondary'), style: { marginRight: '6px' }, onClick: function() { setSortKey('price'); } }, 'Price'), React.createElement('button', { className: 'btn btn-sm ' + (sortKey === 'miles' ? 'btn-primary' : 'btn-secondary'), onClick: function() { setSortKey('miles'); } }, 'Miles')), React.createElement('table', { className: 'table table-dark table-hover table-sm' }, React.createElement('thead', null, React.createElement('tr', null, React.createElement('th', null, 'Model'), React.createElement('th', null, 'Price'), React.createElement('th', null, 'Miles'), React.createElement('th', null, 'MPG'))), React.createElement('tbody', null, cars.map(function(c, i) { return React.createElement('tr', { key: i }, React.createElement('td', null, c.model), React.createElement('td', { style: { color: '#7af7a2' } }, c.price), React.createElement('td', null, c.miles.toLocaleString()), React.createElement('td', null, c.mpg)); })))); } ReactDOM.createRoot(root).render(React.createElement(App));"
}
```
````
---
### 5. Decision Matrix (HTML + interactive weight sliders)
Interactive weight sliders recalculate scores in real time; the best option is highlighted green.
````
```visual
{
"title": "Car Decision Matrix",
"height": 400,
"data": {
"criteria": [
{ "name": "Price", "weight": 8 },
{ "name": "MPG", "weight": 7 },
{ "name": "Reliability", "weight": 9 },
{ "name": "Cargo", "weight": 5 },
{ "name": "Comfort", "weight": 6 }
],
"options": [
{ "name": "Prius 2015", "scores": [7, 10, 9, 6, 7] },
{ "name": "Civic Hybrid 2014", "scores": [9, 8, 8, 5, 6] },
{ "name": "Camry Hybrid 2013", "scores": [5, 7, 8, 8, 9] },
{ "name": "Fusion Hybrid 2014", "scores": [7, 8, 7, 7, 8] }
]
},
"code": "<style>.matrix-table{width:100%;border-collapse:collapse;font-size:0.85rem}.matrix-table th,.matrix-table td{padding:4px 8px;border:1px solid #2d3561;text-align:center}.matrix-table th{background:#1a1f2e;color:#8888aa}.score-cell{color:#c0c0d0}.best{color:#7af7a2;font-weight:bold}.weight-slider{width:80px;accent-color:#7aa2f7}</style><div style='background:transparent;color:#e0e0e0;padding:8px'><div style='margin-bottom:12px;display:flex;flex-wrap:wrap;gap:8px' id='sliders'></div><table class='matrix-table'><thead id='thead'></thead><tbody id='tbody'></tbody></table></div><script>var weights=data.criteria.map(function(c){return c.weight;});function render(){var scores=data.options.map(function(opt){return opt.scores.reduce(function(s,sc,i){return s+sc*weights[i];},0);});var best=Math.max.apply(null,scores);var thead=document.getElementById('thead');thead.innerHTML='<tr><th>Option</th>'+data.criteria.map(function(c){return'<th>'+c.name+'</th>';}).join('')+'<th>Total</th></tr>';var tbody=document.getElementById('tbody');tbody.innerHTML=data.options.map(function(opt,oi){var total=scores[oi];var isBest=total===best;return'<tr><td>'+(isBest?'<span class=\"best\">'+opt.name+' ★</span>':opt.name)+'</td>'+opt.scores.map(function(s){return'<td class=\"score-cell\">'+s+'</td>';}).join('')+'<td class=\"'+(isBest?'best':'score-cell')+'\">'+total+'</td></tr>';}).join('');}var sliderDiv=document.getElementById('sliders');data.criteria.forEach(function(c,i){var label=document.createElement('label');label.style.cssText='display:flex;flex-direction:column;align-items:center;font-size:0.75rem;color:#8888aa';var inp=document.createElement('input');inp.type='range';inp.min=0;inp.max=10;inp.value=c.weight;inp.className='weight-slider';inp.oninput=function(){weights[i]=+this.value;render();};label.appendChild(inp);label.appendChild(document.createTextNode(c.name));sliderDiv.appendChild(label);});render();</script>"
}
```
````
---
## Constraints
- **Self-contained** — no API fetches; pass all data through the `data` field or hardcode it in `code`
- **Dark theme always** — use Tabler `.theme-dark` class, transparent background, light text (`#e0e0e0`)
- **Responsive width** — use percentages and `max-width`, never fixed pixel widths for layout
- **Max height 800px** — use `mcp.resize(height)` to auto-size after content renders
- **No external fetches** — never use `fetch()`, `XMLHttpRequest`, or dynamic script loading in `code`
- **Click-to-explore** — chart bars, table rows, and map markers should call `mcp.sendMessage(...)` so the user can drill in
---
## When to Use Visuals vs A2UI
| Need | Choice |
|---|---|
| Toggle filters for quick filtering | A2UI (`filters` block) |
| Simple form (refine search params) | A2UI (`form` block) |
| Static table with fewer than 5 rows | A2UI (`table` block) |
| Card grid of items with links | A2UI (`cards` block) |
| Interactive charts (bar, line, pie) | `visual` block |
| sortable / filterable table with many rows | `visual` block |
| Comparisons with color-coded columns | `visual` block |
| Maps with markers and click interactions | `visual` block |
| Dashboards with multiple stat cards | `visual` block |
| Decision matrices with weight sliders | `visual` block |
+26
View File
@@ -0,0 +1,26 @@
# Prerequisites before running:
#
# 1. Build the frontend:
# cd frontend && npm run build
#
# 2. Set ANTHROPIC_API_KEY in your environment:
# export ANTHROPIC_API_KEY=your-key-here
#
# 3. Generate a bcrypt password hash for AUTH_PASS_HASH:
# python -c "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
# export AUTH_PASS_HASH='$2b$12$...'
services:
workbench:
build: .
ports:
- "8080:8080" # Web UI
- "6080:6080" # noVNC browser view
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- AUTH_USER=${AUTH_USER:-admin@localhost}
- AUTH_PASS_HASH=${AUTH_PASS_HASH}
- DISPLAY=:99
volumes:
- ./artifacts:/app/artifacts # persist artifacts across restarts
restart: unless-stopped
+233
View File
@@ -0,0 +1,233 @@
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
}
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import { IconFileText, IconRefresh } from '@tabler/icons-react'
import { useArtifacts } from '../hooks/useArtifacts'
export interface ArtifactsPanelProps {
sessionId: string | null
}
export function ArtifactsPanel({ sessionId }: ArtifactsPanelProps) {
const { artifacts, activeArtifact, refresh, loadArtifact } = useArtifacts(sessionId)
useEffect(() => {
if (sessionId) {
refresh()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
if (!sessionId) {
return (
<div
style={{
padding: '2rem',
color: '#6a6a8a',
textAlign: 'center',
}}
>
Select a session to view artifacts
</div>
)
}
const count = artifacts.length
const countLabel = count === 1 ? '1 artifact' : `${count} artifacts`
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header bar */}
<div
style={{
padding: '0.5rem 0.75rem',
borderBottom: '1px solid #2a2a4a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontSize: '0.8rem', color: '#8888aa' }}>{countLabel}</span>
<button
title="Refresh"
onClick={refresh}
style={{
background: 'none',
border: 'none',
color: '#7aa2f7',
cursor: 'pointer',
}}
>
<IconRefresh size={16} />
</button>
</div>
{/* Tab strip — only when there are artifacts */}
{artifacts.length > 0 && (
<div
style={{
display: 'flex',
gap: '2px',
padding: '0.3rem 0.5rem',
overflowX: 'auto',
borderBottom: '1px solid #2a2a4a',
}}
>
{artifacts.map((artifact) => {
const isActive = activeArtifact?.name === artifact.name
return (
<button
key={artifact.name}
onClick={() => loadArtifact(artifact.name)}
style={{
background: isActive ? '#2a3a5a' : 'transparent',
color: isActive ? '#ffffff' : '#8888aa',
padding: '0.3rem 0.6rem',
borderRadius: '4px',
fontSize: '0.75rem',
whiteSpace: 'nowrap',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
}}
>
<IconFileText size={14} />
{artifact.name}
</button>
)
})}
</div>
)}
{/* Content area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem' }}>
{activeArtifact ? (
<div className="chat-message assistant">
<ReactMarkdown>{activeArtifact.content}</ReactMarkdown>
</div>
) : artifacts.length > 0 ? (
<div
style={{ color: '#6a6a8a', textAlign: 'center', paddingTop: '2rem' }}
>
Click an artifact tab to view it
</div>
) : (
<div
style={{ color: '#6a6a8a', textAlign: 'center', paddingTop: '2rem' }}
>
No artifacts yet. The researcher will create them during research.
</div>
)}
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
// BrowserPanel renders a noVNC iframe for viewing the live Playwright browser.
//
// URL detection strategy:
// - Production (cloudflared): When hostname is "research.ampbox.io", use the
// dedicated noVNC HTTPS endpoint at vnc.ampbox.io (routed via cloudflared tunnel).
// - Local dev (Docker): For any other hostname (e.g. localhost, 192.168.x.x),
// connect directly to the noVNC container on port 6080 over plain HTTP.
export interface BrowserPanelProps {
sessionId: string | null
}
function getVncUrl(): string {
const hostname = window.location.hostname
if (hostname === 'research.ampbox.io') {
return 'https://vnc.ampbox.io/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
}
return `http://${hostname}:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2`
}
export function BrowserPanel({ sessionId }: BrowserPanelProps) {
if (!sessionId) {
return (
<div
style={{
padding: '2rem',
color: '#6a6a8a',
textAlign: 'center',
}}
>
Select a session to view the browser
</div>
)
}
return (
<iframe
src={getVncUrl()}
title="Browser - noVNC"
style={{
width: '100%',
height: '100%',
border: 'none',
background: '#0a0a1a',
}}
allow="clipboard-read; clipboard-write"
/>
)
}
+161
View File
@@ -4,6 +4,8 @@ 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 } from './A2UIRenderer'
import { parseA2UIBlocks } from '../lib/a2ui'
import type { ChatMessage } from '../hooks/useChat' import type { ChatMessage } from '../hooks/useChat'
interface ChatPanelProps { interface ChatPanelProps {
@@ -13,6 +15,147 @@ 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()
// Give the iframe time to initialize, then send the render message
setTimeout(() => {
w.postMessage(
{
jsonrpc: '2.0',
method: 'render',
params: { code: payload.code, data: payload.data ?? {} },
},
'*'
)
}, 500)
})
.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 +205,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>
) )
+25
View File
@@ -0,0 +1,25 @@
import type { A2UIBlock } from './types'
/**
* Parses `a2ui` fenced code blocks out of a markdown-like content string.
* Each block must contain a valid JSON object with a `type` field.
* Invalid blocks are silently skipped.
*/
export function parseA2UIBlocks(content: string): A2UIBlock[] {
const blocks: A2UIBlock[] = []
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])
if (parsed && parsed.type) {
blocks.push(parsed as A2UIBlock)
}
} catch {
// Silently skip invalid JSON
}
}
return blocks
}
+380
View File
@@ -0,0 +1,380 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { A2UIRenderer } from '../components/A2UIRenderer'
import { parseA2UIBlocks } from '../lib/a2ui'
import type { A2UIBlock } from '../lib/types'
// Mock @tabler/icons-react so icon SVGs don't cause jsdom issues
vi.mock('@tabler/icons-react', () => ({
IconFilter: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-filter' data-size={size} data-color={color} />
),
IconTable: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-table' data-size={size} data-color={color} />
),
IconCards: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-cards' data-size={size} data-color={color} />
),
IconForms: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-forms' data-size={size} data-color={color} />
),
}))
// ─── parseA2UIBlocks ─────────────────────────────────────────────────────────
describe('parseA2UIBlocks', () => {
it('extracts a single a2ui block from content', () => {
const content = '```a2ui\n{"type":"filters","filters":[]}\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(1)
expect(blocks[0].type).toBe('filters')
})
it('extracts multiple a2ui blocks from content', () => {
const content = [
'```a2ui\n{"type":"filters","filters":[]}\n```',
' some text ',
'```a2ui\n{"type":"table","headers":[],"rows":[]}\n```',
].join('')
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(2)
expect(blocks[0].type).toBe('filters')
expect(blocks[1].type).toBe('table')
})
it('silently skips invalid JSON', () => {
const content = '```a2ui\nnot valid json\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(0)
})
it('skips blocks without a type field', () => {
const content = '```a2ui\n{"data":"no type"}\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(0)
})
it('returns empty array when no a2ui blocks present', () => {
const content = 'plain text with ```js\nsome code\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(0)
})
it('returns the parsed block as A2UIBlock with all fields', () => {
const json = JSON.stringify({
type: 'table',
headers: ['Name', 'Price'],
rows: [['Apple', '$1']],
})
const content = `\`\`\`a2ui\n${json}\n\`\`\``
const blocks = parseA2UIBlocks(content)
const block = blocks[0] as import('../lib/types').A2UITableBlock
expect(block.headers).toEqual(['Name', 'Price'])
expect(block.rows).toEqual([['Apple', '$1']])
})
})
// ─── A2UIRenderer — filters ─────────────────────────────────────────────────
describe('A2UIRenderer — filters', () => {
const filtersBlock: A2UIBlock = {
type: 'filters',
filters: [
{ label: 'In Stock', value: 'in_stock' },
{ label: 'On Sale', value: 'on_sale' },
],
}
it('renders inside a tool-call-card container', () => {
const { container } = render(<A2UIRenderer block={filtersBlock} />)
expect(container.querySelector('.tool-call-card')).toBeTruthy()
})
it('renders the IconFilter icon', () => {
render(<A2UIRenderer block={filtersBlock} />)
expect(screen.getByTestId('icon-filter')).toBeTruthy()
})
it('renders a button for each filter option', () => {
const { container } = render(<A2UIRenderer block={filtersBlock} />)
const buttons = container.querySelectorAll('button')
expect(buttons).toHaveLength(2)
})
it('renders filter label text in buttons', () => {
render(<A2UIRenderer block={filtersBlock} />)
expect(screen.getByText('In Stock')).toBeTruthy()
expect(screen.getByText('On Sale')).toBeTruthy()
})
it('calls onAction with filter_toggle and label when button clicked', () => {
const onAction = vi.fn()
render(<A2UIRenderer block={filtersBlock} onAction={onAction} />)
fireEvent.click(screen.getByText('In Stock'))
expect(onAction).toHaveBeenCalledWith('filter_toggle', {
label: 'In Stock',
active: true,
})
})
it('toggles active state on second click (active: false)', () => {
const onAction = vi.fn()
render(<A2UIRenderer block={filtersBlock} onAction={onAction} />)
fireEvent.click(screen.getByText('In Stock'))
fireEvent.click(screen.getByText('In Stock'))
expect(onAction).toHaveBeenLastCalledWith('filter_toggle', {
label: 'In Stock',
active: false,
})
})
it('does not throw when onAction is not provided', () => {
render(<A2UIRenderer block={filtersBlock} />)
expect(() => fireEvent.click(screen.getByText('In Stock'))).not.toThrow()
})
})
// ─── A2UIRenderer — table ───────────────────────────────────────────────────
describe('A2UIRenderer — table', () => {
const tableBlock: A2UIBlock = {
type: 'table',
headers: ['Name', 'Price', 'Stock'],
rows: [
['Apple', '$1.00', '50'],
['Banana', '$0.50', '100'],
],
}
it('renders inside a tool-call-card container', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
expect(container.querySelector('.tool-call-card')).toBeTruthy()
})
it('renders the IconTable icon', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByTestId('icon-table')).toBeTruthy()
})
it('renders "Data Table" header label', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByText('Data Table')).toBeTruthy()
})
it('renders a <table> element', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
expect(container.querySelector('table')).toBeTruthy()
})
it('renders all column headers', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByText('Name')).toBeTruthy()
expect(screen.getByText('Price')).toBeTruthy()
expect(screen.getByText('Stock')).toBeTruthy()
})
it('renders all row data', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByText('Apple')).toBeTruthy()
expect(screen.getByText('$1.00')).toBeTruthy()
expect(screen.getByText('Banana')).toBeTruthy()
})
it('renders correct number of th elements', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
expect(container.querySelectorAll('th')).toHaveLength(3)
})
it('renders correct number of td elements', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
// 2 rows × 3 cols = 6 td
expect(container.querySelectorAll('td')).toHaveLength(6)
})
})
// ─── A2UIRenderer — cards ───────────────────────────────────────────────────
describe('A2UIRenderer — cards', () => {
const cardsBlock: A2UIBlock = {
type: 'cards',
items: [
{
title: 'Widget Pro',
description: 'Best widget ever',
price: '$29.99',
url: 'https://example.com/widget',
},
{ title: 'Gadget Basic', description: 'Simple gadget' },
],
}
it('renders a grid container with multiple tool-call-card elements', () => {
const { container } = render(<A2UIRenderer block={cardsBlock} />)
const cards = container.querySelectorAll('.tool-call-card')
expect(cards.length).toBeGreaterThanOrEqual(2)
})
it('renders each card title', () => {
render(<A2UIRenderer block={cardsBlock} />)
expect(screen.getByText('Widget Pro')).toBeTruthy()
expect(screen.getByText('Gadget Basic')).toBeTruthy()
})
it('renders price when present', () => {
render(<A2UIRenderer block={cardsBlock} />)
expect(screen.getByText('$29.99')).toBeTruthy()
})
it('renders description when present', () => {
render(<A2UIRenderer block={cardsBlock} />)
expect(screen.getByText('Best widget ever')).toBeTruthy()
})
it('opens url in new tab on click when url is present', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(<A2UIRenderer block={cardsBlock} />)
fireEvent.click(screen.getByText('Widget Pro'))
expect(openSpy).toHaveBeenCalledWith('https://example.com/widget', '_blank')
openSpy.mockRestore()
})
it('does not set cursor pointer on cards without url', () => {
const { container } = render(<A2UIRenderer block={cardsBlock} />)
// Find the card for "Gadget Basic" — the second tool-call-card
const cards = container.querySelectorAll('.tool-call-card')
const gadgetCard = Array.from(cards).find(
card => card.textContent?.includes('Gadget Basic')
)
expect(gadgetCard).toBeTruthy()
expect((gadgetCard as HTMLElement).style.cursor).not.toBe('pointer')
})
})
// ─── A2UIRenderer — form ────────────────────────────────────────────────────
describe('A2UIRenderer — form', () => {
const formBlock: A2UIBlock = {
type: 'form',
submitLabel: 'Search',
fields: [
{ name: 'query', type: 'text', label: 'Search Query' },
{ name: 'max_price', type: 'number', label: 'Max Price' },
{
name: 'category',
type: 'select',
label: 'Category',
options: [
{ label: 'Electronics', value: 'electronics' },
{ label: 'Books', value: 'books' },
],
},
],
}
it('renders inside a tool-call-card container', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
expect(container.querySelector('.tool-call-card')).toBeTruthy()
})
it('renders the IconForms icon', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByTestId('icon-forms')).toBeTruthy()
})
it('renders "Input Form" header label', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Input Form')).toBeTruthy()
})
it('renders a <form> element', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
expect(container.querySelector('form')).toBeTruthy()
})
it('renders labels for each field', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Search Query')).toBeTruthy()
expect(screen.getByText('Max Price')).toBeTruthy()
expect(screen.getByText('Category')).toBeTruthy()
})
it('renders text input for text field type', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
const textInput = container.querySelector('input[type="text"]')
expect(textInput).toBeTruthy()
})
it('renders number input for number field type', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
const numInput = container.querySelector('input[type="number"]')
expect(numInput).toBeTruthy()
})
it('renders a <select> for select field type', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
expect(container.querySelector('select')).toBeTruthy()
})
it('renders select options', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Electronics')).toBeTruthy()
expect(screen.getByText('Books')).toBeTruthy()
})
it('renders submit button with submitLabel text', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Search')).toBeTruthy()
})
it('uses "Submit" as default submit label when submitLabel absent', () => {
const blockWithoutLabel: A2UIBlock = {
type: 'form',
fields: [{ name: 'q', type: 'text' }],
submitLabel: '',
}
render(<A2UIRenderer block={blockWithoutLabel} />)
expect(screen.getByText('Submit')).toBeTruthy()
})
it('uses field.name as label when field.label is absent', () => {
const blockNoLabel: A2UIBlock = {
type: 'form',
fields: [{ name: 'my_field', type: 'text' }],
submitLabel: 'Go',
}
render(<A2UIRenderer block={blockNoLabel} />)
expect(screen.getByText('my_field')).toBeTruthy()
})
it('calls onAction with form_submit and form data on submit', () => {
const onAction = vi.fn()
const { container } = render(
<A2UIRenderer block={formBlock} onAction={onAction} />
)
const form = container.querySelector('form')!
fireEvent.submit(form)
expect(onAction).toHaveBeenCalledWith(
'form_submit',
expect.any(Object)
)
})
it('prevents default form submission', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
const form = container.querySelector('form')!
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
form.dispatchEvent(submitEvent)
expect(submitEvent.defaultPrevented).toBe(true)
})
})
// ─── A2UIRenderer — default / unknown type ──────────────────────────────────
describe('A2UIRenderer — default', () => {
it('returns null for unknown block type', () => {
// Cast to bypass TypeScript — simulates runtime unknown type
const unknown = { type: 'unknown' } as unknown as A2UIBlock
const { container } = render(<A2UIRenderer block={unknown} />)
expect(container.firstChild).toBeNull()
})
})
+283
View File
@@ -0,0 +1,283 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ArtifactContent, Artifact } from '../lib/types'
// Must be hoisted before imports
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <div>{children}</div>,
}))
vi.mock('../hooks/useArtifacts')
import { ArtifactsPanel } from '../components/ArtifactsPanel'
import { useArtifacts } from '../hooks/useArtifacts'
const mockUseArtifacts = vi.mocked(useArtifacts)
const mockRefresh = vi.fn()
const mockLoadArtifact = vi.fn()
function makeHookReturn(
artifacts: Artifact[] = [],
activeArtifact: ArtifactContent | null = null
) {
return {
artifacts,
activeArtifact,
refresh: mockRefresh,
loadArtifact: mockLoadArtifact,
}
}
describe('ArtifactsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseArtifacts.mockReturnValue(makeHookReturn())
})
// ─── No session placeholder ───────────────────────────────────────────
describe('no-session placeholder', () => {
it('renders placeholder text when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(screen.getByText('Select a session to view artifacts')).toBeTruthy()
})
it('placeholder has padding 2rem', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.padding).toBe('2rem')
})
it('placeholder has color #6a6a8a (rgb(106, 106, 138))', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.color).toBe('rgb(106, 106, 138)')
})
it('placeholder is text-centered', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.textAlign).toBe('center')
})
it('does not call refresh when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(mockRefresh).not.toHaveBeenCalled()
})
})
// ─── With session — useEffect ─────────────────────────────────────────
describe('with sessionId — useEffect', () => {
it('calls refresh when sessionId is set', async () => {
await act(async () => {
render(<ArtifactsPanel sessionId="sess-1" />)
})
expect(mockRefresh).toHaveBeenCalledTimes(1)
})
it('does not call refresh when sessionId is null', async () => {
await act(async () => {
render(<ArtifactsPanel sessionId={null} />)
})
expect(mockRefresh).not.toHaveBeenCalled()
})
})
// ─── Header bar ───────────────────────────────────────────────────────
describe('header bar', () => {
it('shows "0 artifacts" when artifacts is empty', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('0 artifacts')).toBeTruthy()
})
it('shows "1 artifact" (singular) when there is one artifact', () => {
mockUseArtifacts.mockReturnValue(
makeHookReturn([{ name: 'report.md', session_id: 'sess-1', size: 100 }])
)
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('1 artifact')).toBeTruthy()
})
it('shows "2 artifacts" (plural) when there are two artifacts', () => {
mockUseArtifacts.mockReturnValue(
makeHookReturn([
{ name: 'report.md', session_id: 'sess-1', size: 100 },
{ name: 'summary.md', session_id: 'sess-1', size: 200 },
])
)
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('2 artifacts')).toBeTruthy()
})
it('renders Refresh button with title "Refresh"', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByTitle('Refresh')).toBeTruthy()
})
it('Refresh button has color #7aa2f7 (rgb(122, 162, 247))', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
const btn = screen.getByTitle('Refresh') as HTMLButtonElement
expect(btn.style.color).toBe('rgb(122, 162, 247)')
})
it('Refresh button has no background', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
const btn = screen.getByTitle('Refresh') as HTMLButtonElement
// background: 'none' normalized as empty string or 'none'
expect(
btn.style.background === 'none' || btn.style.background === ''
).toBe(true)
})
it('clicking Refresh button calls refresh()', async () => {
render(<ArtifactsPanel sessionId="sess-1" />)
await userEvent.click(screen.getByTitle('Refresh'))
// refresh is also called on mount, so check at least once from click
expect(mockRefresh).toHaveBeenCalled()
})
})
// ─── No artifacts empty state ─────────────────────────────────────────
describe('no artifacts empty state', () => {
it('shows "No artifacts yet" message when artifacts array is empty', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(
screen.getByText(
'No artifacts yet. The researcher will create them during research.'
)
).toBeTruthy()
})
it('does not render tab strip when there are no artifacts', () => {
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
// No buttons other than Refresh should be present
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(1) // only the Refresh button
})
})
// ─── Tab strip ────────────────────────────────────────────────────────
describe('tab strip', () => {
const artifacts: Artifact[] = [
{ name: 'report.md', session_id: 'sess-1', size: 100 },
{ name: 'summary.md', session_id: 'sess-1', size: 200 },
]
it('renders a tab button for each artifact', () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts))
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
const buttons = container.querySelectorAll('button')
// Refresh + 2 artifact tabs
expect(buttons.length).toBe(3)
})
it('each tab shows the artifact name', () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts))
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('report.md')).toBeTruthy()
expect(screen.getByText('summary.md')).toBeTruthy()
})
it('clicking a tab calls loadArtifact with the artifact name', async () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts))
render(<ArtifactsPanel sessionId="sess-1" />)
await userEvent.click(screen.getByText('report.md'))
expect(mockLoadArtifact).toHaveBeenCalledWith('report.md')
})
it('active tab has background #2a3a5a and color #ffffff', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Report',
}
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, activeArtifact))
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
const buttons = container.querySelectorAll('button')
// buttons[0] is Refresh, buttons[1] is first artifact tab
const activeTab = buttons[1] as HTMLElement
expect(activeTab.style.background).toBe('rgb(42, 58, 90)')
expect(activeTab.style.color).toBe('rgb(255, 255, 255)')
})
it('inactive tab has transparent background and color #8888aa', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Report',
}
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, activeArtifact))
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
const buttons = container.querySelectorAll('button')
// buttons[2] is the second (inactive) artifact tab
const inactiveTab = buttons[2] as HTMLElement
expect(inactiveTab.style.color).toBe('rgb(136, 136, 170)')
})
it('shows "Click an artifact tab to view it" when artifacts exist but none selected', () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, null))
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('Click an artifact tab to view it')).toBeTruthy()
})
})
// ─── Content area with active artifact ───────────────────────────────
describe('content area — active artifact', () => {
it('renders the artifact content inside ReactMarkdown', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Research Report\n\nFindings here.',
}
mockUseArtifacts.mockReturnValue(
makeHookReturn(
[{ name: 'report.md', session_id: 'sess-1', size: 100 }],
activeArtifact
)
)
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
// The mock renders children in a div; check text content directly to handle newlines
const contentWrapper = container.querySelector('.chat-message.assistant') as HTMLElement
expect(contentWrapper).toBeTruthy()
expect(contentWrapper.textContent).toContain('# Research Report')
expect(contentWrapper.textContent).toContain('Findings here.')
})
it('renders content inside a div with class "chat-message assistant"', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Report',
}
mockUseArtifacts.mockReturnValue(
makeHookReturn(
[{ name: 'report.md', session_id: 'sess-1', size: 100 }],
activeArtifact
)
)
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
expect(container.querySelector('.chat-message.assistant')).toBeTruthy()
})
})
// ─── useArtifacts integration ─────────────────────────────────────────
describe('useArtifacts integration', () => {
it('passes sessionId to useArtifacts', () => {
render(<ArtifactsPanel sessionId="sess-42" />)
expect(mockUseArtifacts).toHaveBeenCalledWith('sess-42')
})
it('passes null to useArtifacts when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(mockUseArtifacts).toHaveBeenCalledWith(null)
})
})
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserPanel } from '../components/BrowserPanel'
describe('BrowserPanel', () => {
const originalLocation = window.location
beforeEach(() => {
// Reset location before each test
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: 'localhost' },
})
})
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
})
describe('placeholder (no sessionId)', () => {
it('renders placeholder text when sessionId is null', () => {
render(<BrowserPanel sessionId={null} />)
expect(screen.getByText('Select a session to view the browser')).toBeTruthy()
})
it('does not render an iframe when sessionId is null', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
expect(container.querySelector('iframe')).toBeNull()
})
it('placeholder div has padding 2rem', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.padding).toBe('2rem')
})
it('placeholder div text has color #6a6a8a', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
// jsdom normalizes hex colors to rgb() — #6a6a8a = rgb(106, 106, 138)
expect(div.style.color).toBe('rgb(106, 106, 138)')
})
it('placeholder div text is centered', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.textAlign).toBe('center')
})
})
describe('iframe (with sessionId)', () => {
it('renders an iframe when sessionId is provided', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
expect(container.querySelector('iframe')).toBeTruthy()
})
it('does not render placeholder text when sessionId is provided', () => {
render(<BrowserPanel sessionId="session-abc" />)
expect(screen.queryByText('Select a session to view the browser')).toBeNull()
})
it('iframe has title "Browser - noVNC"', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.title).toBe('Browser - noVNC')
})
it('iframe has width 100%', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.style.width).toBe('100%')
})
it('iframe has height 100%', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.style.height).toBe('100%')
})
it('iframe has no border', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
// jsdom expands border shorthand — check borderStyle which carries the 'none' value
expect(iframe.style.borderStyle).toBe('none')
})
it('iframe has dark background color #0a0a1a', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
// jsdom normalizes hex colors to rgb() — #0a0a1a = rgb(10, 10, 26)
expect(iframe.style.background).toBe('rgb(10, 10, 26)')
})
it('iframe has clipboard allow attribute', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.getAttribute('allow')).toBe('clipboard-read; clipboard-write')
})
})
describe('getVncUrl() — URL detection', () => {
it('returns cloudflared HTTPS URL when hostname is research.ampbox.io', () => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: 'research.ampbox.io' },
})
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toBe(
'https://vnc.ampbox.io/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
)
})
it('returns localhost http URL with port 6080 when hostname is localhost', () => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: 'localhost' },
})
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toBe(
'http://localhost:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
)
})
it('returns http URL with other hostname when not research.ampbox.io', () => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: '192.168.1.100' },
})
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toBe(
'http://192.168.1.100:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
)
})
it('URL includes autoconnect=true query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('autoconnect=true')
})
it('URL includes resize=remote query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('resize=remote')
})
it('URL includes quality=6 query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('quality=6')
})
it('URL includes compression=2 query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('compression=2')
})
})
})
+163 -2
View File
@@ -15,9 +15,55 @@ 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 lib/a2ui parseA2UIBlocks parser
vi.mock('../lib/a2ui', () => ({
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
}),
}))
// Mock A2UIRenderer component
vi.mock('../components/A2UIRenderer', () => ({
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 +327,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()
}) })
}) })
+2 -2
View File
@@ -60,12 +60,12 @@ check_contains "$A2UI" "a2ui fenced code block" '```a2ui'
echo "" echo ""
echo "=== Checking a2ui-format.md: block type 'filters' ===" echo "=== Checking a2ui-format.md: block type 'filters' ==="
check_contains "$A2UI" "filters type" '"type": "filters"' check_contains "$A2UI" "filters type" '"type": "filters"'
check_contains "$A2UI" "filters options array" '"options"' check_contains "$A2UI" "filters array key" '"filters"'
check_contains "$A2UI" "Toyota option" '"Toyota"' check_contains "$A2UI" "Toyota option" '"Toyota"'
check_contains "$A2UI" "Honda option" '"Honda"' check_contains "$A2UI" "Honda option" '"Honda"'
check_contains "$A2UI" "Hyundai option" '"Hyundai"' check_contains "$A2UI" "Hyundai option" '"Hyundai"'
check_contains "$A2UI" "Under \$5K option" '"Under $5K"' check_contains "$A2UI" "Under \$5K option" '"Under $5K"'
check_contains "$A2UI" "active field" '"active"' check_contains "$A2UI" "value field" '"value"'
echo "" echo ""
echo "=== Checking a2ui-format.md: block type 'table' ===" echo "=== Checking a2ui-format.md: block type 'table' ==="
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# test_docker_compose.sh - Verify docker-compose.yml matches spec for task-12
# RED: Run before docker-compose.yml exists (should fail)
# GREEN: Run after docker-compose.yml is created (should pass)
REPO=/home/ken/workspace/research-workbench
COMPOSE="$REPO/docker-compose.yml"
PASS=0
FAIL=0
check_file() {
if [ -f "$1" ]; then
echo "PASS: file '$1' exists"
((PASS++))
else
echo "FAIL: file '$1' should exist but is missing"
((FAIL++))
fi
}
check_contains() {
local pattern="$1"
local label="$2"
if grep -qF -- "$pattern" "$COMPOSE" 2>/dev/null; then
echo "PASS: docker-compose.yml contains $label"
((PASS++))
else
echo "FAIL: docker-compose.yml missing $label"
((FAIL++))
fi
}
check_regex() {
local pattern="$1"
local label="$2"
if grep -qE "$pattern" "$COMPOSE" 2>/dev/null; then
echo "PASS: docker-compose.yml contains $label"
((PASS++))
else
echo "FAIL: docker-compose.yml missing $label"
((FAIL++))
fi
}
echo "=== Checking docker-compose.yml exists ==="
check_file "$COMPOSE"
echo ""
echo "=== Checking prerequisite comments ==="
check_contains "npm run build" "frontend build prerequisite comment"
check_contains "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY prerequisite comment"
check_contains "bcrypt" "bcrypt password hash prerequisite comment"
echo ""
echo "=== Checking services ==="
check_contains "services:" "services section"
check_contains "workbench:" "workbench service"
echo ""
echo "=== Checking build ==="
check_regex "build: \." "build: ."
echo ""
echo "=== Checking ports ==="
check_contains "8080:8080" "port 8080:8080 (Web UI)"
check_contains "6080:6080" "port 6080:6080 (noVNC browser view)"
echo ""
echo "=== Checking environment variables ==="
check_regex "ANTHROPIC_API_KEY=\\\$\{ANTHROPIC_API_KEY\}" "ANTHROPIC_API_KEY env var"
check_regex "AUTH_USER=\\\$\{AUTH_USER:-admin@localhost\}" "AUTH_USER env var with default"
check_regex "AUTH_PASS_HASH=\\\$\{AUTH_PASS_HASH\}" "AUTH_PASS_HASH env var"
check_contains "DISPLAY=:99" "DISPLAY=:99 env var"
echo ""
echo "=== Checking volumes ==="
check_contains "./artifacts:/app/artifacts" "artifacts volume"
echo ""
echo "=== Checking restart policy ==="
check_contains "restart: unless-stopped" "restart unless-stopped"
echo ""
echo "=============================="
echo "Results: $PASS passed, $FAIL failed"
echo "=============================="
[ $FAIL -eq 0 ] && exit 0 || exit 1
+3 -3
View File
@@ -128,17 +128,17 @@ check_regex "user can take over via VNC" "VNC|vnc"
echo "" echo ""
echo "=== Checking body: A2UI section ===" echo "=== Checking body: A2UI section ==="
check_contains "A2UI section header" "A2UI" check_contains "A2UI section header" "A2UI"
check_contains "a2ui-format.md @mention" "bundle/context/a2ui-format.md" check_contains "a2ui-format @mention" "bundle/context/a2ui-format"
echo "" echo ""
echo "=== Checking body: Artifacts section ===" echo "=== Checking body: Artifacts section ==="
check_contains "Artifacts section header" "Artifacts" check_contains "Artifacts section header" "Artifacts"
check_contains "artifact-conventions.md @mention" "bundle/context/artifact-conventions.md" check_contains "artifact-conventions @mention" "bundle/context/artifact-conventions"
echo "" echo ""
echo "=== Checking body: Inline Visuals section ===" echo "=== Checking body: Inline Visuals section ==="
check_contains "Inline Visuals section header" "Inline Visuals" check_contains "Inline Visuals section header" "Inline Visuals"
check_contains "visual-artifacts.md @mention" "bundle/context/visual-artifacts.md" check_contains "visual-artifacts @mention" "bundle/context/visual-artifacts"
check_regex "proactive visual generation mention" "proactive|visual.*generation|comparison.*table|chart" check_regex "proactive visual generation mention" "proactive|visual.*generation|comparison.*table|chart"
echo "" echo ""
+307
View File
@@ -0,0 +1,307 @@
"""
Tests for bundle/apps/runtime.html
Verifies the visual artifact runtime HTML file exists with all required
CDN references, script structures, and message protocol implementations
as specified in task-5.
"""
import os
import pytest
HTML_FILE = os.path.join(
os.path.dirname(__file__),
"..",
"bundle",
"apps",
"runtime.html",
)
@pytest.fixture(scope="module")
def html_content():
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
with open(HTML_FILE) as f:
return f.read()
class TestFileExists:
def test_runtime_html_exists(self):
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
def test_is_complete_html_document(self, html_content):
assert (
"<!DOCTYPE html>" in html_content
or "<!doctype html>" in html_content.lower()
)
assert "<html" in html_content
assert "</html>" in html_content
class TestCDNLibraries:
"""Verify all required CDN libraries are present in the head section."""
def test_tabler_css_cdn(self, html_content):
assert (
"https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css"
in html_content
)
def test_leaflet_css_cdn(self, html_content):
assert (
"https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" in html_content
)
def test_chartjs_cdn(self, html_content):
assert "https://cdn.jsdelivr.net/npm/chart.js@4" in html_content
def test_leaflet_js_cdn(self, html_content):
assert (
"https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js" in html_content
)
def test_react_19_umd(self, html_content):
# React 19 production UMD
assert "react@19" in html_content
assert (
"umd" in html_content.lower()
or "unpkg.com" in html_content
or "cdn.jsdelivr.net" in html_content
)
def test_react_dom_19_umd(self, html_content):
# ReactDOM 19 production UMD
assert "react-dom@19" in html_content
def test_babel_standalone_7(self, html_content):
assert "@babel/standalone@7" in html_content
assert "babel.min.js" in html_content
class TestStyles:
"""Verify required CSS styles."""
def test_transparent_body_background(self, html_content):
# transparent bg
assert "transparent" in html_content
def test_dark_color_scheme(self, html_content):
assert (
"color-scheme: dark" in html_content or "color-scheme:dark" in html_content
)
def test_body_padding_16px(self, html_content):
assert "16px" in html_content
def test_visual_error_class(self, html_content):
assert ".visual-error" in html_content
def test_visual_error_red_color(self, html_content):
# red text color for error class
assert (
"#ff" in html_content.lower()
or "red" in html_content
or "color: #" in html_content
)
def test_visual_error_monospace(self, html_content):
assert "monospace" in html_content
def test_visual_error_pre_wrap(self, html_content):
assert "pre-wrap" in html_content
class TestBodyStructure:
"""Verify body has root div."""
def test_root_div_exists(self, html_content):
assert '<div id="root">' in html_content or "<div id='root'>" in html_content
class TestMcpBridge:
"""Verify mcpBridge object implementation."""
def test_mcp_bridge_declared(self, html_content):
assert "mcpBridge" in html_content
def test_pending_map(self, html_content):
assert "_pending" in html_content
assert "Map" in html_content
def test_send_to_host_method(self, html_content):
assert "sendToHost" in html_content
assert "window.parent.postMessage" in html_content
def test_call_tool_method(self, html_content):
assert "callTool" in html_content
def test_call_tool_jsonrpc_20(self, html_content):
# jsonrpc key may appear as unquoted JS property (jsonrpc:) or quoted ("jsonrpc" / 'jsonrpc')
assert (
'"jsonrpc"' in html_content
or "'jsonrpc'" in html_content
or "jsonrpc:" in html_content
)
assert '"2.0"' in html_content or "'2.0'" in html_content
def test_call_tool_method_name(self, html_content):
assert "tools/call" in html_content
def test_call_tool_returns_promise(self, html_content):
assert "Promise" in html_content
def test_call_tool_uses_uuid(self, html_content):
# Random UUID for call id
assert (
"crypto" in html_content
or "Math.random" in html_content
or "uuid" in html_content.lower()
)
def test_send_message_method(self, html_content):
assert "sendMessage" in html_content
assert "ui/sendMessage" in html_content
def test_send_message_role_user(self, html_content):
assert "role" in html_content
assert "user" in html_content
def test_resize_method(self, html_content):
assert "resize" in html_content
assert "ui/resize" in html_content
def test_resize_capped_at_800(self, html_content):
assert "800" in html_content
assert "Math.min" in html_content
class TestExecuteVisual:
"""Verify executeVisual function implementation."""
def test_execute_visual_function(self, html_content):
assert "executeVisual" in html_content
def test_sets_visual_data_global(self, html_content):
assert "__VISUAL_DATA__" in html_content
def test_sets_mcp_global(self, html_content):
assert "__MCP__" in html_content
def test_jsx_detection_capital_tag_regex(self, html_content):
# Regex for JSX detection: /<[A-Z]/
assert "<[A-Z]" in html_content
def test_jsx_detection_react_create_element(self, html_content):
assert "React.createElement" in html_content
def test_jsx_detection_type_text_babel(self, html_content):
# The regex pattern in JS source: /type="text\/babel"/ contains text\/babel
assert "text/babel" in html_content or r"text\/babel" in html_content
def test_jsx_path_uses_babel_transform(self, html_content):
assert "Babel.transform" in html_content
assert "react" in html_content
def test_jsx_path_auto_render_app(self, html_content):
assert "App" in html_content
assert "ReactDOM.createRoot" in html_content
def test_html_path_detection(self, html_content):
# HTML path: code contains <[a-z]
assert "<[a-z]" in html_content
def test_html_path_sets_inner_html(self, html_content):
assert "innerHTML" in html_content
def test_html_path_clones_script_tags(self, html_content):
# Clone script elements for HTML path
assert "createElement" in html_content
assert "script" in html_content.lower()
def test_html_path_inline_scripts_wrapped(self, html_content):
# Inline scripts wrapped with data and mcp params
assert "__VISUAL_DATA__" in html_content
assert "__MCP__" in html_content
def test_plain_js_uses_new_function(self, html_content):
assert "new Function" in html_content
def test_plain_js_passes_chart_arg(self, html_content):
assert (
'"Chart"' in html_content
or "'Chart'" in html_content
or "Chart" in html_content
)
def test_plain_js_passes_leaflet_arg(self, html_content):
assert '"L"' in html_content or "'L'" in html_content
def test_auto_resize_via_raf(self, html_content):
assert "requestAnimationFrame" in html_content
def test_auto_resize_scroll_height(self, html_content):
assert "scrollHeight" in html_content
def test_auto_resize_adds_32(self, html_content):
assert "32" in html_content
def test_auto_resize_min_100(self, html_content):
assert "100" in html_content
assert "Math.max" in html_content
def test_error_renders_visual_error_div(self, html_content):
assert "visual-error" in html_content
def test_error_resize_120(self, html_content):
assert "120" in html_content
class TestMessageListener:
"""Verify window message event listener."""
def test_message_event_listener(self, html_content):
assert "addEventListener" in html_content
assert '"message"' in html_content or "'message'" in html_content
def test_pending_response_handler(self, html_content):
# If msg.id in _pending, resolve and delete
assert "_pending" in html_content
assert "resolve" in html_content
assert "delete" in html_content
def test_render_method_handler(self, html_content):
assert '"render"' in html_content or "'render'" in html_content
def test_render_calls_execute_visual(self, html_content):
assert "executeVisual" in html_content
def test_render_params_code(self, html_content):
assert "params" in html_content
assert "code" in html_content
def test_legacy_tool_input_method(self, html_content):
assert "tool/input" in html_content
def test_legacy_tool_input_triggers_render(self, html_content):
# Legacy tool/input with code param should call executeVisual
assert "tool/input" in html_content
assert "executeVisual" in html_content
class TestInitialHandshake:
"""Verify initial ui/initialize handshake is sent on load."""
def test_handshake_sent_on_load(self, html_content):
assert "ui/initialize" in html_content
def test_handshake_runtime_visual_artifact(self, html_content):
assert "visual-artifact" in html_content
def test_handshake_version_1_0_0(self, html_content):
assert "1.0.0" in html_content
def test_handshake_uses_send_to_host(self, html_content):
assert (
"sendToHost" in html_content or "window.parent.postMessage" in html_content
)
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
# test_visual_artifacts_md.sh - Verify bundle/context/visual-artifacts.md (task-6)
# RED: Run before file is created (should fail)
# GREEN: Run after file is created (should pass)
REPO=/home/ken/workspace/research-workbench
PASS=0
FAIL=0
check_file_exists() {
local path="$1"
if [ -f "$REPO/$path" ]; then
echo "PASS: '$path' exists"
((PASS++))
else
echo "FAIL: '$path' should exist but is missing"
((FAIL++))
fi
}
check_contains() {
local file="$1"
local description="$2"
local pattern="$3"
if grep -qF -- "$pattern" "$REPO/$file" 2>/dev/null; then
echo "PASS: $file contains '$description'"
((PASS++))
else
echo "FAIL: $file is missing '$description'"
((FAIL++))
fi
}
check_regex() {
local file="$1"
local description="$2"
local pattern="$3"
if grep -qE "$pattern" "$REPO/$file" 2>/dev/null; then
echo "PASS: $file matches '$description'"
((PASS++))
else
echo "FAIL: $file is missing '$description' (pattern: $pattern)"
((FAIL++))
fi
}
TARGET="bundle/context/visual-artifacts.md"
# ─────────────────────────────────────────────────────────────────────────────
# File existence
# ─────────────────────────────────────────────────────────────────────────────
echo "=== Checking $TARGET exists ==="
check_file_exists "$TARGET"
# ─────────────────────────────────────────────────────────────────────────────
# Intro section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Intro section ==="
check_regex "$TARGET" "intro / overview heading" "^# |^## "
check_contains "$TARGET" "visual fenced code block tag" '```visual'
check_regex "$TARGET" "sandboxed iframe mention" 'sandbox|iframe|sandboxed'
check_regex "$TARGET" "pre-loaded libraries mention" 'pre.loaded|preloaded|pre-loaded'
# ─────────────────────────────────────────────────────────────────────────────
# When to Generate section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking When to Generate section ==="
check_regex "$TARGET" "When to Generate heading" 'When to Generate|When to Use'
check_regex "$TARGET" "comparing options trigger" 'compar'
check_regex "$TARGET" "trends trigger" 'trend'
check_regex "$TARGET" "locations trigger" 'locat'
check_regex "$TARGET" "scoring/ranking trigger" 'scor|rank'
check_regex "$TARGET" "budget/breakdown trigger" 'budget|breakdown'
check_regex "$TARGET" "dashboard trigger" 'dashboard'
check_regex "$TARGET" "proactive / don't wait to be asked" "proactive|don.t wait|without being asked"
# ─────────────────────────────────────────────────────────────────────────────
# Available Libraries section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Available Libraries section ==="
check_regex "$TARGET" "Available Libraries heading" 'Available Lib|Libraries.*pre'
check_regex "$TARGET" "Tabler CSS entry" 'Tabler'
check_regex "$TARGET" "Chart.js entry" 'Chart\.js|Chart'
check_contains "$TARGET" "Chart global variable" '`Chart`'
check_regex "$TARGET" "Leaflet.js entry" 'Leaflet'
check_contains "$TARGET" "L global variable" '`L`'
check_regex "$TARGET" "React v19 entry" 'React.*19|React v19'
check_contains "$TARGET" "React global" '`React`'
check_contains "$TARGET" "ReactDOM global" '`ReactDOM`'
check_regex "$TARGET" "Babel entry" 'Babel'
check_regex "$TARGET" "JSX compilation" 'JSX|jsx'
# ─────────────────────────────────────────────────────────────────────────────
# Output Format section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Output Format section ==="
check_regex "$TARGET" "Output Format heading" 'Output Format'
check_contains "$TARGET" "visual tag in output example" '```visual'
check_regex "$TARGET" "title field" '"title"'
check_regex "$TARGET" "code field" '"code"'
check_regex "$TARGET" "data field" '"data"'
check_regex "$TARGET" "height field" '"height"'
check_regex "$TARGET" "height default 300" '300'
check_regex "$TARGET" "height max 800" '800'
# ─────────────────────────────────────────────────────────────────────────────
# Code Formats section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Code Formats section ==="
check_regex "$TARGET" "Code Formats heading" 'Code Format'
check_regex "$TARGET" "JSX auto-detection" 'JSX|React.*detect|detect.*React'
check_regex "$TARGET" "HTML detection" 'HTML.*detect|detect.*HTML|<div|<table'
check_regex "$TARGET" "Plain JS format" 'Plain JS|plain.*js|JS.*function'
check_regex "$TARGET" "Babel compilation" 'Babel.*compil|compil.*Babel'
check_regex "$TARGET" "App component auto-render" 'App.*component|auto.render|auto-render'
check_contains "$TARGET" "#root element" '#root'
# ─────────────────────────────────────────────────────────────────────────────
# Code Scope section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Code Scope section ==="
check_regex "$TARGET" "Code Scope heading" 'Code Scope|Available.*Scope|Scope.*Variables'
check_contains "$TARGET" "data variable in scope" '`data`'
check_contains "$TARGET" "mcp variable in scope" '`mcp`'
check_contains "$TARGET" "root variable in scope" '`root`'
check_contains "$TARGET" "Chart variable in scope" '`Chart`'
check_contains "$TARGET" "L variable in scope" '`L`'
check_regex "$TARGET" "mcp.sendMessage" 'sendMessage|mcp\.sendMessage'
check_regex "$TARGET" "mcp.callTool" 'callTool|mcp\.callTool'
check_regex "$TARGET" "mcp.resize" 'resize|mcp\.resize'
# ─────────────────────────────────────────────────────────────────────────────
# Example Patterns section - all 5
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Example 1: Comparison Table ==="
check_regex "$TARGET" "Example 1 heading" 'Comparison Table|Example.*1'
check_regex "$TARGET" "Hybrid Cars title" 'Hybrid Cars Under|Hybrid Cars.*10K'
check_regex "$TARGET" "table headers (Model/Year/Price/Miles/MPG)" 'Model|Year|Price|Miles|MPG'
check_regex "$TARGET" "Prius sample data" 'Prius'
check_regex "$TARGET" "data.items reference" 'data\.items|data\[.items'
check_regex "$TARGET" "green price color" '#7af7a2|7af7a2'
check_regex "$TARGET" "blue link color" '#7aa2f7|7aa2f7'
check_regex "$TARGET" "height 280" '"height".*280|280.*height|height.*280'
echo ""
echo "=== Checking Example 2: Bar Chart ==="
check_regex "$TARGET" "Example 2 heading" 'Bar Chart|Example.*2'
check_regex "$TARGET" "Price Comparison title" 'Price Comparison by Model|Price Comparison'
check_regex "$TARGET" "Chart.js new Chart" 'new Chart'
check_regex "$TARGET" "type bar" "type.*bar|bar.*type|type.*'bar'"
check_regex "$TARGET" "data.labels reference" 'data\.labels'
check_regex "$TARGET" "data.values reference" 'data\.values'
check_regex "$TARGET" "blue rgba color" 'rgba.*122.*162.*247|rgba\(122'
check_regex "$TARGET" "onClick with mcp.sendMessage" 'onClick|mcp\.sendMessage.*label|mcp\.sendMessage.*more about'
check_regex "$TARGET" "height 380 for chart" '"height".*380|380.*height|height.*380'
echo ""
echo "=== Checking Example 3: Location Map ==="
check_regex "$TARGET" "Example 3 heading" 'Location Map|Map|Example.*3'
check_regex "$TARGET" "Dealers Near Woodinville title" 'Dealers Near Woodinville|Woodinville'
check_regex "$TARGET" "L.map usage" 'L\.map'
check_regex "$TARGET" "CARTO dark tile layer" 'CARTO|cartocdn|carto'
check_regex "$TARGET" "markers from data.markers" 'data\.markers'
check_regex "$TARGET" "bindPopup" 'bindPopup'
check_regex "$TARGET" "marker click mcp.sendMessage" 'mcp\.sendMessage.*title|click.*mcp|marker.*mcp'
check_regex "$TARGET" "fitBounds for multiple markers" 'fitBounds'
echo ""
echo "=== Checking Example 4: React Dashboard ==="
check_regex "$TARGET" "Example 4 heading" 'React Dashboard|Dashboard|Example.*4'
check_regex "$TARGET" "Research Summary Dashboard title" 'Research Summary Dashboard|Research Summary'
check_regex "$TARGET" "StatCard component" 'StatCard'
check_regex "$TARGET" "App component" 'function App|const App|App ='
check_regex "$TARGET" "useState for sort" 'useState'
check_regex "$TARGET" "sort by price or miles" "sort|price.*miles|miles.*price"
check_regex "$TARGET" "Tabler btn classes" 'btn-primary|btn-secondary|btn tabler|tabler.*btn'
check_regex "$TARGET" "height 450" '"height".*450|450.*height|height.*450'
echo ""
echo "=== Checking Example 5: Decision Matrix ==="
check_regex "$TARGET" "Example 5 heading" 'Decision Matrix|Example.*5'
check_regex "$TARGET" "Car Decision Matrix title" 'Car Decision Matrix|Decision Matrix'
check_regex "$TARGET" "weight sliders (range input)" 'range.*slider|slider.*range|type.*range|range.*0.*10'
check_regex "$TARGET" "criteria (Price, MPG, Reliability)" 'Price|MPG|Reliab'
check_regex "$TARGET" "weighted scores calculation" 'weight.*score|score.*weight|weighted'
check_regex "$TARGET" "best highlighted green" 'best.*green|green.*best|highlight.*green|green.*highlight'
check_regex "$TARGET" "height 400" '"height".*400|400.*height|height.*400'
# ─────────────────────────────────────────────────────────────────────────────
# Constraints section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Constraints section ==="
check_regex "$TARGET" "Constraints heading" 'Constraint'
check_regex "$TARGET" "self-contained / no API fetches" 'self.contained|no.*fetch|no.*API fetch|no.*external fetch'
check_regex "$TARGET" "dark theme constraint" 'dark.*theme|theme.*dark'
check_regex "$TARGET" "responsive width constraint" 'responsive|percentage|max.width'
check_regex "$TARGET" "no external fetches" 'no.*external|external.*fetch'
check_regex "$TARGET" "click-to-explore" 'click.to.explore|click.*explore|explore.*click'
# ─────────────────────────────────────────────────────────────────────────────
# Visuals vs A2UI decision table
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Visuals vs A2UI section ==="
check_regex "$TARGET" "Visuals vs A2UI heading" 'Visuals.*A2UI|A2UI.*Visuals|visual.*vs.*a2ui|vs.*A2UI'
check_regex "$TARGET" "toggle filters -> A2UI" 'filter|toggle'
check_regex "$TARGET" "simple table -> A2UI" 'simple.*table|table.*simple|<5.*row|5.*row'
check_regex "$TARGET" "interactive charts -> visual" 'interactive.*chart|chart.*interactive'
check_regex "$TARGET" "sortable tables -> visual" 'sortable'
check_regex "$TARGET" "maps -> visual" 'map'
check_regex "$TARGET" "dashboards -> visual" 'dashboard'
check_regex "$TARGET" "decision matrices -> visual" 'decision.*matrix|matrix'
# ─────────────────────────────────────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=============================="
echo "Results: $PASS passed, $FAIL failed"
echo "=============================="
[ $FAIL -eq 0 ] && exit 0 || exit 1