diff --git a/.chalk/reviews/sessions/pr-13/handoff.md b/.chalk/reviews/sessions/pr-13/handoff.md new file mode 100644 index 0000000..33dbdc3 --- /dev/null +++ b/.chalk/reviews/sessions/pr-13/handoff.md @@ -0,0 +1,46 @@ +# Handoff + +## Scope +- Item: PR #13 — fix: resolve lint errors in widget-renderer.tsx and layout.tsx +- Branch: fix/lint-errors-2026-03-18 +- Commit: b834bc2 +- Goal: Fix lint errors introduced in prior commits and add community/docs files + +## What Changed +- Added CODE_OF_CONDUCT.md (new community document) +- Added CONTRIBUTING.md (new contributor guide) +- Added Makefile with dev/build/lint/clean targets +- Updated README.md Quick Start to use Makefile commands +- Updated asset video link in README.md +- layout.tsx: Migrated from Google Fonts `` tag to Next.js `next/font/google` (Plus_Jakarta_Sans) +- widget-renderer.tsx: Renamed `description` prop to `_description` to suppress unused-var lint warning +- widget-renderer.tsx: Changed `useEffect` → `useLayoutEffect` for iframe srcdoc mutation +- widget-renderer.tsx: Refactored loading phrase reset to use `prevActiveRef` to avoid reset-on-every-render + +## Files Changed +- CODE_OF_CONDUCT.md (+117) +- CONTRIBUTING.md (+207) +- Makefile (+36) +- README.md (+29, -7) +- apps/app/src/app/layout.tsx (+9, -5) +- apps/app/src/components/generative-ui/widget-renderer.tsx (+14, -4) + +## Risk Areas +- **Lint still fails** — the PR claims to fix lint errors but `pnpm lint` exits with 2 errors and 1 warning after the changes +- CONTRIBUTING.md has inconsistent repo URLs (mixes `OpenGenerativeUI` and `open-generative-ui` casing) +- Font variable `--font-plus-jakarta` is set on body but may not be referenced in Tailwind/CSS config +- `useLayoutEffect` calling `setLoaded(false)` + `setHeight(0)` still triggers `react-hooks/set-state-in-effect` lint error + +## Commands Run +- `pnpm lint` — **FAILED** (2 errors, 1 warning in widget-renderer.tsx) + +## Known Gaps +- Lint errors not actually resolved despite PR title claiming they are +- No TypeScript build check run +- No tests added or run + +## Suggested Focus For Reviewers +1. **[BLOCKING]** Lint failure — PR goal unmet; `setIndex(0)` in useEffect (line 417) and `setLoaded(false)` in useLayoutEffect (line 465) still violate `react-hooks/set-state-in-effect` +2. **[BLOCKING]** `_description` warning still present despite renaming +3. CONTRIBUTING.md URL inconsistency (`OpenGenerativeUI` vs `open-generative-ui`) +4. Font variable wiring — confirm `--font-plus-jakarta` is consumed in Tailwind config diff --git a/.chalk/reviews/sessions/pr-13/maintainer-reply.md b/.chalk/reviews/sessions/pr-13/maintainer-reply.md new file mode 100644 index 0000000..ec73233 --- /dev/null +++ b/.chalk/reviews/sessions/pr-13/maintainer-reply.md @@ -0,0 +1,157 @@ +# Maintainer Review Reply — PR #13 + +> fix: resolve lint errors in widget-renderer.tsx and layout.tsx + +--- + +Thanks for the PR! Great that you're tackling the lint errors — this is exactly the kind of housekeeping that keeps the codebase healthy. + +I went through the changes and have a few things to address before we merge. Two of them are blockers because they still trip the lint gate we're trying to fix. + +--- + +### Blocking + +**`react-hooks/set-state-in-effect` — two violations remain in `widget-renderer.tsx`** + +`eslint-plugin-react-hooks@7` introduced a new rule that disallows synchronous `setState` calls directly inside an effect body. The PR currently has two spots that trigger it: + +1. **~line 417** — `setIndex(0)` inside `useEffect`. The intent (reset to 0 when `active` flips true) is right, but the reset needs to happen outside the synchronous effect body. One clean approach: track `active` in a ref and only call `setIndex` inside the `setInterval` callback — start the interval from index 0 and let it cycle from there. + +2. **~lines 465–466** — `setLoaded(false)` and `setHeight(0)` inside `useLayoutEffect`. These could be combined into a single `useReducer` reset, or the reset state stored in refs and derived at render time. + +These two cause CI to exit non-zero, so the PR goal isn't quite met yet. + +--- + +### Minor (non-blocking, but worth cleaning up) + +**~line 431** — `_description` in the destructuring still produces an unused-var warning. The simplest fix is to just omit it from destructuring (`{ title, html }`). The prop can stay in `WidgetRendererProps` for API compatibility — only the binding needs to go. + +**`globals.css` font variable** — `layout.tsx` correctly injects `--font-plus-jakarta` onto ``, but `globals.css` still references `'Plus Jakarta Sans'` as a hardcoded string instead of `var(--font-plus-jakarta)`. This means Next.js's self-hosted font optimization is wired up but never actually used. Easy one-liner fix: +```css +--font-family: var(--font-plus-jakarta), system-ui, sans-serif; +``` + +--- + +Once the two `set-state-in-effect` issues are sorted, this should be good to go. Happy to help think through the hook refactors if useful — the `useLayoutEffect` one in particular has a few valid approaches depending on how you want to handle the reset. + +--- + +
+Ask AI to Fix These Issues + +Copy and paste the prompt below into Claude, ChatGPT, Cursor, or any AI assistant. It includes full context so the AI can fix all remaining lint failures without additional back-and-forth. + +--- + +```` +I need help fixing 3 remaining ESLint failures in a Next.js / React project. +The branch is `fix/lint-errors-2026-03-18` on https://github.com/CopilotKit/OpenGenerativeUI. +Running `pnpm lint` currently exits with 2 errors and 1 warning — all in one file: + apps/app/src/components/generative-ui/widget-renderer.tsx + +The ESLint config extends `eslint-config-next/core-web-vitals` and `eslint-config-next/typescript`, +which pulls in `eslint-plugin-react-hooks@7.0.1`. That version introduced a new rule — +`react-hooks/set-state-in-effect` — that flags any synchronous setState call made directly inside +an effect body (useEffect or useLayoutEffect). + +─── FAILURE 1 (Error) — widget-renderer.tsx ~line 414 ─────────────────────── + +The `useLoadingPhrase` hook manages a cycling loading message. When `active` flips from false → true +it should reset to phrase index 0, then start an interval to cycle phrases every 1800ms. + +Current (broken) code: +```tsx +function useLoadingPhrase(active: boolean) { + const [index, setIndex] = useState(0); + const prevActiveRef = useRef(null); + + useEffect(() => { + if (active && !prevActiveRef.current) { + setIndex(0); // ← lint error: setState inside effect body + } + prevActiveRef.current = active; + + if (!active) return; + const interval = setInterval(() => { + setIndex((i) => (i + 1) % LOADING_PHRASES.length); + }, 1800); + return () => clearInterval(interval); + }, [active]); + + return LOADING_PHRASES[index]; +} +``` + +Fix goal: eliminate the synchronous `setIndex(0)` call from inside the effect body while keeping +the same observable behaviour (index resets to 0 when active starts, then cycles). + +─── FAILURE 2 (Error) — widget-renderer.tsx ~line 460 ─────────────────────── + +The `WidgetRenderer` component writes HTML into a sandboxed iframe imperatively (to preserve iframe +JS state across React re-renders). When `html` changes it resets `loaded` and `height` back to +false/0 so the loading overlay re-appears. + +Current (broken) code: +```tsx +useLayoutEffect(() => { + if (!html || !iframeRef.current) return; + if (html === committedHtmlRef.current) return; + committedHtmlRef.current = html; + iframeRef.current.srcdoc = assembleDocument(html); + setLoaded(false); // ← lint error: setState inside effect body + setHeight(0); // ← lint error: setState inside effect body +}, [html]); +``` + +State declarations (for context): +```tsx +const [height, setHeight] = useState(0); +const [loaded, setLoaded] = useState(false); +``` + +Fix goal: eliminate synchronous setState calls from inside the useLayoutEffect body while keeping +the reset-on-html-change behaviour. The iframe srcdoc write must stay synchronous (that's why +useLayoutEffect is correct here). Consider combining loaded+height into a single reducer, or +storing reset state in refs and deriving render values from them. + +─── FAILURE 3 (Warning) — widget-renderer.tsx ~line 431 ─────────────────────── + +The `description` prop is accepted in the function signature but never used. A previous attempt +renamed it to `_description` but the project's ESLint config does not have `argsIgnorePattern: "^_"` +configured so the warning still fires. + +Current (broken) code: +```tsx +export function WidgetRenderer({ title, _description, html }: WidgetRendererProps) { +``` + +Fix goal: silence the unused-var warning. The simplest fix is to remove `description` / `_description` +from the destructuring entirely (just use `{ title, html }`). The prop can remain in the +`WidgetRendererProps` type for API compatibility — only the destructuring binding needs to change. + +─── Bonus fix (globals.css) ──────────────────────────────────────────────── + +In `apps/app/src/app/globals.css`, the font-family custom property currently reads: +```css +--font-family: 'Plus Jakarta Sans', system-ui, sans-serif; +``` + +Change it to consume the Next.js font CSS variable injected by layout.tsx: +```css +--font-family: var(--font-plus-jakarta), system-ui, sans-serif; +``` + +─── Constraints ─────────────────────────────────────────────────────────────── + +- Do NOT add eslint-disable comments as a workaround — fix the code properly. +- Keep all existing observable behaviour identical (loading phrase cycling, iframe reload guard, + loaded/height reset on html change). +- After your changes, `pnpm lint` must pass with 0 errors and 0 warnings. +- Only edit `apps/app/src/components/generative-ui/widget-renderer.tsx` and + `apps/app/src/app/globals.css`. +```` + +
diff --git a/.chalk/reviews/sessions/pr-13/self.findings.pr-13.fix-lint-errors-2026-03-18.md b/.chalk/reviews/sessions/pr-13/self.findings.pr-13.fix-lint-errors-2026-03-18.md new file mode 100644 index 0000000..63110d1 --- /dev/null +++ b/.chalk/reviews/sessions/pr-13/self.findings.pr-13.fix-lint-errors-2026-03-18.md @@ -0,0 +1,142 @@ +# Chalk Agent Self-Review Findings + +- Branch: fix/lint-errors-2026-03-18 +- Commit: b834bc2 +- Base: 6df7350 (merge-base with main) +- Session: pr-13 +- PR: #13 — fix: resolve lint errors in widget-renderer.tsx and layout.tsx +- Date: 2026-03-19 UTC + +--- + +## Verdict +- Block merge: **yes** +- Blocking findings: P0=2, P1=1 + +--- + +## Findings + +| ID | Severity | Category | File:Line | Issue | Failure mode | Suggested fix | Confidence | +|---|---|---|---|---|---|---|---| +| F-01 | P0 | Lint / Correctness | [widget-renderer.tsx:417](apps/app/src/components/generative-ui/widget-renderer.tsx#L417) | `setIndex(0)` called synchronously inside `useEffect` body — violates `react-hooks/set-state-in-effect`; lint exits non-zero | CI lint gate fails; PR goal unmet | Move reset out of effect: use a derived index or call `setIndex` only inside the `setInterval` callback (start at 0, interval handles cycling). Or split into two `useEffect`s — one that watches `active` going true and one that runs the interval. | High | +| F-02 | P0 | Lint / Correctness | [widget-renderer.tsx:465-466](apps/app/src/components/generative-ui/widget-renderer.tsx#L465) | `setLoaded(false)` and `setHeight(0)` called synchronously inside `useLayoutEffect` body — same rule violation | CI lint gate fails | Store `loaded`/`height` as refs for the reset path, or batch by computing a single state object. Since these resets only happen when `html` changes, a combined reducer state (`{ loaded: bool, height: number }`) reset in a single `setState` call may satisfy the linter. Alternatively, `startTransition` wrapping won't help here — the real fix is avoiding direct setState in the effect body. | High | +| F-03 | P1 | Lint / Style | [widget-renderer.tsx:431](apps/app/src/components/generative-ui/widget-renderer.tsx#L431) | `_description` renamed with underscore prefix to suppress unused-var, but ESLint still warns — the project's `@typescript-eslint/no-unused-vars` config doesn't honor the `_` prefix convention here | Warning remains in lint output | Simply omit `description` from the destructuring: `{ title, html }`. If the prop must remain in the TypeScript type for API compatibility, add `// eslint-disable-next-line @typescript-eslint/no-unused-vars` or configure `argsIgnorePattern: "^_"` in the ESLint config. | High | +| F-04 | P2 | Correctness / DX | [layout.tsx:13](apps/app/src/app/layout.tsx#L13) & [globals.css:63](apps/app/src/app/globals.css#L63) | Next.js font variable `--font-plus-jakarta` is injected onto `` but `globals.css` references the font via hardcoded string `'Plus Jakarta Sans'` in `--font-family`, not `var(--font-plus-jakarta)`. Next.js font optimization (self-hosting, preloading, CLS prevention) is wired up but the CSS never consumes it. | Font still loads (hardcoded name falls back to Google CDN or cached), but Next.js's optimized self-hosted font is unused — the whole point of migrating away from the `` tag is lost. | In `globals.css` change `--font-family: 'Plus Jakarta Sans', ...` to `--font-family: var(--font-plus-jakarta), system-ui, sans-serif` | High | +| ~~F-05~~ | ~~P2~~ | ~~Docs~~ | ~~CONTRIBUTING.md~~ | ~~RETRACTED~~ — `CopilotKit/OpenGenerativeUI` is the correct official repo slug. Links are accurate. | — | — | — | + +--- + +## Testing Gaps +- No tests added or modified; the two changed source files have no automated test coverage +- No TypeScript build (`pnpm build`) was run to confirm type safety after prop rename + +## Open Questions +- Should `react-hooks/set-state-in-effect` be disabled for specific lines as a short-term fix, or is there appetite to restructure the hooks? The `useLoadingPhrase` and `useLayoutEffect` cases have different ideal solutions. +- Is `WidgetRendererProps` exported or used externally? If `description` is part of a public API contract (e.g. CopilotKit tool parameter schema), removing it from destructuring is safe but the prop type should remain for consumers. + +--- + +
+Ask AI to Resolve These Issues + +Copy and paste the prompt below into Claude, ChatGPT, Cursor, or any AI assistant. It includes full context so the AI can fix all remaining lint failures without additional back-and-forth. + +--- + +```` +I need help fixing 3 remaining ESLint failures in a Next.js / React project. +The branch is `fix/lint-errors-2026-03-18` on https://github.com/CopilotKit/OpenGenerativeUI. +Running `pnpm lint` currently exits with 2 errors and 1 warning — all in one file: + apps/app/src/components/generative-ui/widget-renderer.tsx + +The ESLint config extends `eslint-config-next/core-web-vitals` and `eslint-config-next/typescript`, +which pulls in `eslint-plugin-react-hooks@7.0.1`. That version introduced a new rule — +`react-hooks/set-state-in-effect` — that flags any synchronous setState call made directly inside +an effect body (useEffect or useLayoutEffect). + +─── FAILURE 1 (Error) — widget-renderer.tsx ~line 414 ─────────────────────── + +The `useLoadingPhrase` hook manages a cycling loading message. When `active` flips from false → true +it should reset to phrase index 0, then start an interval to cycle phrases every 1800ms. + +Current (broken) code: +```tsx +function useLoadingPhrase(active: boolean) { + const [index, setIndex] = useState(0); + const prevActiveRef = useRef(null); + + useEffect(() => { + if (active && !prevActiveRef.current) { + setIndex(0); // ← lint error: setState inside effect body + } + prevActiveRef.current = active; + + if (!active) return; + const interval = setInterval(() => { + setIndex((i) => (i + 1) % LOADING_PHRASES.length); + }, 1800); + return () => clearInterval(interval); + }, [active]); + + return LOADING_PHRASES[index]; +} +``` + +Fix goal: eliminate the synchronous `setIndex(0)` call from inside the effect body while keeping +the same observable behaviour (index resets to 0 when active starts, then cycles). + +─── FAILURE 2 (Error) — widget-renderer.tsx ~line 460 ─────────────────────── + +The `WidgetRenderer` component writes HTML into a sandboxed iframe imperatively (to preserve iframe +JS state across React re-renders). When `html` changes it resets `loaded` and `height` back to +false/0 so the loading overlay re-appears. + +Current (broken) code: +```tsx +useLayoutEffect(() => { + if (!html || !iframeRef.current) return; + if (html === committedHtmlRef.current) return; + committedHtmlRef.current = html; + iframeRef.current.srcdoc = assembleDocument(html); + setLoaded(false); // ← lint error: setState inside effect body + setHeight(0); // ← lint error: setState inside effect body +}, [html]); +``` + +State declarations (for context): +```tsx +const [height, setHeight] = useState(0); +const [loaded, setLoaded] = useState(false); +``` + +Fix goal: eliminate synchronous setState calls from inside the useLayoutEffect body while keeping +the reset-on-html-change behaviour. The iframe srcdoc write must stay synchronous (that's why +useLayoutEffect is correct here). Consider combining loaded+height into a single reducer, or +storing reset state in refs and deriving render values from them. + +─── FAILURE 3 (Warning) — widget-renderer.tsx ~line 431 ─────────────────────── + +The `description` prop is accepted in the function signature but never used. A previous attempt +renamed it to `_description` but the project's ESLint config does not have `argsIgnorePattern: "^_"` +configured so the warning still fires. + +Current (broken) code: +```tsx +export function WidgetRenderer({ title, _description, html }: WidgetRendererProps) { +``` + +Fix goal: silence the unused-var warning. The simplest fix is to remove `description` / `_description` +from the destructuring entirely (just use `{ title, html }`). The prop can remain in the +`WidgetRendererProps` type for API compatibility — only the destructuring binding needs to change. + +─── Constraints ─────────────────────────────────────────────────────────────── + +- Do NOT add eslint-disable comments as a workaround — fix the code properly. +- Keep all existing observable behaviour identical (loading phrase cycling, iframe reload guard, + loaded/height reset on html change). +- After your changes, `pnpm lint` must pass with 0 errors and 0 warnings. +- Only edit `apps/app/src/components/generative-ui/widget-renderer.tsx`. +```` + +
diff --git a/apps/agent/main.py b/apps/agent/main.py index 35aadda..c92fb1c 100644 --- a/apps/agent/main.py +++ b/apps/agent/main.py @@ -9,15 +9,17 @@ from src.query import query_data from src.todos import AgentState, todo_tools +from src.document import document_tools from src.form import generate_form -from skills import load_all_skills +from skills import load_all_skills, load_skill -# Load all visualization skills -_skills_text = load_all_skills() +# Load visualization skills (excalidraw loaded separately — it targets MCP tools, not widgetRenderer) +_widget_skills_text = load_all_skills(exclude=["excalidraw-diagram-skill"]) +_excalidraw_skill_text = load_skill("excalidraw-diagram-skill") agent = create_agent( model=ChatOpenAI(model="gpt-5.4-2026-03-05"), - tools=[query_data, *todo_tools, generate_form], + tools=[query_data, *todo_tools, *document_tools, generate_form], middleware=[CopilotKitMiddleware()], state_schema=AgentState, system_prompt=f""" @@ -46,8 +48,34 @@ Follow the skills below for how to produce high-quality visuals: - {_skills_text} + {_widget_skills_text} + + ## Excalidraw Diagramming Skills + + You also have access to Excalidraw MCP tools (`Excalidraw:read_me` and + `Excalidraw:create_view`) for creating animated, interactive diagrams. + + When a user asks you to draw a diagram, flowchart, architecture map, or any + visual that would benefit from the Excalidraw canvas — use the Excalidraw MCP + tools instead of `widgetRenderer`. Follow the skill below exactly: + + {_excalidraw_skill_text} + + ## Document Editing + + When the user asks to write, edit, create, or modify a document, use the `write_document` tool. + Always write the complete document, even when making small changes. After writing, the user will + see a confirmation dialog to review and accept or reject your changes. Be concise and follow + their specific instructions for content and format. """, ) -graph = agent +graph = agent.with_config({ + "metadata": { + "copilotkit:emit-intermediate-state": [{ + "state_key": "document", + "tool": "write_document", + "tool_argument": "document", + }] + } +}) diff --git a/apps/agent/skills/__init__.py b/apps/agent/skills/__init__.py index a871edf..b4a5a88 100644 --- a/apps/agent/skills/__init__.py +++ b/apps/agent/skills/__init__.py @@ -17,10 +17,17 @@ def load_skill(name: str) -> str: return path.read_text() -def load_all_skills() -> str: - """Load and concatenate all .txt skill files in this directory.""" +def load_all_skills(exclude: list[str] | None = None) -> str: + """Load and concatenate all .txt skill files in this directory. + + Args: + exclude: Optional list of skill names (without extension) to skip. + """ + skip = set(exclude or []) parts: list[str] = [] for path in sorted(_SKILLS_DIR.glob("*.txt")): + if path.stem in skip: + continue parts.append(f"\n\n{'='*60}\n# SKILL: {path.stem}\n{'='*60}\n\n") parts.append(path.read_text()) return "".join(parts) diff --git a/apps/agent/skills/excalidraw-diagram-skill.txt b/apps/agent/skills/excalidraw-diagram-skill.txt new file mode 100644 index 0000000..c41d10c --- /dev/null +++ b/apps/agent/skills/excalidraw-diagram-skill.txt @@ -0,0 +1,164 @@ +# Excalidraw Diagram Skill + +Create clean, simple Excalidraw diagrams with progressive camera reveals. Use this skill when a user asks to diagram, visualize, flowchart, or illustrate anything. + +--- + +## Step 1 — Always call read_me first + +Before creating any elements, call `Excalidraw:read_me()` to get the color palette, camera sizes, and element syntax. Then proceed directly to `Excalidraw:create_view`. + +--- + +## Step 2 — Planning (critical for spacing) + +Before writing elements: + +1. **Sketch zones/layers** — What are the main sections? (Frontend/Backend, Input/Process/Output, etc.) +2. **Assign colors** — One color per zone, used consistently +3. **Plan camera positions** — 3–6 stops for the reveal effect +4. **Plan spacing** — Draw it on paper first. Leave **minimum 60px vertical gaps** between rows, **40px horizontal gaps** between columns + +--- + +## Step 3 — Core rules (MUST follow) + +### Camera +- Start with `cameraUpdate` as the **first element** +- Use exact 4:3 ratios: `400x300`, `600x450`, `800x600`, `1200x900`, `1600x1200` +- Pan to each section as you draw it +- End with a wide view showing the full diagram + +### Spacing (prevent overlaps) +- **Minimum vertical gap between rows: 60px** +- **Minimum horizontal gap between columns: 40px** +- Check all y-coordinates: if row 1 ends at y=180, row 2 starts at y=240 or later +- Zone label at top-left (y+8px from zone top), nodes start 40px below label +- Never place text directly on shape edges — add 8–10px padding + +### Color grammar +| Zone | Fill | Stroke | +|------|------|--------| +| UI / Frontend | `#dbe4ff` | `#4a9eed` | +| Logic / Agent | `#e5dbff` | `#8b5cf6` | +| Data / Storage | `#d3f9d8` | `#22c55e` | +| External / API | `#ffd8a8` | `#f59e0b` | +| Error / Alert | `#ffc9c9` | `#ef4444` | +| Notes | `#fff3bf` | `#f59e0b` | + +### Typography +- Title: `fontSize: 28`, `strokeColor: "#1e1e1e"` +- Labels: `fontSize: 16–18` via `label` property +- **Never use fontSize below 14** +- Minimum text color: `#757575` (never light gray on white) + +### Drawing order (z-order) +Per section, emit in this order: +1. Zone background rectangle (sits behind) +2. Zone label text +3. Node shapes (with labels) +4. Arrows between nodes + +**Never dump all rectangles first, then all text.** + +### Shapes +- Use `label: { "text": "...", "fontSize": 16 }` on shapes — no separate text elements +- Minimum size: `120x60` for labeled boxes +- Add `roundness: { type: 3 }` for rounded corners + +### Arrows +- Include `endArrowhead: "arrow"` for direction +- Use `strokeStyle: "dashed"` for responses/optional paths +- Keep labels **under 15 characters** or omit +- Use `startBinding` / `endBinding` with `fixedPoint` to attach cleanly + +--- + +## Step 4 — Diagram type patterns + +**Architecture** — Zones as swim lanes (left-to-right). Arrows show data flow. + +**Sequence** — Actors as header boxes with lifelines. Horizontal arrows = messages. Pan down as flow progresses. + +**Process/Flowchart** — Top-to-bottom. Diamonds for decisions, rectangles for steps. Color-code by stage. + +**Concept** — Start zoomed on title, reveal parts progressively. Use annotation boxes as callouts. + +--- + +## Step 5 — The reveal animation + +Draw section by section with camera moves: + +```json +// 1. Title, zoomed +{"type":"cameraUpdate","width":600,"height":450,"x":100,"y":0}, +{"type":"text","id":"t1","x":200,"y":20,"text":"My Diagram","fontSize":28}, + +// 2. Pan to zone 1, draw it +{"type":"cameraUpdate","width":400,"height":300,"x":20,"y":60}, +{"type":"rectangle","id":"zone1","x":20,"y":80,"width":220,"height":380, ...}, +{"type":"rectangle","id":"node1","x":60,"y":130, ...}, + +// 3. Pan to zone 2 +{"type":"cameraUpdate","width":400,"height":300,"x":280,"y":60}, +{"type":"rectangle","id":"node2","x":320,"y":130, ...}, + +// 4. Draw arrows +{"type":"cameraUpdate","width":800,"height":600,"x":0,"y":40}, +{"type":"arrow","id":"a1", ...}, + +// 5. Final wide view +{"type":"cameraUpdate","width":1200,"height":900,"x":-20,"y":-10} +``` + +--- + +## Step 6 — Overlap prevention checklist + +- [ ] All shapes have **at least 60px vertical separation** +- [ ] Zone labels don't overlap with nodes (40px minimum gap below label) +- [ ] Arrows don't cross unrelated zones +- [ ] Text is 8–10px inside shape bounds (not on edges) +- [ ] Zone backgrounds are drawn BEFORE nodes +- [ ] Arrows drawn AFTER both source and target +- [ ] No shape dimensions smaller than `100x50` +- [ ] All camera sizes are valid 4:3 ratios +- [ ] Final element is a wide cameraUpdate +- [ ] Minimum 3 camera positions for animation + +--- + +## Step 7 — Common mistakes + +- **Overlapping text** — Check y-coordinates strictly; use 60px gaps minimum +- **No cameraUpdate first** — Elements clip and look wrong +- **All elements at once** — Loses the animation; use multiple camerUpdates +- **Long arrow labels** — Overflow; keep under 15 chars +- **Light text on white** — Use `#757575` minimum +- **Zone label covered by nodes** — Put label 8px from zone top, nodes 40px below +- **Shapes touching edges** — Leave padding; awkward layout + +--- + +## Reference: Snippets + +**Zone background:** +```json +{"type":"rectangle","id":"z1","x":20,"y":80,"width":220,"height":380,"backgroundColor":"#dbe4ff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#4a9eed","strokeWidth":1,"opacity":40} +``` + +**Node:** +```json +{"type":"rectangle","id":"n1","x":60,"y":130,"width":150,"height":55,"backgroundColor":"#a5d8ff","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#4a9eed","strokeWidth":2,"label":{"text":"API","fontSize":16}} +``` + +**Arrow:** +```json +{"type":"arrow","id":"a1","x":210,"y":157,"width":100,"height":0,"points":[[0,0],[100,0]],"strokeColor":"#1e1e1e","strokeWidth":2,"endArrowhead":"arrow","startBinding":{"elementId":"n1","fixedPoint":[1,0.5]},"endBinding":{"elementId":"n2","fixedPoint":[0,0.5]}} +``` + +**Annotation:** +```json +{"type":"rectangle","id":"note1","x":80,"y":200,"width":200,"height":36,"backgroundColor":"#fff3bf","fillStyle":"solid","roundness":{"type":3},"strokeColor":"#f59e0b","strokeWidth":1,"opacity":80,"label":{"text":"Note here","fontSize":14}} +``` diff --git a/apps/agent/src/document.py b/apps/agent/src/document.py new file mode 100644 index 0000000..a72fbaa --- /dev/null +++ b/apps/agent/src/document.py @@ -0,0 +1,34 @@ +import uuid +from langchain.tools import tool, ToolRuntime +from langchain.messages import ToolMessage, AIMessage +from langgraph.types import Command + + +@tool +def write_document(document: str, runtime: ToolRuntime) -> Command: + """ + Write or update a document. Use markdown formatting. + Do not use italic or strike-through - reserved for diffs. + Write the complete document, even when making small edits. + Keep document changes minimal - don't rewrite everything. + """ + return Command(update={ + "document": document, + "messages": [ + ToolMessage( + content="Document written.", + tool_call_id=runtime.tool_call_id + ), + AIMessage( + content="", + tool_calls=[{ + "id": str(uuid.uuid4()), + "name": "confirm_changes", + "args": {}, + }] + ), + ], + }) + + +document_tools = [write_document] diff --git a/apps/agent/src/todos.py b/apps/agent/src/todos.py index b647fee..5dcb4b6 100644 --- a/apps/agent/src/todos.py +++ b/apps/agent/src/todos.py @@ -2,7 +2,7 @@ from langchain.tools import ToolRuntime, tool from langchain.messages import ToolMessage from langgraph.types import Command -from typing import TypedDict, Literal +from typing import TypedDict, Literal, Optional import uuid class Todo(TypedDict): @@ -14,6 +14,7 @@ class Todo(TypedDict): class AgentState(BaseAgentState): todos: list[Todo] + document: Optional[str] = None @tool def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command: diff --git a/apps/app/package.json b/apps/app/package.json index e6aa958..6730168 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -15,6 +15,10 @@ "@copilotkit/react-ui": "next", "@copilotkit/runtime": "next", "@copilotkitnext/shared": "next", + "@tiptap/react": "^3.20.4", + "@tiptap/starter-kit": "^3.20.4", + "diff": "^8.0.3", + "markdown-it": "^14.1.1", "next": "16.1.6", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -25,6 +29,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/diff": "^8.0.0", + "@types/markdown-it": "^14.1.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/apps/app/src/app/api/copilotkit/route.ts b/apps/app/src/app/api/copilotkit/route.ts index 9c73d9b..d3418dc 100644 --- a/apps/app/src/app/api/copilotkit/route.ts +++ b/apps/app/src/app/api/copilotkit/route.ts @@ -4,30 +4,40 @@ import { copilotRuntimeNextJSAppRouterEndpoint, } from "@copilotkit/runtime"; import { LangGraphAgent } from "@copilotkit/runtime/langgraph"; +import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware"; import { NextRequest } from "next/server"; -// 1. Define the agent connection to LangGraph +// 1. Define the default agent connection to LangGraph const defaultAgent = new LangGraphAgent({ deploymentUrl: process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123", graphId: "sample_agent", langsmithApiKey: process.env.LANGSMITH_API_KEY || "", }); -// 3. Define the route and CopilotRuntime for the agent +// 2. Wire up MCP apps middleware so widget HTML is sent to the frontend +defaultAgent.use( + new MCPAppsMiddleware({ + mcpServers: [{ + type: "http", + url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com", + serverId: "example_mcp_app", + }], + }) +); + +// Allow long-running agent + MCP tool calls +export const maxDuration = 300; + +// Define the route and CopilotRuntime for the agent export const POST = async (req: NextRequest) => { const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ endpoint: "/api/copilotkit", serviceAdapter: new ExperimentalEmptyAdapter(), runtime: new CopilotRuntime({ - agents: { default: defaultAgent, }, - a2ui: { injectA2UITool: true }, - mcpApps: { - servers: [{ - type: "http", - url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com", - serverId: "example_mcp_app", - }], + agents: { + default: defaultAgent, }, + a2ui: { injectA2UITool: true }, }), }); diff --git a/apps/app/src/app/canvas/page.tsx b/apps/app/src/app/canvas/page.tsx new file mode 100644 index 0000000..7df01bc --- /dev/null +++ b/apps/app/src/app/canvas/page.tsx @@ -0,0 +1,456 @@ +"use client"; + +import "@copilotkit/react-core/v2/styles.css"; +import "./style.css"; + +import MarkdownIt from "markdown-it"; +import React, { useEffect, useRef, useState } from "react"; +import { diffWords } from "diff"; +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { + useAgent, + UseAgentUpdate, + useHumanInTheLoop, + useConfigureSuggestions, + CopilotChat, +} from "@copilotkit/react-core/v2"; +import { CopilotKit } from "@copilotkit/react-core"; +import { z } from "zod"; + +const extensions = [StarterKit]; + +export default function CanvasPage() { + // Defer CopilotChat to client-only to avoid Radix hydration ID mismatch + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + return ( + <> + {/* Animated background */} +
+
+
+ + {/* App shell */} +
+
+ {/* Header Banner */} +
+
+
+
+ + + + + +
+

