diff --git a/apps/agent/skills/advanced-visualization/SKILL.md b/apps/agent/skills/advanced-visualization/SKILL.md
index f40735e..3ee90df 100644
--- a/apps/agent/skills/advanced-visualization/SKILL.md
+++ b/apps/agent/skills/advanced-visualization/SKILL.md
@@ -668,7 +668,15 @@ Only these CDN origins work (CSP-enforced):
```
-**Three.js** (3D graphics):
+**Three.js** (3D graphics) — use ES module import (import map resolves bare specifiers):
+```html
+
+```
+Alternative UMD (global `THREE` variable):
```html
```
diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx
index 2606ec5..d1fbb15 100644
--- a/apps/app/src/components/generative-ui/save-template-overlay.tsx
+++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useCallback, useEffect, useMemo, type ReactNode } from "react";
+import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
@@ -35,29 +35,28 @@ export function SaveTemplateOverlay({
const [saveState, setSaveState] = useState("idle");
const [templateName, setTemplateName] = useState("");
- // Capture pending_template once — it may be cleared by the agent later.
- // Syncs external agent state into local state (legitimate effect-based setState).
+ // Capture pending_template at mount time — it may be cleared by the agent later.
+ // Uses ref (not state) to avoid an async re-render that would shift sibling positions
+ // and cause React to remount the iframe, losing rendered 3D/canvas content.
const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined;
- const [capturedSource, setCapturedSource] = useState<{ id: string; name: string } | null>(null);
- useEffect(() => {
- if (pending?.id) {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time capture of external agent state
- setCapturedSource((prev) => prev ?? pending);
- }
- }, [pending]);
+ const sourceRef = useRef<{ id: string; name: string } | null>(null);
+ // eslint-disable-next-line react-hooks/refs -- one-time ref init during render (React-endorsed pattern)
+ if (pending?.id && !sourceRef.current) {
+ sourceRef.current = pending; // eslint-disable-line react-hooks/refs
+ }
// Check if this content matches an existing template:
// 1. Exact HTML match (seed templates rendered as-is)
// 2. Source template captured from pending_template (applied templates with modified data)
const matchedTemplate = useMemo(() => {
// First check source template from apply flow
- if (capturedSource) {
+ if (sourceRef.current) { // eslint-disable-line react-hooks/refs
const allTemplates = [
...SEED_TEMPLATES,
...((agent.state?.templates as { id: string; name: string }[]) || []),
];
- const found = allTemplates.find((t) => t.id === capturedSource.id);
- if (found) return found;
+ const source = allTemplates.find((t) => t.id === sourceRef.current!.id); // eslint-disable-line react-hooks/refs
+ if (source) return source;
}
// Then check exact HTML match
if (!html) return null;
@@ -68,7 +67,7 @@ export function SaveTemplateOverlay({
...((agent.state?.templates as { id: string; name: string; html: string }[]) || []),
];
return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null;
- }, [html, agent.state?.templates, capturedSource]);
+ }, [html, agent.state?.templates]);
const handleSave = useCallback(() => {
const name = templateName.trim() || title || "Untitled Template";
diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx
index 6a64a1a..c8bd429 100644
--- a/apps/app/src/components/generative-ui/widget-renderer.tsx
+++ b/apps/app/src/components/generative-ui/widget-renderer.tsx
@@ -428,7 +428,13 @@ window.addEventListener('message', function(e) {
content.setAttribute('data-exec-' + hash, '1');
try {
var newScript = document.createElement('script');
- if (scriptInfo.type) newScript.type = scriptInfo.type;
+ // Auto-detect ES module syntax: if the script contains import/export
+ // statements but lacks type="module", promote it so the import map applies.
+ var effectiveType = scriptInfo.type;
+ if (!effectiveType && scriptInfo.text && /\\b(import\\s|export\\s|import\\()/.test(scriptInfo.text)) {
+ effectiveType = 'module';
+ }
+ if (effectiveType) newScript.type = effectiveType;
if (scriptInfo.src) {
newScript.src = scriptInfo.src;
newScript.onload = function() { runScripts(scripts, idx + 1); };
@@ -471,6 +477,20 @@ function assembleShell(initialHtml: string = ""): string {
+
setLoaded(true)}
style={{