diff --git a/.env.example b/.env.example
index 9f6d59f..7bfc224 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,9 @@
-OPENAI_API_KEY=
+ANTHROPIC_API_KEY=
-# LLM model — strong models are required for reliable UI generation
-# Recommended: gpt-5.4, gpt-5.4-pro, claude-opus-4-6, gemini-3.1-pro
-LLM_MODEL=gpt-5.4-2026-03-05
+# Claude model — strong models are required for reliable UI generation
+# Recommended: claude-fable-5 (default), claude-opus-4-6
+# Fallback: gpt-* names route to OpenAI (requires OPENAI_API_KEY)
+LLM_MODEL=claude-fable-5
# Rate limiting (per IP) — disabled by default
RATE_LIMIT_ENABLED=false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 13573d7..9dc6787 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -130,10 +130,34 @@ jobs:
- name: Run linting
run: pnpm lint
+ - name: Run tests
+ run: pnpm test
+
+ agent-tests:
+ name: Agent tests (pytest)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ - name: Install uv
+ run: pip install uv
+
+ - name: Sync agent dependencies
+ working-directory: apps/agent
+ run: uv sync
+
+ - name: Run pytest
+ working-directory: apps/agent
+ run: uv run pytest -q
+
notify-slack:
name: Notify Slack on Failure
runs-on: ubuntu-latest
- needs: [smoke, lint]
+ needs: [smoke, lint, agent-tests]
if: |
failure() &&
github.event_name == 'schedule'
diff --git a/CLAUDE.md b/CLAUDE.md
index 6070bc5..714a420 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -121,7 +121,7 @@ from copilotkit import CopilotKitMiddleware
from src.todos import todo_tools, AgentState
agent = create_agent(
- model="gpt-5.2",
+ model="claude-fable-5",
tools=[*todo_tools, ...], # manage_todos, get_todos
middleware=[CopilotKitMiddleware()],
state_schema=AgentState, # Defines state shape
@@ -213,7 +213,7 @@ export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
## Tech Stack
- **Frontend**: Next.js 16, React 19, TailwindCSS 4
-- **Agent**: LangGraph (Python), OpenAI GPT-5.2
+- **Agent**: LangGraph (Python), Anthropic Claude (Fable 5)
- **CopilotKit**: React hooks for agent integration (v2)
- **Monorepo**: Turborepo with pnpm workspaces
- **Other**: MCP (Model Context Protocol) integration, Recharts for generative UI examples
@@ -244,8 +244,8 @@ pnpm lint
### Environment Setup
```bash
-# Set OpenAI API key for the agent
-echo 'OPENAI_API_KEY=your-key-here' > apps/agent/.env
+# Set Anthropic API key for the agent
+echo 'ANTHROPIC_API_KEY=your-key-here' > apps/agent/.env
```
## Design Principles
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7a5fbb7..41c6034 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -61,10 +61,10 @@ Or manually:
```bash
pnpm install
-echo 'OPENAI_API_KEY=your-key-here' > apps/agent/.env
+echo 'ANTHROPIC_API_KEY=your-key-here' > apps/agent/.env
```
-Then add your real OpenAI API key to `apps/agent/.env`.
+Then add your real Anthropic API key to `apps/agent/.env`.
### 3) Run the Project
diff --git a/Makefile b/Makefile
index 020b7e1..0dc71e9 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ install: ## Install all dependencies (Node + Python)
setup: install ## Full setup: install deps and create .env from template
@if [ ! -f apps/agent/.env ]; then \
- echo "OPENAI_API_KEY=your-key-here" > apps/agent/.env; \
+ echo "ANTHROPIC_API_KEY=your-key-here" > apps/agent/.env; \
echo "Created apps/agent/.env — add your OpenAI API key"; \
else \
echo "apps/agent/.env already exists, skipping"; \
diff --git a/README.md b/README.md
index 0d6aa78..5b670f0 100644
--- a/README.md
+++ b/README.md
@@ -21,19 +21,18 @@ All visuals are rendered in sandboxed iframes with automatic light/dark theming,
```bash
make setup # Install deps + create .env template
-# Edit apps/agent/.env with your real OpenAI API key
+# Edit apps/agent/.env with your real Anthropic API key
make dev # Start all services
```
-> **Strong models required.** Generative UI demands high-capability models that can produce complex, well-structured HTML/SVG in a single pass. Set `LLM_MODEL` in your `.env` to one of:
+> **Strong models required.** Generative UI demands high-capability models that can produce complex, well-structured HTML/SVG in a single pass. The agent runs on Anthropic Claude — `claude-fable-5` by default. Override with `LLM_MODEL` in your `.env`:
>
-> | Model | Provider |
-> |-------|----------|
-> | `gpt-5.4` / `gpt-5.4-pro` | OpenAI |
-> | `claude-opus-4-6` | Anthropic |
-> | `gemini-3.1-pro` | Google |
+> | Model | Notes |
+> |-------|-------|
+> | `claude-fable-5` | Default |
+> | `claude-opus-4-6` | Strong alternative |
>
-> Smaller or weaker models will produce broken layouts, missing interactivity, or incomplete visualizations.
+> Setting `LLM_MODEL` to a `gpt-*` name routes to OpenAI instead (requires `OPENAI_API_KEY`). For other providers, swap the chat model in `apps/agent/src/model.py` (see [docs/bring-to-your-app.md](docs/bring-to-your-app.md)). Smaller or weaker models will produce broken layouts, missing interactivity, or incomplete visualizations.
- **App**: http://localhost:3000
- **Agent**: http://localhost:8123
@@ -127,15 +126,17 @@ Deep agents also provide built-in planning (`write_todos`), filesystem tools, an
1. **User sends a prompt** via the CopilotKit chat UI
2. **Deep agent decides** whether to respond with text, call a tool, or render a visual component — consulting relevant skills as needed
-3. **`widgetRenderer`** — a frontend `useComponent` hook — receives the agent's HTML and renders it in a sandboxed iframe
-4. **Skeleton loading** shows while the iframe loads, then content fades in smoothly
-5. **ResizeObserver** inside the iframe reports content height back to the parent for seamless auto-sizing
+3. **`generateSandboxedUi`** — the canonical tool the CopilotKit runtime exposes when `openGenerativeUI` is enabled — receives the UI as ordered streaming parameters: `initialHeight` → `placeholderMessages` → `css` → `html` → `jsFunctions` → `jsExpressions`
+4. **`OpenGenerativeUIMiddleware`** in the runtime translates the streaming tool call into `open-generative-ui` activity events the frontend subscribes to
+5. **The demo's activity renderer** (registered via `renderActivityMessages`) shows the html streaming in live — morphing each update into a preview iframe with Idiomorph so nothing flickers — then boots the final websandbox iframe with the shared design-system CSS and CDN importmap injected
+6. **Sandbox bridge + autosize** — the generated UI calls back into the host through Zod-validated `sendPrompt`/`openLink` sandbox functions, and a ResizeObserver inside the iframe continuously reports content height for seamless auto-sizing
### Key CopilotKit Patterns
-| Pattern | Hook | Example |
-|---------|------|---------|
-| Generative UI | `useComponent` | Pie charts, bar charts, widget renderer |
+| Pattern | Hook / Option | Example |
+|---------|---------------|---------|
+| Open Generative UI | `openGenerativeUI` + `renderActivityMessages` | Streaming sandboxed widgets via `generateSandboxedUi` |
+| Generative UI | `useComponent` | Pie charts, bar charts |
| Frontend tools | `useFrontendTool` | Theme toggle |
| Human-in-the-loop | `useHumanInTheLoop` | Meeting scheduler |
| Default tool render | `useDefaultRenderTool` | Tool execution status |
diff --git a/apps/agent/main.py b/apps/agent/main.py
index 4283880..8a5d8fa 100644
--- a/apps/agent/main.py
+++ b/apps/agent/main.py
@@ -12,78 +12,26 @@
from copilotkit import CopilotKitMiddleware, LangGraphAGUIAgent
from ag_ui_langgraph import add_langgraph_fastapi_endpoint
from deepagents import create_deep_agent
-from langchain_openai import ChatOpenAI
+from src.anthropic_compat import ConsecutiveSystemMessagesMiddleware
from src.bounded_memory_saver import BoundedMemorySaver
+from src.model import build_model
from src.query import query_data
from src.todos import AgentState, todo_tools
from src.form import generate_form
from src.plan import plan_visualization
+from src.prompt import SYSTEM_PROMPT
load_dotenv()
agent = create_deep_agent(
- model=ChatOpenAI(model=os.environ.get("LLM_MODEL", "gpt-5.4-2026-03-05")),
+ model=build_model(),
tools=[query_data, plan_visualization, *todo_tools, generate_form],
- middleware=[CopilotKitMiddleware()],
+ middleware=[CopilotKitMiddleware(), ConsecutiveSystemMessagesMiddleware()],
context_schema=AgentState,
skills=[str(Path(__file__).parent / "skills")],
checkpointer=BoundedMemorySaver(max_threads=200),
- system_prompt="""
- You are a helpful assistant that helps users understand CopilotKit and LangGraph used together.
-
- Be brief in your explanations of CopilotKit and LangGraph, 1 to 2 sentences.
-
- When demonstrating charts, always call the query_data tool to fetch all data from the database first.
-
- ## Visual Response Skills
-
- You have the ability to produce rich, interactive visual responses using the
- `widgetRenderer` component. When a user asks you to visualize, explain visually,
- diagram, or illustrate something, you MUST use the `widgetRenderer` component
- instead of plain text.
-
- The `widgetRenderer` component accepts three parameters:
- - title: A short title for the visualization
- - description: A one-sentence description of what the visualization shows
- - html: A self-contained HTML fragment with inline
-
+css parameter:
+```css
+.sim-controls {
+ display: flex; align-items: center; gap: 16px;
+ margin: 12px 0; font-size: 13px;
+ color: var(--color-text-secondary);
+}
+#sim {
+ width: 100%; height: 300px;
+ border-radius: var(--border-radius-md);
+ background: var(--color-background-secondary);
+}
+```
+
+html parameter:
+```html
+
Play / Pause
Speed
+ oninput="setSimSpeed(+this.value)">
Reset
+```
-
+function toggleSim() {
+ window.sim.running = !window.sim.running;
+ if (window.sim.running) stepSim();
+}
+
+function resetSim() {
+ const wasRunning = window.sim.running;
+ initSim(window.sim.particles.length);
+ if (!wasRunning) stepSim();
+}
+```
+
+jsExpressions parameter:
+```js
+initSim(50);
+stepSim();
```
### Math Visualizations
For plotting functions, showing geometric relationships, or exploring equations.
**Pattern: Function Plotter with SVG**
+
+css parameter:
+```css
+.plot-controls {
+ display: flex; gap: 16px; align-items: center;
+ margin: 12px 0; font-size: 13px;
+ color: var(--color-text-secondary);
+}
+.plot-controls input[type="number"] { width: 60px; }
+.plot-controls input[type="range"] { flex: 1; }
+```
+
+html parameter:
```html
@@ -305,19 +386,20 @@ For plotting functions, showing geometric relationships, or exploring equations.
-
+
f(x) = sin(
x)
+ oninput="plotFn()">x)
Amplitude
+ oninput="plotFn()">
+```
-
```
### Sortable / Filterable Data Tables
-```html
-
-
+css parameter:
+```css
+.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.data-table th {
+ text-align: left; padding: 8px 12px; font-weight: 500;
+ border-bottom: 0.5px solid var(--color-border-secondary);
+ color: var(--color-text-secondary); cursor: pointer;
+ user-select: none; font-size: 12px;
+}
+.data-table th:hover { color: var(--color-text-primary); }
+.data-table td {
+ padding: 8px 12px;
+ border-bottom: 0.5px solid var(--color-border-tertiary);
+}
+.table-filter { width: 100%; margin-bottom: 12px; }
+.status-pill {
+ font-size: 12px; padding: 2px 10px;
+ border-radius: var(--border-radius-md);
+}
+.status-pill.active {
+ background: var(--color-background-success);
+ color: var(--color-text-success);
+}
+.status-pill.paused {
+ background: var(--color-background-warning);
+ color: var(--color-text-warning);
+}
+```
+
+html parameter:
+```html
+
@@ -370,44 +470,51 @@ plotFn();
+```
-
+jsExpressions parameter:
+```js
+initTable([
+ ['Alpha', 42, 'Active'],
+ ['Beta', 18, 'Paused'],
+ ['Gamma', 91, 'Active'],
+]);
```
---
@@ -456,7 +563,8 @@ plugins: { legend: { display: false } }
```
### Dashboard Layout
-Metric cards on top -> chart below -> sendPrompt for drill-down:
+Metric cards on top -> chart below -> drill-down buttons wired to the
+sendPrompt bridge (Part 6):
```html
-
Overview
+
+ Overview
Details
Code
@@ -579,8 +694,10 @@ Instead, render all content stacked, then use post-stream JS to create tabs:
+```
-
```
-### sendPrompt() — Chat-Driven Interactivity
-A global function that sends a message as if the user typed it.
-Use it when the user's next action benefits from AI thinking:
+### sendPrompt — Chat-Driven Interactivity
+The host bridge exposes `await Websandbox.connection.remote.sendPrompt({ text })`,
+which sends a message as if the user typed it. Use it when the user's next
+action benefits from AI thinking. Wire it through a named jsFunction:
+
+jsFunctions parameter:
+```js
+function drillDown(text) {
+ Websandbox.connection.remote.sendPrompt({ text });
+}
+```
+html parameter:
```html
-
+
Drill into Q4 ↗
-
+
Learn about shear ↗
```
**Use for**: drill-downs, follow-up questions, "explain this part".
**Don't use for**: filtering, sorting, toggling — handle those in JS.
-Append ` ↗` to button text when it triggers sendPrompt.
+Append ` ↗` to button text when it triggers the bridge.
+For external links use `await Websandbox.connection.remote.openLink({ url })`
+(https only — anything else is rejected).
### Responsive Grid Pattern
```css
@@ -653,22 +784,38 @@ Use `minmax(0, 1fr)` if children have large min-content that could overflow.
---
-## Part 7: External Libraries (CDN Allowlist)
+## Part 7: External Libraries
-Only these CDN origins work (CSP-enforced):
-- `cdnjs.cloudflare.com`
-- `esm.sh`
-- `cdn.jsdelivr.net`
-- `unpkg.com`
+### Importmap Libraries (Pre-Injected)
-### Useful Libraries
+An importmap for `three`, `gsap`, `d3`, and `chart.js` (served via esm.sh) is
+pre-injected into every sandbox. jsFunctions and jsExpressions execute as classic
+scripts, where top-level await is a SyntaxError that fails silently — so PREFER
+loading libraries with dynamic imports INSIDE an async function declared in
+jsFunctions, and keep jsExpressions synchronous invocations of those functions:
-**Chart.js** (data visualization):
-```html
-
+```js
+async function setupScene() {
+ const THREE = await import('three');
+ const { OrbitControls } =
+ await import('three/examples/jsm/controls/OrbitControls.js');
+ // ... build the scene with real geometry and PBR materials.
+ // NEVER fake 3D with CSS transforms or Canvas 2D projection.
+}
+```
+
+```js
+async function setupLibraries() {
+ const { default: gsap } = await import('gsap');
+ const d3 = await import('d3');
+ const { default: Chart } = await import('chart.js/auto');
+ // ... use the libraries here.
+}
```
-**Three.js** (3D graphics) — use ES module import (import map resolves bare specifiers):
+Where a module script genuinely belongs in the html parameter,
+`
```
-Alternative UMD (global `THREE` variable):
-```html
-
-```
-**D3.js** (advanced data viz, force layouts, geographic maps):
-```html
-
-```
+**Critical**: Regular `
+```
+
+jsExpressions parameter:
+```js
+updateParam(50);
```
### Template: Step-Through Explainer
For cyclic or staged processes (event loops, biological cycles, pipelines).
-```html
-
+css parameter:
+```css
+.step-nav {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 12px 0;
+ font-size: 13px;
+}
+.step-nav button {
+ padding: 6px 16px;
+ border: 1px solid var(--color-border-tertiary, #ddd);
+ border-radius: 8px;
+ background: var(--color-background-secondary, #f5f5f5);
+ color: var(--color-text-primary, #333);
+ cursor: pointer;
+ font-size: 13px;
+}
+.step-nav button:hover {
+ background: var(--color-background-tertiary, #eee);
+}
+.dot { width: 8px; height: 8px; border-radius: 50%;
+ background: var(--color-border-tertiary, #ccc);
+ transition: background 0.2s; }
+.dot.active { background: var(--color-text-info, #185FA5); }
+.step-content { min-height: 300px; }
+#dots { display: flex; gap: 6px; }
+#step-label { margin-left: auto; color: var(--color-text-secondary, #888); }
+```
+html parameter:
+```html
Previous
-
+
Next
-
Step 1 of 4
+
Step 1 of 4
+```
-
+function prevStep() {
+ window.current = (window.current - 1 + window.steps.length) % window.steps.length;
+ renderStep();
+}
+```
+
+jsExpressions parameter:
+```js
+initSteps([
+ { title: "Step 1", svg: `... `, desc: "What happens first" },
+ { title: "Step 2", svg: `... `, desc: "Then this" },
+]);
```
### CSS Animation Patterns (for live diagrams)
+These belong in the css parameter alongside the rest of your styles:
+
```css
/* Flowing particles along a path */
@keyframes flow {
@@ -261,41 +330,55 @@ For simple charts, hand-draw in SVG. No library needed.
### Approach: Chart.js (For Complex/Interactive Charts)
-When you need tooltips, responsive legends, animations:
+When you need tooltips, responsive legends, animations. `chart.js` is in the
+pre-injected importmap — load it with a dynamic import inside a jsFunction:
+html parameter:
```html
-
-
-
+ });
+}
+```
+
+jsExpressions parameter:
+```js
+renderRevenueChart();
```
---
@@ -305,6 +388,9 @@ new Chart(document.getElementById('myChart'), {
For relationship diagrams (ERDs, class diagrams, sequence diagrams) where
precise layout math isn't worth doing by hand.
+Mermaid is not in the importmap — load it by URL in a module script placed in
+the html parameter (CDN script tags still work there):
+
```html
";',
+ jsExpressions: ['document.title = "";'],
+ },
+ "Escapes"
+ );
+ expect(doc).not.toContain('const s = "";');
+ expect(doc).toContain('const s = "<\\/script>";');
+ expect(doc).not.toContain('document.title = "";');
+ expect(doc).toContain('document.title = "<\\/script>";');
+ });
+
+ it("neutralizes closing style sequences embedded in generated css", () => {
+ const doc = assembleStandaloneHtmlFromActivity(
+ {
+ css: "body{color:red}",
+ html: ["
"],
+ },
+ "Css escape"
+ );
+ expect(doc).not.toContain("");
+ expect(doc).toContain("body{color:red}<\\/style>");
+ });
+
+ it("tolerates empty and missing fields", () => {
+ const doc = assembleStandaloneHtmlFromActivity({});
+ expect(doc.startsWith("")).toBe(true);
+ expect(doc).toContain('`;
+export interface StandaloneActivityContent {
+ css?: string;
+ html?: string[];
+ jsFunctions?: string;
+ jsExpressions?: string[];
+}
+
+const WEBSANDBOX_STUB = `window.Websandbox = { connection: { remote: { sendPrompt: async () => {}, openLink: async ({ url }) => { if (/^https:/.test(url)) window.open(url, "_blank", "noopener,noreferrer"); } } } };`;
+
+function escapeScriptClose(js: string): string {
+ return js.replace(/<\/script/gi, "<\\/script");
+}
+
+function escapeStyleClose(css: string): string {
+ return css.replace(/<\/style/gi, "<\\/style");
+}
/**
- * Wrap a raw HTML fragment (the same string passed to WidgetRenderer)
- * in a standalone document that works when opened in a browser.
+ * Wrap an open-generative-ui activity payload in a standalone document that
+ * works when opened in a browser: importmap first, then the same design-system
+ * css composition the live renderer injects, then the generated css, a
+ * Websandbox stub so exported bridge calls degrade gracefully, the joined html
+ * chunks, and finally the generated js in ONE classic (non-module) script:
+ * jsFunctions at top level — so function declarations become window globals,
+ * matching the live websandbox rail where inline onclick="fn()" handlers
+ * resolve them — followed by the jsExpressions inside an async IIFE so they
+ * can use `await` (dynamic import() still resolves via the importmap in
+ * classic scripts).
*/
-export function assembleStandaloneHtml(html: string, title: string): string {
+export function assembleStandaloneHtmlFromActivity(
+ content: StandaloneActivityContent,
+ title = "generated-widget"
+): string {
+ const body = content.html?.join("") ?? "";
+ const expressions = content.jsExpressions ?? [];
+ const scriptParts = [
+ ...(content.jsFunctions ? [escapeScriptClose(content.jsFunctions)] : []),
+ ...(expressions.length > 0
+ ? [
+ `(async () => {
+${expressions.map(escapeScriptClose).join("\n")}
+})();`,
+ ]
+ : []),
+ ];
+ const generatedScript =
+ scriptParts.length > 0
+ ? ``
+ : "";
return `
${escapeHtml(title)}
- ${IMPORT_MAP}
+ ${IMPORTMAP_SCRIPT_TAG}
+ ${content.css ? `\n ` : ""}
+
-
- ${html}
-
-
+ ${body}
+ ${generatedScript}
`;
}
diff --git a/apps/app/src/components/generative-ui/open-generative-ui/__tests__/open-generative-ui-renderer.test.tsx b/apps/app/src/components/generative-ui/open-generative-ui/__tests__/open-generative-ui-renderer.test.tsx
new file mode 100644
index 0000000..8fed986
--- /dev/null
+++ b/apps/app/src/components/generative-ui/open-generative-ui/__tests__/open-generative-ui-renderer.test.tsx
@@ -0,0 +1,645 @@
+import { render, cleanup, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import React from "react";
+import { z } from "zod";
+import {
+ SandboxFunctionsContext,
+ type SandboxFunction,
+} from "@copilotkit/react-core/v2";
+import { IDIOMORPH_JS } from "../../idiomorph-inline";
+import {
+ OpenGenUIActivityRenderer,
+ OPEN_GEN_UI_ACTIVITY_RENDERER,
+ LOADING_PHRASES,
+ type OpenGenUIContent,
+} from "../index";
+import { THROTTLE_MS } from "../renderer";
+
+// The react-core v2 dist entry imports index.css, which vitest's node loader
+// rejects (same precedent as src/lib/sandbox/__tests__/prompt-bridge.test.tsx)
+// — mock the module with a faithful context + hook pair.
+vi.mock("@copilotkit/react-core/v2", async () => {
+ const { createContext, useContext } = await import("react");
+ const SandboxFunctionsContext = createContext([]);
+ return {
+ SandboxFunctionsContext,
+ useSandboxFunctions: () => useContext(SandboxFunctionsContext),
+ };
+});
+
+// Mock the websandbox loader seam. @jetbrains/websandbox is a transitive
+// dependency of @copilotkit/react-core that pnpm strict isolation makes
+// unresolvable from this package, so vite's import-analysis rejects a direct
+// vi.mock("@jetbrains/websandbox") at transform time. The mock shape below
+// mirrors the canonical OpenGenerativeUIRenderer.test.tsx websandbox mock.
+const mockRun = vi.fn().mockResolvedValue(undefined);
+const mockDestroy = vi.fn();
+let mockIframe: HTMLIFrameElement;
+let mockPromiseResolve: () => void;
+let mockPromise: Promise;
+
+function resetMockPromise() {
+ mockPromise = new Promise((resolve) => {
+ mockPromiseResolve = resolve;
+ });
+}
+
+const mockCreate = vi.fn(
+ (
+ _localApi: Record,
+ options: { frameContainer: HTMLElement; frameContent: string }
+ ) => {
+ mockIframe = document.createElement("iframe");
+ options.frameContainer.appendChild(mockIframe);
+ return {
+ iframe: mockIframe,
+ promise: mockPromise,
+ run: mockRun,
+ destroy: mockDestroy,
+ };
+ }
+);
+
+vi.mock("../websandbox-loader", () => ({
+ loadWebsandbox: async () => ({
+ create: (
+ ...args: [
+ Record,
+ { frameContainer: HTMLElement; frameContent: string },
+ ]
+ ) => mockCreate(...args),
+ }),
+}));
+
+/** Flush the dynamic import() microtask so the sandbox gets created */
+async function flushImport() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+async function resolveSandboxReady() {
+ await act(async () => {
+ mockPromiseResolve();
+ await mockPromise;
+ });
+ await flushImport();
+}
+
+function rendererElement(content: OpenGenUIContent) {
+ return (
+
+ );
+}
+
+function renderRenderer(content: OpenGenUIContent) {
+ return render(rendererElement(content));
+}
+
+function runCallsContaining(needle: string) {
+ return mockRun.mock.calls.filter(
+ (c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes(needle)
+ );
+}
+
+describe("OpenGenUIActivityRenderer", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetMockPromise();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders loading placeholder and no sandbox when there is no html", async () => {
+ const { container } = renderRenderer({ initialHeight: 300, generating: true });
+ await flushImport();
+
+ // The root is the constant ExportOverlay wrapper; the frame div with the
+ // height style is its child.
+ const div = container.querySelector("div[style]")!;
+ expect(div.style.height).toBe("300px");
+ expect(container.textContent).toContain(LOADING_PHRASES[0]);
+ expect(mockCreate).not.toHaveBeenCalled();
+ });
+
+ it("creates no sandbox while css is incomplete, even as html chunks stream", async () => {
+ const { rerender } = renderRenderer({
+ html: ["Streaming
"],
+ htmlComplete: false,
+ generating: true,
+ });
+ await flushImport();
+
+ expect(mockCreate).not.toHaveBeenCalled();
+
+ rerender(
+ rendererElement({
+ html: ["Streaming
", "More
"],
+ htmlComplete: false,
+ generating: true,
+ })
+ );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, THROTTLE_MS + 100));
+ });
+ await flushImport();
+
+ expect(mockCreate).not.toHaveBeenCalled();
+ }, 10000);
+
+ it("creates the preview sandbox once cssComplete and injects design system + generated css via run()", async () => {
+ renderRenderer({
+ css: "body{--marker:gen-css}",
+ cssComplete: true,
+ html: ["Hello
"],
+ htmlComplete: false,
+ generating: true,
+ });
+ await flushImport();
+
+ expect(mockCreate).toHaveBeenCalledTimes(1);
+ const [localApi, options] = mockCreate.mock.calls[0]!;
+ expect(options.frameContent).toBe("");
+ expect(Object.keys(localApi)).toHaveLength(0);
+
+ await resolveSandboxReady();
+
+ const headCalls = runCallsContaining("document.head.innerHTML");
+ expect(headCalls.length).toBeGreaterThanOrEqual(1);
+ const headCode = headCalls[headCalls.length - 1]![0] as string;
+ expect(headCode).toContain("--color-background-primary");
+ expect(headCode).toContain("svg text.t");
+ expect(headCode).toContain("fadeSlideIn");
+ expect(headCode).toContain("--marker:gen-css");
+ });
+
+ it("injects Idiomorph once and morphs preview body updates with an innerHTML fallback", async () => {
+ const { rerender } = renderRenderer({
+ css: "body{--marker:gen-css}",
+ cssComplete: true,
+ html: ["Hello
"],
+ htmlComplete: false,
+ generating: true,
+ });
+ await flushImport();
+ await resolveSandboxReady();
+
+ const idiomorphInjections = () =>
+ mockRun.mock.calls.filter((c: unknown[]) => c[0] === IDIOMORPH_JS).length;
+ expect(idiomorphInjections()).toBe(1);
+
+ const morphCalls = runCallsContaining("Idiomorph.morph");
+ expect(morphCalls.length).toBeGreaterThanOrEqual(1);
+ const morphCode = morphCalls[morphCalls.length - 1]![0] as string;
+ expect(morphCode).toContain("Idiomorph.morph(document.body");
+ expect(morphCode).toContain("morphStyle: 'innerHTML'");
+ // Streaming entrance animation: new nodes are tagged morph-enter so the
+ // design system's fadeSlideIn rule animates them in (legacy bridge parity).
+ expect(morphCode).toContain("beforeNodeAdded");
+ expect(morphCode).toContain("morph-enter");
+ expect(morphCode).toContain("document.body.innerHTML");
+ expect(morphCode).toContain("Hello");
+ // processPartialHtml must have stripped the wrapper before morph.
+ expect(morphCode).not.toContain("");
+
+ rerender(
+ rendererElement({
+ css: "body{--marker:gen-css}",
+ cssComplete: true,
+ html: ["Hello
", "World
"],
+ htmlComplete: false,
+ generating: true,
+ })
+ );
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, THROTTLE_MS + 100));
+ });
+ await flushImport();
+
+ const updatedMorphCalls = runCallsContaining("Idiomorph.morph");
+ expect(updatedMorphCalls[updatedMorphCalls.length - 1]![0]).toContain("World");
+ expect(idiomorphInjections()).toBe(1);
+ }, 10000);
+
+ it("builds final frameContent ordered importmap -> design system -> generated css -> generated html, with sandbox functions as localApi", async () => {
+ const handler = vi.fn().mockResolvedValue(42);
+ const fns: SandboxFunction[] = [
+ {
+ name: "addToCart",
+ description: "Add item to cart",
+ parameters: z.object({ itemId: z.string() }),
+ handler,
+ },
+ ];
+
+ render(
+
+ {rendererElement({
+ css: "body{--marker:gen-css}",
+ cssComplete: true,
+ html: ['Done
'],
+ htmlComplete: true,
+ generating: false,
+ })}
+
+ );
+ await flushImport();
+
+ expect(mockCreate).toHaveBeenCalledTimes(1);
+ const [localApi, options] = mockCreate.mock.calls[0]!;
+ expect(localApi.addToCart).toBe(handler);
+
+ const frameContent = options.frameContent as string;
+ const importmapIdx = frameContent.indexOf('Hi
')
+ ).toBe("Hi
");
+ });
+
+ it("strips unterminated
-
-
-
-
-
- ${initialHtml}
-
-
-
-
-`;
-}
-
-// ─── Loading Phrases ─────────────────────────────────────────────────
-const LOADING_PHRASES = [
- "Sketching pixels",
- "Wiring up nodes",
- "Painting gradients",
- "Compiling visuals",
- "Arranging atoms",
- "Rendering magic",
- "Polishing edges",
-];
-
-function useLoadingPhrase(active: boolean) {
- const [index, setIndex] = useState(0);
- useEffect(() => {
- if (!active) return;
- const interval = setInterval(() => {
- setIndex((i) => (i + 1) % LOADING_PHRASES.length);
- }, 1800);
- return () => clearInterval(interval);
- }, [active]);
- return LOADING_PHRASES[index];
-}
-
-// ─── React Component ─────────────────────────────────────────────────
-
-export function WidgetRenderer({ title, html }: WidgetRendererProps) {
- const iframeRef = useRef(null);
- const [height, setHeight] = useState(0);
- const [loaded, setLoaded] = useState(false);
- // Whether the iframe shell has been initialized
- const shellReadyRef = useRef(false);
- // Track the last html sent to the iframe to avoid redundant updates
- const committedHtmlRef = useRef("");
- // Tracks whether html content has settled (stopped changing)
- const [htmlSettled, setHtmlSettled] = useState(false);
- const [prevHtml, setPrevHtml] = useState(html);
- const settledTimerRef = useRef | null>(null);
- const fadeTimerRef = useRef | null>(null);
- const [fadingOut, setFadingOut] = useState(false);
-
- const handleMessage = useCallback((e: MessageEvent) => {
- // Only handle messages from our own iframe
- if (
- iframeRef.current &&
- e.source === iframeRef.current.contentWindow &&
- e.data?.type === "widget-resize" &&
- typeof e.data.height === "number"
- ) {
- setHeight(Math.max(50, Math.min(e.data.height, 4000)));
- }
- }, []);
-
- useEffect(() => {
- window.addEventListener("message", handleMessage);
- return () => window.removeEventListener("message", handleMessage);
- }, [handleMessage]);
-
- // Reset settled/fade state when html changes (adjust state during render)
- if (html !== prevHtml) {
- setPrevHtml(html);
- setHtmlSettled(false);
- setFadingOut(false);
- }
-
- // Initialize the iframe shell once when html first appears.
- // Shell loads EMPTY so partial streaming fragments (e.g. unclosed