Skip to content

Commit 6a00ff7

Browse files
authored
Merge pull request #40 from CopilotKit/fix/progressive-rendering-33
Progressive rendering with DOM morphing for generative UI
2 parents ba2b133 + 34c70d1 commit 6a00ff7

File tree

6 files changed

+109
-34
lines changed

6 files changed

+109
-34
lines changed

apps/app/src/app/globals.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,3 +642,8 @@ body, html {
642642
from { transform: scale(0.9); opacity: 0; }
643643
to { transform: scale(1); opacity: 1; }
644644
}
645+
646+
@keyframes fadeSlideUp {
647+
from { opacity: 0; transform: translateY(12px); }
648+
to { opacity: 1; transform: translateY(0); }
649+
}

apps/app/src/components/generative-ui/charts/bar-chart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function BarChart({ title, description, data }: BarChartProps) {
6262
stroke="var(--chart-axis)"
6363
/>
6464
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
65-
<Bar isAnimationActive={false} dataKey="value" radius={[4, 4, 0, 0]} />
65+
<Bar isAnimationActive={true} animationDuration={800} animationEasing="ease-out" dataKey="value" radius={[4, 4, 0, 0]} />
6666
</RechartsBarChart>
6767
</ResponsiveContainer>
6868
</div>

apps/app/src/components/generative-ui/charts/pie-chart.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ export function PieChart({ title, description, data }: PieChartProps) {
7070
cx="50%"
7171
cy="50%"
7272
outerRadius={100}
73-
isAnimationActive={false}
73+
isAnimationActive={true}
74+
animationDuration={800}
75+
animationEasing="ease-out"
76+
animationBegin={200}
7477
/>
7578
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
7679
</RechartsPieChart>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// idiomorph v0.3.0 — https://github.com/bigskysoftware/idiomorph
2+
// Minified source inlined for use inside sandboxed iframes (no network access).
3+
//
4+
// To update:
5+
// 1. npm install idiomorph@<version> (or download from GitHub releases)
6+
// 2. Copy the minified IIFE build into the IDIOMORPH_JS string below
7+
// 3. Update the version comment above
8+
// 4. Verify: the global `Idiomorph.morph(target, newContent, opts)` must be available
9+
//
10+
// Do NOT edit the minified code by hand.
11+
12+
export const IDIOMORPH_JS = `var Idiomorph=function(){"use strict";let o=new Set;let n={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:t,afterNodeAdded:t,beforeNodeMorphed:t,afterNodeMorphed:t,beforeNodeRemoved:t,afterNodeRemoved:t,beforeAttributeUpdated:t},head:{style:"merge",shouldPreserve:function(e){return e.getAttribute("im-preserve")==="true"},shouldReAppend:function(e){return e.getAttribute("im-re-append")==="true"},shouldRemove:t,afterHeadMorphed:t}};function e(e,t,n={}){if(e instanceof Document){e=e.documentElement}if(typeof t==="string"){t=k(t)}let l=y(t);let r=p(e,l,n);return a(e,l,r)}function a(r,i,o){if(o.head.block){let t=r.querySelector("head");let n=i.querySelector("head");if(t&&n){let e=c(n,t,o);Promise.all(e).then(function(){a(r,i,Object.assign(o,{head:{block:false,ignore:true}}))});return}}if(o.morphStyle==="innerHTML"){l(i,r,o);return r.children}else if(o.morphStyle==="outerHTML"||o.morphStyle==null){let e=M(i,r,o);let t=e?.previousSibling;let n=e?.nextSibling;let l=d(r,e,o);if(e){return N(t,l,n)}else{return[]}}else{throw"Do not understand how to morph style "+o.morphStyle}}function u(e,t){return t.ignoreActiveValue&&e===document.activeElement}function d(e,t,n){if(n.ignoreActive&&e===document.activeElement){}else if(t==null){if(n.callbacks.beforeNodeRemoved(e)===false)return e;e.remove();n.callbacks.afterNodeRemoved(e);return null}else if(!g(e,t)){if(n.callbacks.beforeNodeRemoved(e)===false)return e;if(n.callbacks.beforeNodeAdded(t)===false)return e;e.parentElement.replaceChild(t,e);n.callbacks.afterNodeAdded(t);n.callbacks.afterNodeRemoved(e);return t}else{if(n.callbacks.beforeNodeMorphed(e,t)===false)return e;if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){c(t,e,n)}else{r(t,e,n);if(!u(e,n)){l(t,e,n)}}n.callbacks.afterNodeMorphed(e,t);return e}}function l(n,l,r){let i=n.firstChild;let o=l.firstChild;let a;while(i){a=i;i=a.nextSibling;if(o==null){if(r.callbacks.beforeNodeAdded(a)===false)return;l.appendChild(a);r.callbacks.afterNodeAdded(a);H(r,a);continue}if(b(a,o,r)){d(o,a,r);o=o.nextSibling;H(r,a);continue}let e=A(n,l,a,o,r);if(e){o=v(o,e,r);d(e,a,r);H(r,a);continue}let t=S(n,l,a,o,r);if(t){o=v(o,t,r);d(t,a,r);H(r,a);continue}if(r.callbacks.beforeNodeAdded(a)===false)return;l.insertBefore(a,o);r.callbacks.afterNodeAdded(a);H(r,a)}while(o!==null){let e=o;o=o.nextSibling;T(e,r)}}function f(e,t,n,l){if(e==="value"&&l.ignoreActiveValue&&t===document.activeElement){return true}return l.callbacks.beforeAttributeUpdated(e,t,n)===false}function r(t,n,l){let e=t.nodeType;if(e===1){const r=t.attributes;const i=n.attributes;for(const o of r){if(f(o.name,n,"update",l)){continue}if(n.getAttribute(o.name)!==o.value){n.setAttribute(o.name,o.value)}}for(let e=i.length-1;0<=e;e--){const a=i[e];if(f(a.name,n,"remove",l)){continue}if(!t.hasAttribute(a.name)){n.removeAttribute(a.name)}}}if(e===8||e===3){if(n.nodeValue!==t.nodeValue){n.nodeValue=t.nodeValue}}if(!u(n,l)){s(t,n,l)}}function i(t,n,l,r){if(t[l]!==n[l]){let e=f(l,n,"update",r);if(!e){n[l]=t[l]}if(t[l]){if(!e){n.setAttribute(l,t[l])}}else{if(!f(l,n,"remove",r)){n.removeAttribute(l)}}}}function s(n,l,r){if(n instanceof HTMLInputElement&&l instanceof HTMLInputElement&&n.type!=="file"){let e=n.value;let t=l.value;i(n,l,"checked",r);i(n,l,"disabled",r);if(!n.hasAttribute("value")){if(!f("value",l,"remove",r)){l.value="";l.removeAttribute("value")}}else if(e!==t){if(!f("value",l,"update",r)){l.setAttribute("value",e);l.value=e}}}else if(n instanceof HTMLOptionElement){i(n,l,"selected",r)}else if(n instanceof HTMLTextAreaElement&&l instanceof HTMLTextAreaElement){let e=n.value;let t=l.value;if(f("value",l,"update",r)){return}if(e!==t){l.value=e}if(l.firstChild&&l.firstChild.nodeValue!==e){l.firstChild.nodeValue=e}}}function c(e,t,l){let r=[];let i=[];let o=[];let a=[];let u=l.head.style;let d=new Map;for(const n of e.children){d.set(n.outerHTML,n)}for(const s of t.children){let e=d.has(s.outerHTML);let t=l.head.shouldReAppend(s);let n=l.head.shouldPreserve(s);if(e||n){if(t){i.push(s)}else{d.delete(s.outerHTML);o.push(s)}}else{if(u==="append"){if(t){i.push(s);a.push(s)}}else{if(l.head.shouldRemove(s)!==false){i.push(s)}}}}a.push(...d.values());m("to append: ",a);let f=[];for(const c of a){m("adding: ",c);let n=document.createRange().createContextualFragment(c.outerHTML).firstChild;m(n);if(l.callbacks.beforeNodeAdded(n)!==false){if(n.href||n.src){let t=null;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});f.push(e)}t.appendChild(n);l.callbacks.afterNodeAdded(n);r.push(n)}}for(const h of i){if(l.callbacks.beforeNodeRemoved(h)!==false){t.removeChild(h);l.callbacks.afterNodeRemoved(h)}}l.head.afterHeadMorphed(t,{added:r,kept:o,removed:i});return f}function m(){}function t(){}function h(e){let t={};Object.assign(t,n);Object.assign(t,e);t.callbacks={};Object.assign(t.callbacks,n.callbacks);Object.assign(t.callbacks,e.callbacks);t.head={};Object.assign(t.head,n.head);Object.assign(t.head,e.head);return t}function p(e,t,n){n=h(n);return{target:e,newContent:t,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,ignoreActiveValue:n.ignoreActiveValue,idMap:C(e,t),deadIds:new Set,callbacks:n.callbacks,head:n.head}}function b(e,t,n){if(e==null||t==null){return false}if(e.nodeType===t.nodeType&&e.tagName===t.tagName){if(e.id!==""&&e.id===t.id){return true}else{return L(n,e,t)>0}}return false}function g(e,t){if(e==null||t==null){return false}return e.nodeType===t.nodeType&&e.tagName===t.tagName}function v(t,e,n){while(t!==e){let e=t;t=t.nextSibling;T(e,n)}H(n,e);return e.nextSibling}function A(n,e,l,r,i){let o=L(i,l,e);let t=null;if(o>0){let e=r;let t=0;while(e!=null){if(b(l,e,i)){return e}t+=L(i,e,n);if(t>o){return null}e=e.nextSibling}}return t}function S(e,t,n,l,r){let i=l;let o=n.nextSibling;let a=0;while(i!=null){if(L(r,i,e)>0){return null}if(g(n,i)){return i}if(g(o,i)){a++;o=o.nextSibling;if(a>=2){return null}}i=i.nextSibling}return i}function k(n){let l=new DOMParser;let e=n.replace(/<svg(\\s[^>]*>|>)([\\s\\S]*?)<\\/svg>/gim,"");if(e.match(/<\\/html>/)||e.match(/<\\/head>/)||e.match(/<\\/body>/)){let t=l.parseFromString(n,"text/html");if(e.match(/<\\/html>/)){t.generatedByIdiomorph=true;return t}else{let e=t.firstChild;if(e){e.generatedByIdiomorph=true;return e}else{return null}}}else{let e=l.parseFromString("<body><template>"+n+"</template></body>","text/html");let t=e.body.querySelector("template").content;t.generatedByIdiomorph=true;return t}}function y(e){if(e==null){const t=document.createElement("div");return t}else if(e.generatedByIdiomorph){return e}else if(e instanceof Node){const t=document.createElement("div");t.append(e);return t}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}function N(e,t,n){let l=[];let r=[];while(e!=null){l.push(e);e=e.previousSibling}while(l.length>0){let e=l.pop();r.push(e);t.parentElement.insertBefore(e,t)}r.push(t);while(n!=null){l.push(n);r.push(n);n=n.nextSibling}while(l.length>0){t.parentElement.insertBefore(l.pop(),t.nextSibling)}return r}function M(e,t,n){let l;l=e.firstChild;let r=l;let i=0;while(l){let e=w(l,t,n);if(e>i){r=l;i=e}l=l.nextSibling}return r}function w(e,t,n){if(g(e,t)){return.5+L(n,e,t)}return 0}function T(e,t){H(t,e);if(t.callbacks.beforeNodeRemoved(e)===false)return;e.remove();t.callbacks.afterNodeRemoved(e)}function E(e,t){return!e.deadIds.has(t)}function x(e,t,n){let l=e.idMap.get(n)||o;return l.has(t)}function H(e,t){let n=e.idMap.get(t)||o;for(const l of n){e.deadIds.add(l)}}function L(e,t,n){let l=e.idMap.get(t)||o;let r=0;for(const i of l){if(E(e,i)&&x(e,i,n)){++r}}return r}function R(e,n){let l=e.parentElement;let t=e.querySelectorAll("[id]");for(const r of t){let t=r;while(t!==l&&t!=null){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(r.id);t=t.parentElement}}}function C(e,t){let n=new Map;R(e,n);R(t,n);return n}return{morph:e,defaults:n}}();`;

apps/app/src/components/generative-ui/meeting-time-picker.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ export function MeetingTimePicker({
9898
hover:scale-102 active:scale-98
9999
flex justify-between items-center
100100
hover:bg-blue-50 dark:hover:bg-blue-900/30"
101+
style={{
102+
animation: "fadeSlideUp 0.4s ease-out both",
103+
animationDelay: `${index * 80}ms`,
104+
}}
101105
>
102106
<div className="text-left">
103107
<div className="font-bold text-gray-900 dark:text-zinc-100">{slot.date}</div>
@@ -115,6 +119,10 @@ export function MeetingTimePicker({
115119
text-gray-600 dark:text-zinc-400 hover:text-gray-800 dark:hover:text-zinc-200
116120
transition-all cursor-pointer
117121
hover:bg-gray-100 dark:hover:bg-zinc-700"
122+
style={{
123+
animation: "fadeSlideUp 0.4s ease-out both",
124+
animationDelay: `${slots.length * 80}ms`,
125+
}}
118126
>
119127
None of these work
120128
</button>

apps/app/src/components/generative-ui/widget-renderer.tsx

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useRef, useState, useCallback } from "react";
44
import { z } from "zod";
55
import { SaveTemplateOverlay } from "./save-template-overlay";
6+
import { IDIOMORPH_JS } from "./idiomorph-inline";
67

78
// ─── Zod Schema (CopilotKit parameter contract) ─────────────────────
89
export const WidgetRendererProps = z.object({
@@ -302,16 +303,21 @@ input[type="checkbox"], input[type="radio"] {
302303
a { color: var(--color-text-info); text-decoration: none; }
303304
a:hover { text-decoration: underline; }
304305
305-
/* Progressive reveal for content children */
306-
#content > * {
306+
/* First render: stagger all children */
307+
#content.initial-render > * {
308+
animation: fadeSlideIn 0.4s ease-out both;
309+
}
310+
#content.initial-render > *:nth-child(1) { animation-delay: 0s; }
311+
#content.initial-render > *:nth-child(2) { animation-delay: 0.06s; }
312+
#content.initial-render > *:nth-child(3) { animation-delay: 0.12s; }
313+
#content.initial-render > *:nth-child(4) { animation-delay: 0.18s; }
314+
#content.initial-render > *:nth-child(5) { animation-delay: 0.24s; }
315+
#content.initial-render > *:nth-child(n+6) { animation-delay: 0.3s; }
316+
317+
/* Subsequent morphs: only new elements animate in */
318+
.morph-enter {
307319
animation: fadeSlideIn 0.4s ease-out both;
308320
}
309-
#content > *:nth-child(1) { animation-delay: 0s; }
310-
#content > *:nth-child(2) { animation-delay: 0.06s; }
311-
#content > *:nth-child(3) { animation-delay: 0.12s; }
312-
#content > *:nth-child(4) { animation-delay: 0.18s; }
313-
#content > *:nth-child(5) { animation-delay: 0.24s; }
314-
#content > *:nth-child(n+6) { animation-delay: 0.3s; }
315321
316322
@keyframes fadeSlideIn {
317323
from { opacity: 0; transform: translateY(8px); }
@@ -352,32 +358,72 @@ window.addEventListener('message', function(e) {
352358
if (e.source !== window.parent) return;
353359
if (e.data && e.data.type === 'update-content') {
354360
var content = document.getElementById('content');
355-
if (content) {
356-
// Strip script tags from HTML before inserting — scripts are handled separately below
357-
var tmp = document.createElement('div');
358-
tmp.innerHTML = e.data.html;
359-
var incomingScripts = [];
360-
tmp.querySelectorAll('script').forEach(function(s) {
361-
incomingScripts.push({ src: s.src, text: s.textContent });
362-
s.remove();
363-
});
364-
content.innerHTML = tmp.innerHTML;
365-
366-
// Execute only new scripts (not previously executed)
367-
incomingScripts.forEach(function(scriptInfo) {
368-
var key = scriptInfo.src || scriptInfo.text;
369-
if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return;
370-
content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1');
371-
var newScript = document.createElement('script');
372-
if (scriptInfo.src) {
373-
newScript.src = scriptInfo.src;
374-
} else {
375-
newScript.textContent = scriptInfo.text;
376-
}
377-
content.appendChild(newScript);
378-
});
361+
if (!content) return;
362+
363+
// Strip script tags from HTML before inserting — scripts are handled separately below
364+
var tmp = document.createElement('div');
365+
tmp.innerHTML = e.data.html;
366+
var incomingScripts = [];
367+
tmp.querySelectorAll('script').forEach(function(s) {
368+
incomingScripts.push({ src: s.src, text: s.textContent });
369+
s.remove();
370+
});
371+
372+
// Reset tracking when content is cleared (new streaming session)
373+
if (!tmp.innerHTML.trim()) {
374+
content.removeAttribute('data-has-content');
375+
content.innerHTML = '';
379376
reportHeight();
377+
return;
380378
}
379+
380+
// First render: add stagger class for initial entrance animation
381+
var isFirstRender = !content.hasAttribute('data-has-content');
382+
if (isFirstRender) {
383+
content.classList.add('initial-render');
384+
content.setAttribute('data-has-content', '1');
385+
setTimeout(function() { content.classList.remove('initial-render'); }, 800);
386+
}
387+
388+
// Use idiomorph to diff/patch DOM (preserves existing nodes, no flicker)
389+
if (window.Idiomorph) {
390+
try {
391+
Idiomorph.morph(content, tmp.innerHTML, {
392+
morphStyle: 'innerHTML',
393+
callbacks: {
394+
beforeNodeAdded: function(node) {
395+
// Tag new element nodes for entrance animation
396+
if (node.nodeType === 1) {
397+
node.classList.add('morph-enter');
398+
node.addEventListener('animationend', function() {
399+
node.classList.remove('morph-enter');
400+
}, { once: true });
401+
}
402+
}
403+
}
404+
});
405+
} catch (err) {
406+
// Fallback: full replacement on morph failure
407+
content.innerHTML = tmp.innerHTML;
408+
}
409+
} else {
410+
content.innerHTML = tmp.innerHTML;
411+
}
412+
413+
// Execute only new scripts (not previously executed)
414+
incomingScripts.forEach(function(scriptInfo) {
415+
var key = scriptInfo.src || scriptInfo.text;
416+
if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return;
417+
content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1');
418+
var newScript = document.createElement('script');
419+
if (scriptInfo.src) {
420+
newScript.src = scriptInfo.src;
421+
} else {
422+
newScript.textContent = scriptInfo.text;
423+
}
424+
content.appendChild(newScript);
425+
});
426+
reportHeight();
381427
}
382428
});
383429
@@ -425,6 +471,7 @@ function assembleShell(initialHtml: string = ""): string {
425471
<div id="content">
426472
${initialHtml}
427473
</div>
474+
<script>${IDIOMORPH_JS}</script>
428475
<script>
429476
${BRIDGE_JS}
430477
</script>

0 commit comments

Comments
 (0)