+ Document to Diagram + — powered by CopilotKit +

+
+ + Get started + +
+
+ + + {/* Content Area */} +
+
+ {/* Left: Document Editor */} +
+ +
+ + {/* Right: Chat Panel */} +
+ {mounted && } +
+
+
+
+
+
+ + ); +} + +interface AgentState { + document: string; +} + +const DEFAULT_DOCUMENT = `# How do WebSockets Work? + +## 1. The Handshake (HTTP Upgrade) + +It starts as a regular HTTP request. The client sends a special header asking to "upgrade" the connection: + +- GET /chat HTTP/1.1 +- Upgrade: websocket +- Connection: Upgrade + +The server responds with 101 Switching Protocols, and from that point on, the connection is no longer HTTP — it's a WebSocket. + +## 2. The Persistent Connection + +Unlike HTTP (where each request opens and closes a connection), the WebSocket connection stays open. Both sides can now send messages to each other at any time without waiting for the other to ask first. + +## 3. Frames, Not Requests + +Data is sent as lightweight "frames" — small packets that can carry text, binary data, or control signals (like ping/pong to keep the connection alive). + +## HTTP vs WebSocket + +| Aspect | HTTP | WebSocket | +|--------|------|-----------| +| Direction | One-way (request → response) | Two-way (either side) | +| Connection | Opens and closes each time | Stays open | +| Overhead | Headers sent every request | Minimal after handshake | +| Use case | Loading pages, REST APIs | Chat, live feeds, games | + +## A simple mental model + +Think of HTTP like sending letters — you write one, wait for a reply, then write another. WebSocket is like a phone call — once connected, both people can speak freely at any time without hanging up between each sentence. + +## Common use cases + +- Chat apps — messages appear instantly without polling +- Live dashboards — stock prices, sports scores, analytics +- Multiplayer games — real-time position and state sync +- Collaborative tools — like Google Docs, where edits appear live`; + +const DocumentEditor = () => { + const editor = useEditor({ + extensions, + immediatelyRender: false, + editorProps: { + attributes: { class: "tiptap" }, + }, + }); + + const [placeholderVisible, setPlaceholderVisible] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [currentDocument, setCurrentDocument] = useState(DEFAULT_DOCUMENT); + const wasRunning = useRef(false); + const isMountedRef = useRef(true); + + // Initialize editor with default document on mount + useEffect(() => { + if (!editor || !isMountedRef.current) return; + editor.commands.setContent(currentDocument); + }, [editor, currentDocument]); + + // Cleanup on unmount to prevent state updates after component is removed + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + // Track editor focus state + useEffect(() => { + if (!editor) return; + + const handleFocus = () => { + if (isMountedRef.current) setIsFocused(true); + }; + const handleBlur = () => { + if (isMountedRef.current) setIsFocused(false); + }; + + editor.on("focus", handleFocus); + editor.on("blur", handleBlur); + + return () => { + editor.off("focus", handleFocus); + editor.off("blur", handleBlur); + }; + }, [editor]); + + useConfigureSuggestions({ + suggestions: [ + { + title: "Generate WebSocket document", + message: "Create a comprehensive document explaining how WebSockets work, including the handshake process, persistent connections, frames, comparison with HTTP, and common use cases.", + }, + { + title: "Explain REST API architecture", + message: "Write a detailed document about REST API design principles, HTTP methods, status codes, request/response structure, and best practices.", + }, + { + title: "Microservices architecture", + message: "Write a comprehensive guide to microservices architecture, covering service decomposition, inter-service communication, data consistency, and deployment patterns.", + }, + ], + }); + + const { agent } = useAgent({ + agentId: "default", + updates: [UseAgentUpdate.OnStateChanged, UseAgentUpdate.OnRunStatusChanged], + }); + + const agentState = agent.state as AgentState | undefined; + const setAgentState = (s: AgentState) => agent.setState(s); + const isLoading = agent.isRunning; + + // Handle loading state transitions + useEffect(() => { + if (!isMountedRef.current) return; + + if (isLoading) { + setCurrentDocument(editor?.getText() || ""); + } + editor?.setEditable(!isLoading); + }, [isLoading, editor]); + + // Handle final state update when run completes + useEffect(() => { + if (!isMountedRef.current) return; + + if (wasRunning.current && !isLoading) { + if (currentDocument.trim().length > 0 && currentDocument !== agentState?.document) { + const newDocument = agentState?.document || ""; + editor?.commands.setContent(newDocument); + } + } + wasRunning.current = isLoading; + }, [isLoading, currentDocument, agentState?.document, editor]); + + // Handle streaming updates while agent is running + useEffect(() => { + if (!isMountedRef.current) return; + + if (isLoading && agentState?.document) { + editor?.commands.setContent(agentState.document); + } + }, [agentState?.document, isLoading, editor]); + + const text = editor?.getText() || ""; + + // Sync user edits to agent state + useEffect(() => { + if (!isMountedRef.current) return; + + // Show placeholder only when editor is not focused AND text is empty + setPlaceholderVisible(text.length === 0 && !isFocused); + + if (!isLoading && text !== currentDocument) { + setCurrentDocument(text); + setAgentState({ + document: text, + }); + } + }, [text, isLoading, currentDocument, isFocused, setAgentState]); + + // Human-in-the-loop: confirm_changes (legacy) + useHumanInTheLoop( + { + agentId: "default", + name: "confirm_changes", + render: ({ args, respond, status }) => ( + { + editor?.commands.setContent(currentDocument); + setAgentState({ document: currentDocument }); + }} + onConfirm={() => { + editor?.commands.setContent(agentState?.document || ""); + setCurrentDocument(agentState?.document || ""); + setAgentState({ document: agentState?.document || "" }); + }} + /> + ), + }, + [agentState?.document], + ); + + // Human-in-the-loop: write_document (primary) + useHumanInTheLoop( + { + agentId: "default", + name: "confirm_changes", + description: "Present the proposed changes to the user for review", + parameters: z.object({ + document: z.string().describe("The full updated document in markdown format"), + }), + render({ args, status, respond }: { args: { document?: string }; status: string; respond?: (result: unknown) => Promise }) { + if (status === "executing") { + return ( + { + editor?.commands.setContent(currentDocument); + setAgentState({ document: currentDocument }); + }} + onConfirm={() => { + editor?.commands.setContent(agentState?.document || ""); + setCurrentDocument(agentState?.document || ""); + setAgentState({ document: agentState?.document || "" }); + }} + /> + ); + } + return <>; + }, + }, + [agentState?.document], + ); + + return ( +
+ {placeholderVisible && ( +
+ How do WebSockets work? +
+ )} +
+ +
+
+ ); +}; + +interface ConfirmChangesProps { + args: any; + respond: any; + status: any; + onReject: () => void; + onConfirm: () => void; +} + +function ConfirmChanges({ args, respond, status, onReject, onConfirm }: ConfirmChangesProps) { + const [accepted, setAccepted] = useState(null); + + return ( +
+

Confirm Changes

+

Accept the proposed changes?

+ {accepted === null && ( +
+ + +
+ )} + {accepted !== null && ( +
+
+ {accepted ? "✓ Accepted" : "✗ Rejected"} +
+
+ )} +
+ ); +} + +function fromMarkdown(text: string) { + const md = new MarkdownIt({ + typographer: true, + html: true, + }); + + return md.render(text); +} + +function diffPartialText(oldText: string, newText: string, isComplete: boolean = false) { + let oldTextToCompare = oldText; + if (oldText.length > newText.length && !isComplete) { + oldTextToCompare = oldText.slice(0, newText.length); + } + + const changes = diffWords(oldTextToCompare, newText); + + let result = ""; + changes.forEach((part) => { + if (part.added) { + result += `${part.value}`; + } else if (part.removed) { + result += `${part.value}`; + } else { + result += part.value; + } + }); + + if (oldText.length > newText.length && !isComplete) { + result += oldText.slice(newText.length); + } + + return result; +} diff --git a/apps/app/src/app/canvas/style.css b/apps/app/src/app/canvas/style.css new file mode 100644 index 0000000..c247219 --- /dev/null +++ b/apps/app/src/app/canvas/style.css @@ -0,0 +1,142 @@ +/* Tiptap Editor Base */ +.tiptap :first-child { + margin-top: 0; +} + +.tiptap ul, +.tiptap ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; +} + +.tiptap ul li p, +.tiptap ol li p { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + +.tiptap h1, +.tiptap h2, +.tiptap h3, +.tiptap h4, +.tiptap h5, +.tiptap h6 { + line-height: 1.1; + margin-top: 3.5rem; + margin-bottom: 1.5rem; + text-wrap: pretty; + font-weight: bold; + color: var(--text-primary); +} + +.tiptap p { + margin-bottom: 1rem; + color: var(--text-primary); +} + +.tiptap h1 { + font-size: 1.4rem; +} +.tiptap h2 { + font-size: 1.2rem; +} +.tiptap h3 { + font-size: 1.1rem; +} +.tiptap h4, +.tiptap h5, +.tiptap h6 { + font-size: 1rem; +} + +.tiptap code { + background-color: var(--surface-quaternary); + border-radius: 0.4rem; + color: var(--text-primary); + font-size: 0.85rem; + padding: 0.25em 0.3em; +} + +.tiptap pre { + background: var(--text-primary); + border-radius: 0.5rem; + color: var(--surface-primary); + font-family: "Menlo", "Monaco", monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; + overflow-x: auto; +} + +.tiptap pre code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; +} + +.tiptap blockquote { + border-left: 3px solid var(--border-light); + margin: 1.5rem 0; + padding-left: 1rem; + color: var(--text-secondary); +} + +.tiptap hr { + border: none; + border-top: 1px solid var(--border-light); + margin: 2rem 0; +} + +/* Diff highlights */ +.tiptap s { + background-color: rgba(249, 129, 129, 0.3); + padding: 2px; + text-decoration: line-through; +} + +.tiptap em { + background-color: rgba(178, 242, 187, 1); + padding: 2px; + font-style: normal; +} + +/* Editor focus and interaction states */ +.tiptap { + outline: none; + cursor: text; +} + +.tiptap:focus, +.tiptap:focus-visible { + outline: none; +} + +/* Tiptap container styling */ +.ProseMirror { + outline: none; + cursor: text; + min-height: 100%; +} + +.ProseMirror:focus, +.ProseMirror:focus-visible { + outline: none; +} + +/* Streaming animation for active generation */ +@keyframes streamingGlow { + 0% { + box-shadow: inset 0 0 0 2px rgba(139, 92, 246, 0.3), 0 0 20px rgba(139, 92, 246, 0.1); + } + 50% { + box-shadow: inset 0 0 0 2px rgba(139, 92, 246, 0.6), 0 0 30px rgba(139, 92, 246, 0.2); + } + 100% { + box-shadow: inset 0 0 0 2px rgba(139, 92, 246, 0.3), 0 0 20px rgba(139, 92, 246, 0.1); + } +} + +.editor-streaming { + animation: streamingGlow 2s ease-in-out infinite; + border-radius: 0.5rem; +} diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 7994182..e228b1c 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { ExampleLayout } from "@/components/example-layout"; import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks"; import { ExplainerCardsPortal } from "@/components/explainer-cards"; +import { McpWidgetZoom } from "@/components/mcp-widget-zoom"; import { CopilotChat } from "@copilotkit/react-core/v2"; @@ -11,6 +12,10 @@ export default function HomePage() { useGenerativeUIExamples(); useExampleSuggestions(); + // Defer CopilotChat to client-only to avoid Radix hydration ID mismatch + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + // Widget bridge: handle openLink from widget iframes useEffect(() => { const handler = (e: MessageEvent) => { @@ -75,13 +80,16 @@ export default function HomePage() {
+ mounted ? ( + + ) : null } /> + diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx index f978385..4c96ab5 100644 --- a/apps/app/src/components/generative-ui/widget-renderer.tsx +++ b/apps/app/src/components/generative-ui/widget-renderer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"; import { z } from "zod"; // ─── Zod Schema (CopilotKit parameter contract) ───────────────────── @@ -228,6 +228,12 @@ body { -webkit-font-smoothing: antialiased; } +#content { + display: flex; + flex-direction: column; + align-items: center; +} + button { font-family: inherit; font-size: 14px; @@ -358,6 +364,17 @@ window.addEventListener('load', reportHeight); // Periodic reports during initial load var _resizeInterval = setInterval(reportHeight, 200); setTimeout(function() { clearInterval(_resizeInterval); }, 15000); + +// Patch: receive incremental HTML updates without full reload +window.addEventListener('message', function(e) { + if (e.data && e.data.type === 'update-content' && typeof e.data.html === 'string') { + var content = document.getElementById('content'); + if (content) { + content.innerHTML = e.data.html; + reportHeight(); + } + } +}); `; // ─── Document Assembly ─────────────────────────────────────────────── @@ -425,8 +442,10 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps const iframeRef = useRef(null); const [height, setHeight] = useState(0); const [loaded, setLoaded] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); // Track what html has been committed to the iframe to avoid redundant reloads const committedHtmlRef = useRef(""); + const isFirstRenderRef = useRef(true); const handleMessage = useCallback((e: MessageEvent) => { // Only handle messages from our own iframe @@ -436,7 +455,7 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps e.data?.type === "widget-resize" && typeof e.data.height === "number" ) { - setHeight(Math.max(50, Math.min(e.data.height + 8, 4000))); + setHeight(Math.max(50, Math.min(e.data.height + 8, 800))); } }, []); @@ -445,19 +464,37 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps return () => window.removeEventListener("message", handleMessage); }, [handleMessage]); - // Write to iframe imperatively — bypasses React reconciliation so the - // iframe only reloads when the html *content* truly changes, preserving - // internal JS state (Three.js scenes, step counters, etc.) across - // CopilotKit re-renders. - useEffect(() => { + // Write to iframe imperatively — first render sets srcdoc (executes scripts), + // subsequent streaming updates patch #content.innerHTML via postMessage + // to preserve JS state (Three.js scenes, step counters, etc.). + useLayoutEffect(() => { if (!html || !iframeRef.current) return; if (html === committedHtmlRef.current) return; committedHtmlRef.current = html; - iframeRef.current.srcdoc = assembleDocument(html); - setLoaded(false); - setHeight(0); + + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + iframeRef.current.srcdoc = assembleDocument(html); + setLoaded(false); + setHeight(0); + } else { + iframeRef.current.contentWindow?.postMessage( + { type: "update-content", html }, + "*" + ); + } }, [html]); + // Escape key exits fullscreen + useEffect(() => { + if (!isFullscreen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsFullscreen(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isFullscreen]); + // Fallback: if iframe has html but hasn't reported ready after 4s, force-show useEffect(() => { if (!html || (loaded && height > 0)) return; @@ -474,65 +511,110 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps const loadingPhrase = useLoadingPhrase(showLoading); return ( -
- {/* Loading indicator: visible until iframe is fully ready */} - {showLoading && ( + <> + {/* Fullscreen backdrop */} + {isFullscreen && (
- {/* Animated gradient border top */} + onClick={() => setIsFullscreen(false)} + /> + )} +
+ {/* Toolbar */} + {html && ( +
+ +
+ )} + {/* Loading indicator: visible until iframe is fully ready */} + {showLoading && !isFullscreen && (
-
- {/* Spinning icon */} + >
- - {loadingPhrase}... - +
+
+ + {loadingPhrase}... + +
-
- )} - {/* Iframe: always mounted so ref is stable; srcdoc set imperatively. - No srcDoc React prop — prevents React from reloading the iframe - on parent re-renders. */} -