Compare commits
10 Commits
9367d2b957
...
389a8fb655
| Author | SHA1 | Date | |
|---|---|---|---|
| 389a8fb655 | |||
| 2c3cfdbedc | |||
| 92df88975c | |||
| b895a1c94b | |||
| ab99259bcc | |||
| a3635a9eb7 | |||
| c1533fe25c | |||
| 74b2861db5 | |||
| 6538d0aa25 | |||
| 0751fbfa0a |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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: '© 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 |
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' ==="
|
||||||
|
|||||||
Executable
+88
@@ -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
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
Executable
+230
@@ -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
|
||||||
Reference in New Issue
Block a user