@@ -346,6 +346,28 @@ document.addEventListener('click', function(e) {
346346 }
347347});
348348
349+ // Listen for streaming content updates from parent
350+ window.addEventListener('message', function(e) {
351+ if (e.data && e.data.type === 'update-content') {
352+ var content = document.getElementById('content');
353+ if (content) {
354+ content.innerHTML = e.data.html;
355+ // Re-run any inline scripts (new ones added by streaming)
356+ var scripts = content.querySelectorAll('script');
357+ scripts.forEach(function(oldScript) {
358+ var newScript = document.createElement('script');
359+ if (oldScript.src) {
360+ newScript.src = oldScript.src;
361+ } else {
362+ newScript.textContent = oldScript.textContent;
363+ }
364+ oldScript.parentNode.replaceChild(newScript, oldScript);
365+ });
366+ reportHeight();
367+ }
368+ }
369+ });
370+
349371// Auto-resize: report content height to host
350372function reportHeight() {
351373 var content = document.getElementById('content');
@@ -361,7 +383,13 @@ setTimeout(function() { clearInterval(_resizeInterval); }, 15000);
361383` ;
362384
363385// ─── Document Assembly ───────────────────────────────────────────────
386+ /** Full document with content — used for final/complete renders */
364387function assembleDocument ( html : string ) : string {
388+ return assembleShell ( html ) ;
389+ }
390+
391+ /** Empty shell or shell with initial content — iframe loads once, content streamed via postMessage */
392+ function assembleShell ( initialHtml : string = "" ) : string {
365393 return `<!DOCTYPE html>
366394<html>
367395<head>
@@ -387,7 +415,7 @@ function assembleDocument(html: string): string {
387415</head>
388416<body>
389417 <div id="content">
390- ${ html }
418+ ${ initialHtml }
391419 </div>
392420 <script>
393421 ${ BRIDGE_JS }
@@ -429,8 +457,15 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
429457 const [ loaded , setLoaded ] = useState ( false ) ;
430458 const [ saveState , setSaveState ] = useState < SaveState > ( "idle" ) ;
431459 const [ templateName , setTemplateName ] = useState ( "" ) ;
432- // Track what html has been committed to the iframe to avoid redundant reloads
460+ // Whether the iframe shell has been initialized
461+ const shellReadyRef = useRef ( false ) ;
462+ // Track the last html sent to the iframe to avoid redundant updates
433463 const committedHtmlRef = useRef ( "" ) ;
464+ // Whether streaming has started (html is arriving but may not be complete)
465+ const [ streaming , setStreaming ] = useState ( false ) ;
466+ // Tracks whether html content has settled (stopped changing)
467+ const [ htmlSettled , setHtmlSettled ] = useState ( false ) ;
468+ const settledTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
434469
435470 const handleMessage = useCallback ( ( e : MessageEvent ) => {
436471 // Only handle messages from our own iframe
@@ -449,20 +484,49 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
449484 return ( ) => window . removeEventListener ( "message" , handleMessage ) ;
450485 } , [ handleMessage ] ) ;
451486
452- // Write to iframe imperatively — bypasses React reconciliation so the
453- // iframe only reloads when the html *content* truly changes, preserving
454- // internal JS state (Three.js scenes, step counters, etc.) across
455- // CopilotKit re-renders.
487+ // Initialize the iframe shell once when html first appears.
488+ // After that, stream content updates via postMessage — no iframe reload.
456489 useEffect ( ( ) => {
457490 if ( ! html || ! iframeRef . current ) return ;
491+
492+ if ( ! shellReadyRef . current ) {
493+ // First time: load the full document so the shell (styles, bridge JS) is ready
494+ shellReadyRef . current = true ;
495+ committedHtmlRef . current = html ;
496+ iframeRef . current . srcdoc = assembleShell ( html ) ;
497+ setStreaming ( true ) ;
498+ return ;
499+ }
500+
501+ // Subsequent updates: stream content into existing iframe via postMessage
458502 if ( html === committedHtmlRef . current ) return ;
459503 committedHtmlRef . current = html ;
460- iframeRef . current . srcdoc = assembleDocument ( html ) ;
461- setLoaded ( false ) ;
462- setHeight ( 0 ) ;
504+
505+ const iframe = iframeRef . current ;
506+ if ( iframe . contentWindow ) {
507+ iframe . contentWindow . postMessage (
508+ { type : "update-content" , html } ,
509+ "*"
510+ ) ;
511+ }
512+ } , [ html ] ) ;
513+
514+ // Detect when html has stopped changing (streaming complete).
515+ // Resets a debounce timer on every html update — settles after 800ms of no changes.
516+ useEffect ( ( ) => {
517+ if ( ! html ) {
518+ setHtmlSettled ( false ) ;
519+ return ;
520+ }
521+ setHtmlSettled ( false ) ;
522+ if ( settledTimerRef . current ) clearTimeout ( settledTimerRef . current ) ;
523+ settledTimerRef . current = setTimeout ( ( ) => setHtmlSettled ( true ) , 800 ) ;
524+ return ( ) => {
525+ if ( settledTimerRef . current ) clearTimeout ( settledTimerRef . current ) ;
526+ } ;
463527 } , [ html ] ) ;
464528
465- // Fallback: if iframe has html but hasn't reported ready after 4s, force-show
529+ // Fallback: if iframe has html but hasn't reported a height after 4s, force-show
466530 useEffect ( ( ) => {
467531 if ( ! html || ( loaded && height > 0 ) ) return ;
468532 const timeout = setTimeout ( ( ) => {
@@ -472,10 +536,27 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
472536 return ( ) => clearTimeout ( timeout ) ;
473537 } , [ html , loaded , height ] ) ;
474538
475- // Iframe is ready when it has loaded AND reported a valid height
539+ // Content is "complete" when iframe has loaded and reported a valid height
476540 const ready = loaded && height > 0 ;
477- const showLoading = ! ! html && ! ready ;
478- const loadingPhrase = useLoadingPhrase ( showLoading ) ;
541+ // Show the iframe (even partially) as soon as we have content streaming
542+ const showIframe = ! ! html && ( streaming || ready ) ;
543+ // Streaming is active until html has stopped changing
544+ const isStreaming = ! ! html && ! htmlSettled ;
545+ // Save template only available when fully rendered AND streaming is done
546+ const showSaveTemplate = ready && htmlSettled ;
547+ const loadingPhrase = useLoadingPhrase ( isStreaming ) ;
548+
549+ // Keep the streaming indicator mounted long enough to fade out
550+ const [ showStreamingIndicator , setShowStreamingIndicator ] = useState ( false ) ;
551+ useEffect ( ( ) => {
552+ if ( isStreaming ) {
553+ setShowStreamingIndicator ( true ) ;
554+ } else if ( showStreamingIndicator ) {
555+ // Keep mounted for fade-out, then unmount
556+ const timeout = setTimeout ( ( ) => setShowStreamingIndicator ( false ) , 600 ) ;
557+ return ( ) => clearTimeout ( timeout ) ;
558+ }
559+ } , [ isStreaming , showStreamingIndicator ] ) ;
479560
480561 const handleSaveTemplate = useCallback ( ( ) => {
481562 const name = templateName . trim ( ) || title || "Untitled Template" ;
@@ -494,10 +575,49 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
494575 } , [ templateName , title , description , html ] ) ;
495576
496577 return (
497- < div className = "w-full my-3 relative" >
498- { /* Save as Template — only shown when widget is ready */ }
499- { ready && html && (
500- < div className = "absolute top-2 right-2 z-10" >
578+ < div className = "w-full my-3" >
579+ { /* Loading phrases — sits above the widget, fades out when ready */ }
580+ { showStreamingIndicator && (
581+ < div
582+ className = "mb-2 transition-all duration-500 ease-out"
583+ style = { {
584+ opacity : isStreaming ? 1 : 0 ,
585+ maxHeight : isStreaming ? 32 : 0 ,
586+ overflow : "hidden" ,
587+ } }
588+ >
589+ < div className = "flex items-center gap-2" >
590+ < div
591+ style = { {
592+ width : 12 ,
593+ height : 12 ,
594+ borderRadius : "50%" ,
595+ border : "2px solid var(--color-border-light, rgba(0,0,0,0.1))" ,
596+ borderTopColor : "var(--color-lilac-dark, #6366f1)" ,
597+ animation : "spin 0.8s linear infinite" ,
598+ flexShrink : 0 ,
599+ } }
600+ />
601+ < span
602+ className = "text-[12px] font-medium"
603+ style = { { color : "var(--text-secondary, #666)" } }
604+ >
605+ { loadingPhrase } ...
606+ </ span >
607+ </ div >
608+ </ div >
609+ ) }
610+
611+ < div className = "relative" >
612+ { /* Save as Template — fades in once widget is fully ready */ }
613+ { html && (
614+ < div
615+ className = "absolute top-2 right-2 z-10 transition-opacity duration-500"
616+ style = { {
617+ opacity : showSaveTemplate ? 1 : 0 ,
618+ pointerEvents : showSaveTemplate ? "auto" : "none" ,
619+ } }
620+ >
501621 { /* ── Saved confirmation ── */ }
502622 { saveState === "saved" && (
503623 < div
@@ -610,64 +730,24 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
610730 ) }
611731 </ div >
612732 ) }
613- { /* Loading indicator: visible until iframe is fully ready */ }
614- { showLoading && (
615- < div
616- className = "overflow-hidden rounded-xl"
617- style = { {
618- border : "1px solid var(--color-border-glass)" ,
619- background : "var(--surface-primary)" ,
620- } }
621- >
622- { /* Animated gradient border top */ }
623- < div
624- style = { {
625- height : 2 ,
626- background : "linear-gradient(90deg, var(--color-lilac), var(--color-mint), var(--color-lilac))" ,
627- backgroundSize : "200% 100%" ,
628- animation : "shimmer 1.5s ease-in-out infinite" ,
629- } }
630- />
631- < div className = "flex items-center gap-3 px-4 py-3" >
632- { /* Spinning icon */ }
633- < div
634- style = { {
635- width : 18 ,
636- height : 18 ,
637- borderRadius : "50%" ,
638- border : "2px solid var(--color-border-light)" ,
639- borderTopColor : "var(--color-lilac-dark)" ,
640- animation : "spin 0.8s linear infinite" ,
641- flexShrink : 0 ,
642- } }
643- />
644- < span
645- className = "text-[13px] font-medium"
646- style = { { color : "var(--text-secondary)" } }
647- >
648- { loadingPhrase } ...
649- </ span >
650- </ div >
651- </ div >
652- ) }
653- { /* Iframe: always mounted so ref is stable; srcdoc set imperatively.
654- No srcDoc React prop — prevents React from reloading the iframe
655- on parent re-renders. */ }
733+ { /* Iframe: always mounted so ref is stable; srcdoc set once,
734+ content streamed via postMessage for progressive rendering. */ }
656735 < iframe
657736 ref = { iframeRef }
658737 sandbox = "allow-scripts allow-same-origin"
659738 className = "w-full border-0"
660739 onLoad = { ( ) => setLoaded ( true ) }
661740 style = { {
662- height : ready ? height : 0 ,
741+ height : showIframe ? ( height > 0 ? height : 300 ) : 0 ,
663742 overflow : "hidden" ,
664743 background : "transparent" ,
665- opacity : ready ? 1 : 0 ,
666- transition : "opacity 300ms ease-in " ,
744+ opacity : showIframe ? 1 : 0 ,
745+ transition : "height 200ms ease-out " ,
667746 display : html ? undefined : "none" ,
668747 } }
669748 title = { title }
670749 />
750+ </ div >
671751 </ div >
672752 ) ;
673753}
0 commit comments