From 70a827e1603a5be2e45650b93424aca83140f7ba Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:56:55 -0700 Subject: [PATCH 1/6] feat: add interactive notebook app for OpenGenerativeUI demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone Next.js app (apps/notebook) that explains OpenGenerativeUI concepts in a Jupyter notebook-like format with markdown cells, syntax- highlighted code cells (Shiki), and interactive playground cells (Sandpack). 6 chapters: Introduction, Agent State, Generative UI, CopilotKit Hooks, Frontend Tools, and Widget Renderer — each with live editable examples. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/notebook/eslint.config.mjs | 18 + apps/notebook/next.config.ts | 5 + apps/notebook/package.json | 31 ++ apps/notebook/postcss.config.mjs | 5 + apps/notebook/src/app/globals.css | 299 +++++++++++ apps/notebook/src/app/layout.tsx | 28 + apps/notebook/src/app/page.tsx | 5 + .../notebook/src/components/cell-renderer.tsx | 41 ++ .../src/components/chapter-header.tsx | 30 ++ apps/notebook/src/components/code-cell.tsx | 76 +++ apps/notebook/src/components/copy-button.tsx | 27 + .../notebook/src/components/markdown-cell.tsx | 31 ++ .../src/components/notebook-shell.tsx | 51 ++ .../src/components/playground-cell.tsx | 143 +++++ apps/notebook/src/components/sidebar-nav.tsx | 110 ++++ apps/notebook/src/components/theme-toggle.tsx | 41 ++ .../src/content/chapters/01-introduction.ts | 74 +++ .../src/content/chapters/02-agent-state.ts | 187 +++++++ .../src/content/chapters/03-generative-ui.ts | 154 ++++++ .../content/chapters/04-copilotkit-hooks.ts | 254 +++++++++ .../src/content/chapters/05-frontend-tools.ts | 221 ++++++++ .../content/chapters/06-widget-renderer.ts | 218 ++++++++ apps/notebook/src/content/index.ts | 16 + apps/notebook/src/hooks/use-theme.tsx | 40 ++ apps/notebook/src/lib/highlight.ts | 13 + apps/notebook/src/lib/types.ts | 32 ++ apps/notebook/tsconfig.json | 29 + package.json | 1 + pnpm-lock.yaml | 505 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 30 files changed, 2686 insertions(+) create mode 100644 apps/notebook/eslint.config.mjs create mode 100644 apps/notebook/next.config.ts create mode 100644 apps/notebook/package.json create mode 100644 apps/notebook/postcss.config.mjs create mode 100644 apps/notebook/src/app/globals.css create mode 100644 apps/notebook/src/app/layout.tsx create mode 100644 apps/notebook/src/app/page.tsx create mode 100644 apps/notebook/src/components/cell-renderer.tsx create mode 100644 apps/notebook/src/components/chapter-header.tsx create mode 100644 apps/notebook/src/components/code-cell.tsx create mode 100644 apps/notebook/src/components/copy-button.tsx create mode 100644 apps/notebook/src/components/markdown-cell.tsx create mode 100644 apps/notebook/src/components/notebook-shell.tsx create mode 100644 apps/notebook/src/components/playground-cell.tsx create mode 100644 apps/notebook/src/components/sidebar-nav.tsx create mode 100644 apps/notebook/src/components/theme-toggle.tsx create mode 100644 apps/notebook/src/content/chapters/01-introduction.ts create mode 100644 apps/notebook/src/content/chapters/02-agent-state.ts create mode 100644 apps/notebook/src/content/chapters/03-generative-ui.ts create mode 100644 apps/notebook/src/content/chapters/04-copilotkit-hooks.ts create mode 100644 apps/notebook/src/content/chapters/05-frontend-tools.ts create mode 100644 apps/notebook/src/content/chapters/06-widget-renderer.ts create mode 100644 apps/notebook/src/content/index.ts create mode 100644 apps/notebook/src/hooks/use-theme.tsx create mode 100644 apps/notebook/src/lib/highlight.ts create mode 100644 apps/notebook/src/lib/types.ts create mode 100644 apps/notebook/tsconfig.json diff --git a/apps/notebook/eslint.config.mjs b/apps/notebook/eslint.config.mjs new file mode 100644 index 0000000..6d22b8e --- /dev/null +++ b/apps/notebook/eslint.config.mjs @@ -0,0 +1,18 @@ +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import nextTypescript from "eslint-config-next/typescript"; + +const eslintConfig = [ + ...nextCoreWebVitals, + ...nextTypescript, + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/apps/notebook/next.config.ts b/apps/notebook/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/apps/notebook/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/apps/notebook/package.json b/apps/notebook/package.json new file mode 100644 index 0000000..bd2d546 --- /dev/null +++ b/apps/notebook/package.json @@ -0,0 +1,31 @@ +{ + "name": "@repo/notebook", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack --port 3001", + "build": "next build", + "start": "next start", + "lint": "eslint ." + }, + "dependencies": { + "next": "16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-markdown": "^9.0.3", + "remark-gfm": "^4.0.0", + "@codesandbox/sandpack-react": "^2.19.0", + "shiki": "^3.2.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/notebook/postcss.config.mjs b/apps/notebook/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/apps/notebook/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/apps/notebook/src/app/globals.css b/apps/notebook/src/app/globals.css new file mode 100644 index 0000000..e04cb82 --- /dev/null +++ b/apps/notebook/src/app/globals.css @@ -0,0 +1,299 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* === Brand Design Tokens === */ +:root { + --background: #F7F7F9; + --foreground: #010507; + + --color-lilac: #BEC2FF; + --color-lilac-light: #D4D7FF; + --color-lilac-dark: #9599CC; + --color-mint: #85E0CE; + --color-mint-light: #A8E9DC; + --color-mint-dark: #1B936F; + + --color-surface: #DEDEE9; + --color-surface-light: #F7F7F9; + --color-container: #FFFFFF; + + --color-text-primary: #010507; + --color-text-secondary: #57575B; + --color-text-tertiary: #8E8E93; + + --color-border: #DBDBE5; + --color-border-light: #EBEBF0; + --color-border-glass: rgba(255, 255, 255, 0.3); + + --color-glass: rgba(255, 255, 255, 0.7); + --color-glass-subtle: rgba(255, 255, 255, 0.5); + --color-glass-dark: rgba(255, 255, 255, 0.85); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 25px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.03); + --shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.1); + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 24px; + + --font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; + + --surface-primary: #fff; + --surface-secondary: #fafafa; + --text-primary: #374151; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + + --border-default: #e5e7eb; + --border-subtle: #f0f0f0; + + color-scheme: light dark; +} + +/* === Dark Mode === */ +:root.dark { + --background: #0a0a0a; + --foreground: #ededed; + + --color-surface: #1a1a2e; + --color-surface-light: #0f0f1a; + --color-container: #1a1a2e; + + --color-text-primary: #e5e7eb; + --color-text-secondary: #9ca3af; + --color-text-tertiary: #6b7280; + + --color-border: rgba(255, 255, 255, 0.1); + --color-border-light: rgba(255, 255, 255, 0.06); + --color-border-glass: rgba(255, 255, 255, 0.08); + + --color-glass: rgba(20, 20, 40, 0.7); + --color-glass-subtle: rgba(20, 20, 40, 0.5); + --color-glass-dark: rgba(10, 10, 20, 0.85); + + --shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.3); + + --surface-primary: rgba(255, 255, 255, 0.04); + --surface-secondary: rgba(255, 255, 255, 0.02); + + --text-primary: #e5e7eb; + --text-secondary: #9ca3af; + --text-tertiary: #6b7280; + + --border-default: rgba(255, 255, 255, 0.08); + --border-subtle: rgba(255, 255, 255, 0.06); +} + +/* === Base === */ +body { + font-family: var(--font-family); + background: var(--background); + color: var(--foreground); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +body, html { + height: 100%; +} + +/* === Animated Background === */ +.abstract-bg { + position: fixed; + inset: 0; + overflow: hidden; + z-index: 0; + background: linear-gradient(135deg, var(--color-surface-light) 0%, var(--color-surface) 100%); +} + +.abstract-bg::before, +.abstract-bg::after { + content: ''; + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.4; +} + +.abstract-bg::before { + width: 600px; + height: 600px; + background: var(--color-lilac); + top: -200px; + right: -100px; + animation: blob1 25s ease-in-out infinite; +} + +.abstract-bg::after { + width: 500px; + height: 500px; + background: var(--color-mint); + bottom: -150px; + left: -100px; + animation: blob2 30s ease-in-out infinite; +} + +@keyframes blob1 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(-30px, 50px) scale(1.1); } + 50% { transform: translate(20px, -30px) scale(0.95); } + 75% { transform: translate(40px, 20px) scale(1.05); } +} + +@keyframes blob2 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(50px, -40px) scale(1.1); } + 66% { transform: translate(-30px, 30px) scale(0.9); } +} + +/* === Glassmorphism === */ +.glass { + background: var(--color-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-glass); +} + +.glass-subtle { + background: var(--color-glass-subtle); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-lg); +} + +/* === Gradient Utilities === */ +.text-gradient { + background: linear-gradient(135deg, var(--color-lilac-dark), var(--color-mint-dark)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* === Notebook-specific === */ +.notebook-cell { + background: var(--color-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease; +} + +.notebook-cell:hover { + box-shadow: var(--shadow-md); +} + +/* Code block styling */ +.code-block { + border-left: 3px solid var(--color-lilac); + overflow-x: auto; +} + +.code-block pre { + margin: 0; + padding: var(--space-4); + font-size: 13px; + line-height: 1.6; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +.code-block code { + font-family: inherit; +} + +/* Markdown cell prose */ +.markdown-prose h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.75rem; } +.markdown-prose h2 { font-size: 1.375rem; font-weight: 600; margin-bottom: 0.5rem; margin-top: 1.5rem; } +.markdown-prose h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; margin-top: 1rem; } +.markdown-prose p { margin-bottom: 0.75rem; color: var(--text-primary); } +.markdown-prose ul, .markdown-prose ol { margin-bottom: 0.75rem; padding-left: 1.5rem; } +.markdown-prose li { margin-bottom: 0.25rem; color: var(--text-primary); } +.markdown-prose strong { font-weight: 600; } +.markdown-prose a { color: var(--color-lilac-dark); text-decoration: underline; } +.markdown-prose code { + background: var(--color-glass-subtle); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.875em; + font-family: 'SF Mono', 'Fira Code', monospace; +} +.markdown-prose blockquote { + border-left: 3px solid var(--color-lilac); + padding: var(--space-3) var(--space-4); + margin: 0.75rem 0; + background: var(--color-glass-subtle); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.markdown-prose blockquote p { margin: 0; color: var(--text-secondary); } +.markdown-prose hr { + border: none; + border-top: 1px solid var(--color-border-light); + margin: 1.5rem 0; +} + +/* Sidebar */ +.sidebar-link { + transition: all 0.15s ease; + border-left: 2px solid transparent; +} + +.sidebar-link:hover { + background: var(--color-glass-subtle); +} + +.sidebar-link--active { + border-left-color: var(--color-lilac-dark); + background: var(--color-glass-subtle); + color: var(--color-text-primary); + font-weight: 600; +} + +/* Playground */ +.playground-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + min-height: 300px; + overflow: hidden; +} + +@media (max-width: 768px) { + .playground-container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} + +/* Shimmer loading */ +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.shimmer-loading { + background: linear-gradient( + 90deg, + var(--color-glass-subtle) 25%, + var(--color-glass) 50%, + var(--color-glass-subtle) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/apps/notebook/src/app/layout.tsx b/apps/notebook/src/app/layout.tsx new file mode 100644 index 0000000..6669ed6 --- /dev/null +++ b/apps/notebook/src/app/layout.tsx @@ -0,0 +1,28 @@ +"use client"; + +import "./globals.css"; +import { ThemeProvider } from "@/hooks/use-theme"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + OpenGenerativeUI Notebook + + + + + + + {children} + + + ); +} diff --git a/apps/notebook/src/app/page.tsx b/apps/notebook/src/app/page.tsx new file mode 100644 index 0000000..aa1cb7a --- /dev/null +++ b/apps/notebook/src/app/page.tsx @@ -0,0 +1,5 @@ +import { NotebookShell } from "@/components/notebook-shell"; + +export default function Page() { + return ; +} diff --git a/apps/notebook/src/components/cell-renderer.tsx b/apps/notebook/src/components/cell-renderer.tsx new file mode 100644 index 0000000..b9cacb5 --- /dev/null +++ b/apps/notebook/src/components/cell-renderer.tsx @@ -0,0 +1,41 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { Cell } from "@/lib/types"; +import { MarkdownCell } from "./markdown-cell"; +import { CodeCell } from "./code-cell"; + +const PlaygroundCell = dynamic( + () => import("./playground-cell").then((m) => m.PlaygroundCell), + { + ssr: false, + loading: () => ( +
+
+
+ ), + } +); + +export function CellRenderer({ cell }: { cell: Cell }) { + switch (cell.type) { + case "markdown": + return ; + case "code": + return ( + + ); + case "playground": + return ( + + ); + } +} diff --git a/apps/notebook/src/components/chapter-header.tsx b/apps/notebook/src/components/chapter-header.tsx new file mode 100644 index 0000000..a6fa99a --- /dev/null +++ b/apps/notebook/src/components/chapter-header.tsx @@ -0,0 +1,30 @@ +"use client"; + +export function ChapterHeader({ + icon, + title, + description, +}: { + icon: string; + title: string; + description: string; +}) { + return ( +
+ + {icon} + +
+

{title}

+

+ {description} +

+
+
+ ); +} diff --git a/apps/notebook/src/components/code-cell.tsx b/apps/notebook/src/components/code-cell.tsx new file mode 100644 index 0000000..8e26d85 --- /dev/null +++ b/apps/notebook/src/components/code-cell.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getHighlighter } from "@/lib/highlight"; +import { CopyButton } from "./copy-button"; + +export function CodeCell({ + content, + language, + filename, +}: { + content: string; + language: string; + filename?: string; +}) { + const [html, setHtml] = useState(""); + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const check = () => + setIsDark(document.documentElement.classList.contains("dark")); + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + let cancelled = false; + getHighlighter().then((highlighter) => { + if (cancelled) return; + const theme = isDark ? "github-dark" : "github-light"; + const highlighted = highlighter.codeToHtml(content.trim(), { + lang: language, + theme, + }); + setHtml(highlighted); + }); + return () => { + cancelled = true; + }; + }, [content, language, isDark]); + + return ( +
+ {filename && ( +
+ {filename} +
+ )} + + {html ? ( +
+ ) : ( +
+
+            {content.trim()}
+          
+
+ )} +
+ ); +} diff --git a/apps/notebook/src/components/copy-button.tsx b/apps/notebook/src/components/copy-button.tsx new file mode 100644 index 0000000..d848575 --- /dev/null +++ b/apps/notebook/src/components/copy-button.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useState } from "react"; + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/apps/notebook/src/components/markdown-cell.tsx b/apps/notebook/src/components/markdown-cell.tsx new file mode 100644 index 0000000..ed900c9 --- /dev/null +++ b/apps/notebook/src/components/markdown-cell.tsx @@ -0,0 +1,31 @@ +"use client"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export function MarkdownCell({ content }: { content: string }) { + return ( +
+
+ ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {content} +
+
+
+ ); +} diff --git a/apps/notebook/src/components/notebook-shell.tsx b/apps/notebook/src/components/notebook-shell.tsx new file mode 100644 index 0000000..7736a04 --- /dev/null +++ b/apps/notebook/src/components/notebook-shell.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { chapters } from "@/content"; +import { SidebarNav } from "./sidebar-nav"; +import { ChapterHeader } from "./chapter-header"; +import { CellRenderer } from "./cell-renderer"; +import { ThemeToggle } from "./theme-toggle"; + +export function NotebookShell() { + return ( +
+ {/* Animated background */} +
+ + + + {/* Main content */} +
+ {/* Top bar */} +
+ +
+ + {/* Chapters */} +
+ {chapters.map((chapter) => ( +
+ +
+ {chapter.cells.map((cell) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/notebook/src/components/playground-cell.tsx b/apps/notebook/src/components/playground-cell.tsx new file mode 100644 index 0000000..214802b --- /dev/null +++ b/apps/notebook/src/components/playground-cell.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { + SandpackProvider, + SandpackCodeEditor, + SandpackPreview, + SandpackLayout, + useSandpack, +} from "@codesandbox/sandpack-react"; +import { useEffect, useState } from "react"; + +function ResetButton() { + const { sandpack } = useSandpack(); + return ( + + ); +} + +export function PlaygroundCell({ + files, + dependencies, + title, +}: { + files: Record; + dependencies?: Record; + title?: string; +}) { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const check = () => + setIsDark(document.documentElement.classList.contains("dark")); + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + const sandpackTheme = { + colors: { + surface1: isDark ? "#1a1a2e" : "#ffffff", + surface2: isDark ? "#0f0f1a" : "#f7f7f9", + surface3: isDark ? "#2a2a4a" : "#ebebf0", + clickable: isDark ? "#9ca3af" : "#6b7280", + base: isDark ? "#e5e7eb" : "#374151", + disabled: isDark ? "#4b5563" : "#d1d5db", + hover: isDark ? "#d1d5db" : "#111827", + accent: isDark ? "#BEC2FF" : "#9599CC", + error: "#ef4444", + errorSurface: isDark ? "#451a1a" : "#fef2f2", + }, + font: { + body: "'Plus Jakarta Sans', system-ui, sans-serif", + mono: "'SF Mono', 'Fira Code', monospace", + size: "13px", + lineHeight: "1.6", + }, + }; + + return ( +
+ {title && ( +
+ + ▶ + + {title} +
+ )} + + + + + + +
+ + + Edit the code and see changes live + +
+
+
+ ); +} diff --git a/apps/notebook/src/components/sidebar-nav.tsx b/apps/notebook/src/components/sidebar-nav.tsx new file mode 100644 index 0000000..0cdf082 --- /dev/null +++ b/apps/notebook/src/components/sidebar-nav.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Chapter } from "@/lib/types"; + +export function SidebarNav({ chapters }: { chapters: Chapter[] }) { + const [activeId, setActiveId] = useState(chapters[0]?.id ?? ""); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.find((e) => e.isIntersecting); + if (visible) setActiveId(visible.target.id); + }, + { rootMargin: "-80px 0px -60% 0px", threshold: 0.1 } + ); + + chapters.forEach((ch) => { + const el = document.getElementById(ch.id); + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, [chapters]); + + const scrollTo = (id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth" }); + setIsOpen(false); + }; + + return ( + <> + {/* Mobile hamburger */} + + + {/* Overlay for mobile */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + {/* Sidebar */} + + + ); +} diff --git a/apps/notebook/src/components/theme-toggle.tsx b/apps/notebook/src/components/theme-toggle.tsx new file mode 100644 index 0000000..e47743a --- /dev/null +++ b/apps/notebook/src/components/theme-toggle.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTheme } from "@/hooks/use-theme"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches); + + return ( + + ); +} diff --git a/apps/notebook/src/content/chapters/01-introduction.ts b/apps/notebook/src/content/chapters/01-introduction.ts new file mode 100644 index 0000000..6fbf22d --- /dev/null +++ b/apps/notebook/src/content/chapters/01-introduction.ts @@ -0,0 +1,74 @@ +import type { Chapter } from "@/lib/types"; + +export const introduction: Chapter = { + id: "introduction", + title: "Introduction", + description: "What is OpenGenerativeUI and how does it work?", + icon: "🪁", + cells: [ + { + type: "markdown", + id: "intro-what", + content: `# Welcome to OpenGenerativeUI + +OpenGenerativeUI is an open-source template that demonstrates **AI agent-driven UI** using [CopilotKit](https://copilotkit.ai) and [LangGraph](https://langchain-ai.github.io/langgraph/). + +Unlike traditional chatbots that only produce text, this project shows how an AI agent can **directly manipulate application state** — adding todos, generating charts, rendering interactive widgets — all while the user can interact with the same UI. + +## What makes this different? + +- **Agent-driven UI**: The AI doesn't just talk — it builds and modifies real UI components +- **Bidirectional state sync**: Both the user and the agent can modify the same state +- **Generative UI**: The agent produces rich, interactive components (charts, widgets, 3D visualizations) on the fly +- **Human-in-the-loop**: The agent can pause and ask for user input when needed`, + }, + { + type: "markdown", + id: "intro-architecture", + content: `## Architecture + +The project is a **Turborepo monorepo** with three main pieces: + +| Component | Tech | Purpose | +|-----------|------|---------| +| \`apps/app\` | Next.js 16, React 19 | Frontend with CopilotKit integration | +| \`apps/agent\` | LangGraph, Python | AI agent with tools and state | +| \`apps/mcp\` | MCP Server | Design system for external AI tools | + +The frontend communicates with the agent through CopilotKit's runtime, which bridges React hooks to the LangGraph agent backend.`, + }, + { + type: "code", + id: "intro-monorepo", + language: "bash", + filename: "Project Structure", + content: `apps/ +├── app/ # Next.js frontend (React 19, TailwindCSS 4) +│ └── src/ +│ ├── app/ # Pages & API routes +│ ├── components/ # UI components +│ └── hooks/ # CopilotKit hook registrations +├── agent/ # LangGraph Python agent +│ ├── main.py # Agent entry point +│ └── src/ +│ ├── todos.py # Todo tools & state schema +│ └── query.py # Data query tool +└── mcp/ # MCP design system server + └── skills/ # Agent skill documents`, + }, + { + type: "markdown", + id: "intro-flow", + content: `## How it flows + +1. User opens the app and sees a chat interface alongside a todo canvas +2. User types a message (e.g., "Add 3 todos for a weekend trip") +3. CopilotKit sends the message to the LangGraph agent +4. The agent calls the \`manage_todos\` tool to update state +5. State syncs back to the frontend via CopilotKit +6. The todo list UI updates reactively + +Let's dive into how each piece works, starting with **Agent State**.`, + }, + ], +}; diff --git a/apps/notebook/src/content/chapters/02-agent-state.ts b/apps/notebook/src/content/chapters/02-agent-state.ts new file mode 100644 index 0000000..7bfd675 --- /dev/null +++ b/apps/notebook/src/content/chapters/02-agent-state.ts @@ -0,0 +1,187 @@ +import type { Chapter } from "@/lib/types"; + +export const agentState: Chapter = { + id: "agent-state", + title: "Agent State", + description: + "The v2 pattern where state lives in the agent and syncs to the frontend.", + icon: "🔄", + cells: [ + { + type: "markdown", + id: "state-concept", + content: `# Agent State Pattern + +CopilotKit v2 introduces a powerful pattern: **state lives in the agent backend**, not in the frontend. The frontend simply reads and writes to the agent's state, and CopilotKit handles the sync. + +This means: +- The agent defines the **shape** of the state (a Python TypedDict) +- The agent modifies state through **tools** (using LangGraph's \`Command(update={...})\`) +- The frontend reads state via \`agent.state\` and writes via \`agent.setState()\` +- Changes propagate **bidirectionally** — user edits sync to the agent, agent edits sync to the UI`, + }, + { + type: "code", + id: "state-schema", + language: "python", + filename: "apps/agent/src/todos.py — State Schema", + content: `from typing import Literal, TypedDict + +class Todo(TypedDict): + id: str + title: str + description: str + emoji: str + status: Literal["pending", "completed"] + +class AgentState(TypedDict): + todos: list[Todo]`, + }, + { + type: "markdown", + id: "state-tools-intro", + content: `## Agent Tools + +The agent manipulates state through LangGraph tools. The key tool is \`manage_todos\`, which receives a list of todos and returns a \`Command\` that updates the agent's state:`, + }, + { + type: "code", + id: "state-manage-tool", + language: "python", + filename: "apps/agent/src/todos.py — manage_todos tool", + content: `import uuid +from langchain_core.tools import tool +from langchain_core.messages import ToolMessage +from langgraph.types import Command + +@tool +def manage_todos(todos: list[Todo], runtime) -> Command: + """Manage the current todos.""" + # Ensure each todo has a unique ID + for todo in todos: + if "id" not in todo or not todo["id"]: + todo["id"] = str(uuid.uuid4()) + + return Command(update={ + "todos": todos, + "messages": [ToolMessage( + content="Updated todos successfully.", + tool_call_id=runtime.tool_call_id, + )] + })`, + }, + { + type: "markdown", + id: "state-frontend-intro", + content: `## Frontend Integration + +On the frontend, the \`useAgent()\` hook provides access to the agent's state. The Canvas component reads todos from the agent and sends updates back:`, + }, + { + type: "code", + id: "state-frontend-code", + language: "tsx", + filename: "apps/app/src/components/canvas/index.tsx", + content: `import { useAgent } from "@copilotkit/react-core"; + +export function Canvas() { + const { agent } = useAgent(); + + return ( + agent.setState({ todos: updatedTodos })} + isAgentRunning={agent.isRunning} + /> + ); +}`, + }, + { + type: "playground", + id: "state-playground", + title: "Try it: State-driven Todo List", + files: { + "/App.js": `import { useState } from "react"; + +const initialTodos = [ + { id: "1", title: "Learn CopilotKit", emoji: "📚", status: "pending" }, + { id: "2", title: "Build an agent", emoji: "🤖", status: "pending" }, + { id: "3", title: "Ship it!", emoji: "🚀", status: "completed" }, +]; + +export default function App() { + // In the real app, this state comes from agent.state.todos + const [todos, setTodos] = useState(initialTodos); + + const toggle = (id) => { + setTodos(todos.map(t => + t.id === id + ? { ...t, status: t.status === "completed" ? "pending" : "completed" } + : t + )); + }; + + const addTodo = () => { + setTodos([...todos, { + id: String(Date.now()), + title: "New todo", + emoji: "✨", + status: "pending", + }]); + }; + + const pending = todos.filter(t => t.status === "pending"); + const completed = todos.filter(t => t.status === "completed"); + + return ( +
+

+ Agent State: Todos +

+
+ + +
+ +
+ ); +} + +function Column({ title, todos, onToggle }) { + return ( +
+

+ {title} ({todos.length}) +

+ {todos.map(t => ( +
onToggle(t.id)} + style={{ + padding: "10px 12px", marginBottom: 8, borderRadius: 8, + background: t.status === "completed" ? "#f0fdf4" : "#fff", + border: "1px solid #e5e7eb", cursor: "pointer", + opacity: t.status === "completed" ? 0.7 : 1, + textDecoration: t.status === "completed" ? "line-through" : "none", + fontSize: 14, + }} + > + {t.emoji} {t.title} +
+ ))} +
+ ); +}`, + }, + }, + ], +}; diff --git a/apps/notebook/src/content/chapters/03-generative-ui.ts b/apps/notebook/src/content/chapters/03-generative-ui.ts new file mode 100644 index 0000000..b464326 --- /dev/null +++ b/apps/notebook/src/content/chapters/03-generative-ui.ts @@ -0,0 +1,154 @@ +import type { Chapter } from "@/lib/types"; + +export const generativeUi: Chapter = { + id: "generative-ui", + title: "Generative UI", + description: + "How the agent generates rich, interactive UI components beyond text.", + icon: "🎨", + cells: [ + { + type: "markdown", + id: "genui-concept", + content: `# Generative UI + +The most powerful feature of OpenGenerativeUI is **Generative UI** — the agent doesn't just return text responses, it renders **actual React components** in the chat stream. + +This means the agent can produce: +- **Charts** (bar charts, pie charts) with real data +- **Interactive widgets** (HTML/SVG/3D rendered in sandboxed iframes) +- **Planning cards** that show the agent's thinking process +- **Forms** for human-in-the-loop interactions + +## How it works + +CopilotKit provides hooks to register "component tools" that the agent can call. When the agent calls one of these tools, instead of returning text, it renders a React component with the tool's parameters as props.`, + }, + { + type: "code", + id: "genui-register", + language: "tsx", + filename: "apps/app/src/hooks/use-generative-ui-examples.tsx", + content: `import { useComponent } from "@copilotkit/react-core"; +import { PieChart, PieChartProps } from "../components/generative-ui/charts/pie-chart"; +import { BarChart, BarChartProps } from "../components/generative-ui/charts/bar-chart"; +import { WidgetRenderer, WidgetProps } from "../components/generative-ui/widget-renderer"; + +export function useGenerativeUIExamples() { + // Register a pie chart component the agent can render + useComponent("pieChart", { + component: PieChart, + schema: PieChartProps, // Zod schema for props validation + description: "Render a pie chart with labeled data segments", + }); + + // Register a bar chart + useComponent("barChart", { + component: BarChart, + schema: BarChartProps, + description: "Render a bar chart with labeled data", + }); + + // Register the widget renderer (most flexible - renders any HTML) + useComponent("widgetRenderer", { + component: WidgetRenderer, + schema: WidgetProps, + description: "Render interactive HTML/SVG/3D visualizations", + }); +}`, + }, + { + type: "markdown", + id: "genui-props", + content: `## Props are validated with Zod + +Each component tool defines a **Zod schema** for its props. This serves two purposes: +1. The agent sees the schema as part of the tool description, so it knows exactly what data to provide +2. Props are validated before the component renders, preventing runtime errors`, + }, + { + type: "code", + id: "genui-zod", + language: "tsx", + filename: "Chart props schema", + content: `import { z } from "zod"; + +export const PieChartProps = z.object({ + title: z.string().describe("The title displayed above the chart"), + description: z.string().describe("A brief description of the data"), + data: z.array( + z.object({ + label: z.string().describe("Segment label"), + value: z.number().describe("Segment value"), + }) + ).describe("The data segments for the pie chart"), +});`, + }, + { + type: "playground", + id: "genui-chart-playground", + title: "Try it: Build a Chart Component", + dependencies: { recharts: "2.12.7" }, + files: { + "/App.js": `import { PieChart, Pie, Cell, Legend, Tooltip, ResponsiveContainer } from "recharts"; + +const COLORS = ["#9599CC", "#85E0CE", "#BEC2FF", "#A8E9DC", "#D4D7FF", "#1B936F"]; + +// Try editing this data! +const data = [ + { label: "React", value: 40 }, + { label: "Python", value: 25 }, + { label: "TypeScript", value: 20 }, + { label: "CSS", value: 15 }, +]; + +export default function App() { + return ( +
+

+ Languages Used +

+

+ Distribution of languages in the codebase +

+ + + + {data.map((_, i) => ( + + ))} + + + + + +
+ ); +}`, + }, + }, + { + type: "markdown", + id: "genui-widget", + content: `## The Widget Renderer + +The most flexible generative UI component is the **Widget Renderer**. It takes arbitrary HTML/SVG/CSS and renders it in a **sandboxed iframe** with: + +- A full design system (CSS variables, fonts) +- Import maps for libraries (Three.js, D3, GSAP, Chart.js) +- A communication bridge (\`window.sendPrompt()\` to send messages back to the agent) +- Auto-resizing based on content height +- Efficient streaming updates via Idiomorph DOM diffing + +This is what enables the agent to produce 3D visualizations, interactive diagrams, and custom widgets on the fly.`, + }, + ], +}; diff --git a/apps/notebook/src/content/chapters/04-copilotkit-hooks.ts b/apps/notebook/src/content/chapters/04-copilotkit-hooks.ts new file mode 100644 index 0000000..167a245 --- /dev/null +++ b/apps/notebook/src/content/chapters/04-copilotkit-hooks.ts @@ -0,0 +1,254 @@ +import type { Chapter } from "@/lib/types"; + +export const copilotKitHooks: Chapter = { + id: "copilotkit-hooks", + title: "CopilotKit Hooks", + description: + "The React hooks that connect your frontend to the AI agent.", + icon: "🪝", + cells: [ + { + type: "markdown", + id: "hooks-overview", + content: `# CopilotKit Hooks + +CopilotKit provides several React hooks that form the bridge between your frontend and the AI agent. Here's an overview of the key hooks used in OpenGenerativeUI: + +| Hook | Purpose | +|------|---------| +| \`useAgent()\` | Access agent state, send messages, check if agent is running | +| \`useComponent()\` | Register a React component the agent can render in chat | +| \`useFrontendTool()\` | Register a JS function the agent can call in the browser | +| \`useRenderTool()\` | Register a tool that renders UI while executing | +| \`useHumanInTheLoop()\` | Create async tools that pause for user input |`, + }, + { + type: "markdown", + id: "hooks-useagent", + content: `## useAgent() + +The core hook. It gives you access to the agent's state and lets you interact with it: + +- \`agent.state\` — The current agent state (typed by your AgentState schema) +- \`agent.setState()\` — Update the agent's state from the frontend +- \`agent.isRunning\` — Whether the agent is currently processing +- \`agent.sendMessage()\` — Send a message to the agent programmatically`, + }, + { + type: "code", + id: "hooks-useagent-code", + language: "tsx", + filename: "Using useAgent()", + content: `import { useAgent } from "@copilotkit/react-core"; + +function TodoCanvas() { + const { agent } = useAgent(); + + // Read state + const todos = agent.state?.todos || []; + + // Write state (syncs to agent backend) + const addTodo = (todo) => { + agent.setState({ todos: [...todos, todo] }); + }; + + // Check if agent is working + if (agent.isRunning) { + return ; + } + + return ; +}`, + }, + { + type: "markdown", + id: "hooks-usecomponent", + content: `## useComponent() + +Registers a React component as a tool the agent can call. When the agent calls this tool, instead of returning text, CopilotKit renders your component inline in the chat with the agent-provided props:`, + }, + { + type: "code", + id: "hooks-usecomponent-code", + language: "tsx", + filename: "Registering a component tool", + content: `import { useComponent } from "@copilotkit/react-core"; +import { z } from "zod"; + +function MyChart({ title, data }) { + return ( +
+

{title}

+ {/* render chart with data */} +
+ ); +} + +// In your hook setup: +useComponent("myChart", { + component: MyChart, + schema: z.object({ + title: z.string(), + data: z.array(z.object({ + label: z.string(), + value: z.number(), + })), + }), + description: "Render a custom chart", +});`, + }, + { + type: "markdown", + id: "hooks-usefrontendtool", + content: `## useFrontendTool() + +Registers a JavaScript function that the agent can call to perform actions in the browser. Unlike \`useComponent()\`, this doesn't render UI — it executes logic:`, + }, + { + type: "code", + id: "hooks-usefrontendtool-code", + language: "tsx", + filename: "Frontend tool: theme toggle", + content: `import { useFrontendTool } from "@copilotkit/react-core"; + +useFrontendTool("toggleTheme", { + description: "Toggle between light and dark theme", + schema: z.object({ + theme: z.enum(["light", "dark"]).describe("The theme to switch to"), + }), + handler: ({ theme }) => { + document.documentElement.classList.remove("light", "dark"); + document.documentElement.classList.add(theme); + return \`Switched to \${theme} mode\`; + }, +});`, + }, + { + type: "playground", + id: "hooks-playground", + title: "Try it: Simulated Agent Hooks", + files: { + "/App.js": `import { useState, useCallback } from "react"; + +// Simulating what useAgent() provides +function useSimulatedAgent() { + const [state, setState] = useState({ + todos: [ + { id: "1", title: "Read the docs", emoji: "📖", status: "pending" }, + ], + theme: "light", + }); + const [isRunning, setIsRunning] = useState(false); + + const simulateAgentAction = useCallback(async () => { + setIsRunning(true); + // Simulate agent thinking... + await new Promise(r => setTimeout(r, 1500)); + setState(prev => ({ + ...prev, + todos: [ + ...prev.todos, + { + id: String(Date.now()), + title: "Agent-added task", + emoji: "🤖", + status: "pending", + }, + ], + })); + setIsRunning(false); + }, []); + + return { state, setState, isRunning, simulateAgentAction }; +} + +export default function App() { + const { state, setState, isRunning, simulateAgentAction } = useSimulatedAgent(); + + const toggleTodo = (id) => { + setState(prev => ({ + ...prev, + todos: prev.todos.map(t => + t.id === id + ? { ...t, status: t.status === "completed" ? "pending" : "completed" } + : t + ), + })); + }; + + return ( +
+
+

+ Hook Simulation +

+ {isRunning && ( + + Agent working... + + )} +
+ +
+ {state.todos.map(t => ( +
toggleTodo(t.id)} + style={{ + padding: "10px 12px", marginBottom: 6, borderRadius: 8, + background: t.status === "completed" ? "#f0fdf4" : "#fff", + border: "1px solid #e5e7eb", cursor: "pointer", fontSize: 14, + textDecoration: t.status === "completed" ? "line-through" : "none", + }} + > + {t.emoji} {t.title} +
+ ))} +
+ +
+ + +
+ +

+ Both buttons modify the same state — just like agent.setState() and user interactions do in the real app. +

+
+ ); +}`, + }, + }, + ], +}; diff --git a/apps/notebook/src/content/chapters/05-frontend-tools.ts b/apps/notebook/src/content/chapters/05-frontend-tools.ts new file mode 100644 index 0000000..ca9e24b --- /dev/null +++ b/apps/notebook/src/content/chapters/05-frontend-tools.ts @@ -0,0 +1,221 @@ +import type { Chapter } from "@/lib/types"; + +export const frontendTools: Chapter = { + id: "frontend-tools", + title: "Frontend Tools", + description: + "How the agent calls JavaScript functions in the browser.", + icon: "🛠", + cells: [ + { + type: "markdown", + id: "tools-concept", + content: `# Frontend Tools + +Frontend tools let the agent **execute JavaScript in the browser**. This is different from component tools (which render UI) — frontend tools perform actions like: + +- Toggling theme (light/dark mode) +- Navigating to a page +- Triggering animations +- Reading browser state (viewport size, scroll position) +- Modifying DOM elements + +The agent sees these tools in its tool list and can call them as needed during a conversation.`, + }, + { + type: "code", + id: "tools-toggle-theme", + language: "tsx", + filename: "apps/app/src/hooks/use-generative-ui-examples.tsx — toggleTheme", + content: `import { useFrontendTool } from "@copilotkit/react-core"; +import { z } from "zod"; +import { useTheme } from "@/hooks/use-theme"; + +// Inside the hook setup function: +const { setTheme } = useTheme(); + +useFrontendTool("toggleTheme", { + description: "Toggle between light and dark mode", + schema: z.object({ + theme: z.enum(["light", "dark"]), + }), + handler: ({ theme }) => { + setTheme(theme); + return \`Theme switched to \${theme}\`; + }, +});`, + }, + { + type: "markdown", + id: "tools-render", + content: `## Render Tools (useRenderTool) + +Sometimes you want a tool that **both executes logic AND renders UI** while it runs. \`useRenderTool()\` shows a component during tool execution — perfect for progress indicators, previews, or step-by-step visualizations:`, + }, + { + type: "code", + id: "tools-render-code", + language: "tsx", + filename: "Plan visualization render tool", + content: `import { useRenderTool } from "@copilotkit/react-core"; +import { PlanCard } from "../components/generative-ui/plan-card"; + +useRenderTool("plan_visualization", { + description: "Show planning progress to the user", + schema: z.object({ + status: z.string(), + approach: z.string(), + technology: z.string(), + keyElements: z.array(z.string()), + }), + component: PlanCard, + // Tool can also return data to the agent + handler: (props) => \`Plan displayed: \${props.approach}\`, +});`, + }, + { + type: "playground", + id: "tools-playground", + title: "Try it: Frontend Tool Simulation", + files: { + "/App.js": `import { useState } from "react"; + +// Simulating frontend tools the agent can call +const tools = { + toggleTheme: { + description: "Toggle between light and dark mode", + handler: (args, setState) => { + setState(prev => ({ ...prev, theme: args.theme })); + return \`Switched to \${args.theme} mode\`; + }, + }, + setBackground: { + description: "Change the background color", + handler: (args, setState) => { + setState(prev => ({ ...prev, bgColor: args.color })); + return \`Background set to \${args.color}\`; + }, + }, + addNotification: { + description: "Show a notification to the user", + handler: (args, setState) => { + setState(prev => ({ + ...prev, + notifications: [...prev.notifications, args.message], + })); + return "Notification displayed"; + }, + }, +}; + +export default function App() { + const [state, setState] = useState({ + theme: "light", + bgColor: "#ffffff", + notifications: [], + log: [], + }); + + const callTool = (name, args) => { + const result = tools[name].handler(args, setState); + setState(prev => ({ + ...prev, + log: [...prev.log, \`Agent called \${name} → \${result}\`], + })); + }; + + const isDark = state.theme === "dark"; + + return ( +
+

+ Frontend Tool Playground +

+ + {/* Notifications */} + {state.notifications.map((msg, i) => ( +
+ {msg} +
+ ))} + + {/* Tool buttons (simulating agent tool calls) */} +

+ Click buttons to simulate the agent calling frontend tools: +

+
+ callTool("toggleTheme", { theme: isDark ? "light" : "dark" })} + isDark={isDark} + /> + callTool("setBackground", { color: "#fce7f3" })} + isDark={isDark} + /> + callTool("setBackground", { color: "#dbeafe" })} + isDark={isDark} + /> + callTool("addNotification", { message: "Hello from the agent! 👋" })} + isDark={isDark} + /> +
+ + {/* Tool call log */} + {state.log.length > 0 && ( +
+
+ Tool Call Log: +
+ {state.log.map((entry, i) => ( +
+ {entry} +
+ ))} +
+ )} +
+ ); +} + +function ToolButton({ label, onClick, isDark }) { + return ( + + ); +}`, + }, + }, + ], +}; diff --git a/apps/notebook/src/content/chapters/06-widget-renderer.ts b/apps/notebook/src/content/chapters/06-widget-renderer.ts new file mode 100644 index 0000000..fb89f1c --- /dev/null +++ b/apps/notebook/src/content/chapters/06-widget-renderer.ts @@ -0,0 +1,218 @@ +import type { Chapter } from "@/lib/types"; + +export const widgetRenderer: Chapter = { + id: "widget-renderer", + title: "Widget Renderer", + description: + "The sandboxed iframe that renders agent-generated HTML, SVG, and 3D content.", + icon: "🖼", + cells: [ + { + type: "markdown", + id: "widget-concept", + content: `# Widget Renderer + +The Widget Renderer is the most flexible generative UI component. It takes **arbitrary HTML/CSS/JS** from the agent and renders it in a **sandboxed iframe**. This is what enables the agent to produce: + +- 3D scenes (Three.js) +- Data visualizations (D3, Chart.js) +- Animated graphics (GSAP) +- Interactive SVG diagrams +- Custom form UIs +- Anything expressible in HTML + +## Security Model + +The iframe is sandboxed with \`allow-scripts allow-same-origin\`, meaning: +- Scripts can run inside the iframe +- The iframe cannot navigate the parent page +- The iframe cannot access parent cookies or storage +- Communication happens only through \`postMessage\``, + }, + { + type: "code", + id: "widget-bridge", + language: "typescript", + filename: "Bridge API (injected into iframe)", + content: `// The bridge script injected into every widget iframe: + +// Send a new prompt to the agent from inside the widget +window.sendPrompt = (text: string) => { + window.parent.postMessage( + { type: "send-prompt", prompt: text }, + "*" + ); +}; + +// Open an external link (handled by parent) +window.openLink = (url: string) => { + window.parent.postMessage( + { type: "open-link", url }, + "*" + ); +}; + +// Auto-resize: report height changes to parent +const observer = new ResizeObserver(() => { + window.parent.postMessage( + { type: "resize", height: document.body.scrollHeight }, + "*" + ); +}); +observer.observe(document.body);`, + }, + { + type: "markdown", + id: "widget-streaming", + content: `## Streaming Updates + +When the agent streams HTML content, the widget renderer uses **Idiomorph** for efficient DOM diffing. Instead of replacing the entire iframe content on each chunk, Idiomorph morphs the existing DOM to match the new HTML — preserving interactive state, animations, and scroll position. + +The flow works like this: + +1. Agent starts streaming HTML → Empty iframe shell loaded +2. Each HTML chunk → Sent via \`postMessage\` to the iframe +3. Inside the iframe → Idiomorph morphs the DOM (minimal changes) +4. Agent finishes → Final HTML applied, export overlay appears + +## Import Maps + +The iframe includes import maps so widgets can use ES modules directly:`, + }, + { + type: "code", + id: "widget-imports", + language: "html", + filename: "Import map (injected into iframe head)", + content: ``, + }, + { + type: "playground", + id: "widget-playground", + title: "Try it: Mini Widget Renderer", + files: { + "/App.js": `import { useState, useRef, useEffect } from "react"; + +const defaultHTML = \`
+

+ Hello from the Widget! +

+

+ This HTML is rendered in an iframe, just like the real widget renderer. +

+
+
+
+
+
+ +
\`; + +export default function App() { + const [html, setHtml] = useState(defaultHTML); + const iframeRef = useRef(null); + + useEffect(() => { + if (!iframeRef.current) return; + const doc = iframeRef.current.contentDocument; + if (!doc) return; + doc.open(); + doc.write(\` + + + + +\${html} +\`); + doc.close(); + }, [html]); + + return ( +
+
+
+
+ Edit HTML +
+