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 +
+``` - +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. -
+
+``` - ``` ### 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
- +
+
@@ -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 - - ``` **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
-
+
- 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