@@ -396,11 +396,37 @@ function assembleDocument(html: string): string {
396396</html>` ;
397397}
398398
399+ // ─── Loading Phrases ─────────────────────────────────────────────────
400+ const LOADING_PHRASES = [
401+ "Sketching pixels" ,
402+ "Wiring up nodes" ,
403+ "Painting gradients" ,
404+ "Compiling visuals" ,
405+ "Arranging atoms" ,
406+ "Rendering magic" ,
407+ "Polishing edges" ,
408+ ] ;
409+
410+ function useLoadingPhrase ( active : boolean ) {
411+ const [ index , setIndex ] = useState ( 0 ) ;
412+ useEffect ( ( ) => {
413+ if ( ! active ) return ;
414+ setIndex ( 0 ) ;
415+ const interval = setInterval ( ( ) => {
416+ setIndex ( ( i ) => ( i + 1 ) % LOADING_PHRASES . length ) ;
417+ } , 1800 ) ;
418+ return ( ) => clearInterval ( interval ) ;
419+ } , [ active ] ) ;
420+ return LOADING_PHRASES [ index ] ;
421+ }
422+
399423// ─── React Component ─────────────────────────────────────────────────
400424export function WidgetRenderer ( { title, description, html } : WidgetRendererProps ) {
401425 const iframeRef = useRef < HTMLIFrameElement > ( null ) ;
402426 const [ height , setHeight ] = useState ( 0 ) ;
403427 const [ loaded , setLoaded ] = useState ( false ) ;
428+ // Track what html has been committed to the iframe to avoid redundant reloads
429+ const committedHtmlRef = useRef ( "" ) ;
404430
405431 const handleMessage = useCallback ( ( e : MessageEvent ) => {
406432 // Only handle messages from our own iframe
@@ -419,47 +445,94 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
419445 return ( ) => window . removeEventListener ( "message" , handleMessage ) ;
420446 } , [ handleMessage ] ) ;
421447
422- // Reset loaded state when html changes
448+ // Write to iframe imperatively — bypasses React reconciliation so the
449+ // iframe only reloads when the html *content* truly changes, preserving
450+ // internal JS state (Three.js scenes, step counters, etc.) across
451+ // CopilotKit re-renders.
423452 useEffect ( ( ) => {
453+ if ( ! html || ! iframeRef . current ) return ;
454+ if ( html === committedHtmlRef . current ) return ;
455+ committedHtmlRef . current = html ;
456+ iframeRef . current . srcdoc = assembleDocument ( html ) ;
424457 setLoaded ( false ) ;
425458 setHeight ( 0 ) ;
426459 } , [ html ] ) ;
427460
461+ // Fallback: if iframe has html but hasn't reported ready after 4s, force-show
462+ useEffect ( ( ) => {
463+ if ( ! html || ( loaded && height > 0 ) ) return ;
464+ const timeout = setTimeout ( ( ) => {
465+ setLoaded ( true ) ;
466+ setHeight ( ( h ) => ( h > 0 ? h : 500 ) ) ;
467+ } , 4000 ) ;
468+ return ( ) => clearTimeout ( timeout ) ;
469+ } , [ html , loaded , height ] ) ;
470+
428471 // Iframe is ready when it has loaded AND reported a valid height
429472 const ready = loaded && height > 0 ;
430-
431- const srcdoc = html ? assembleDocument ( html ) : "" ;
473+ const showLoading = ! ! html && ! ready ;
474+ const loadingPhrase = useLoadingPhrase ( showLoading ) ;
432475
433476 return (
434477 < div className = "w-full my-3" >
435- { /* Skeleton: visible until iframe is fully ready */ }
436- { ! ready && (
437- < div className = "animate-pulse" >
438- < div className = "space-y-3 py-4" >
439- < div className = "h-4 bg-gray-200 dark:bg-zinc-700 rounded w-3/4" />
440- < div className = "h-32 bg-gray-200 dark:bg-zinc-700 rounded" />
441- < div className = "h-4 bg-gray-200 dark:bg-zinc-700 rounded w-1/2" />
442- </ div >
443- </ div >
444- ) }
445- { /* Iframe: rendered when html exists, but invisible until ready */ }
446- { html && (
447- < iframe
448- ref = { iframeRef }
449- srcDoc = { srcdoc }
450- sandbox = "allow-scripts allow-same-origin"
451- className = "w-full border-0"
452- onLoad = { ( ) => setLoaded ( true ) }
478+ { /* Loading indicator: visible until iframe is fully ready */ }
479+ { showLoading && (
480+ < div
481+ className = "overflow-hidden rounded-xl"
453482 style = { {
454- height : ready ? height : 0 ,
455- overflow : "hidden" ,
456- background : "transparent" ,
457- opacity : ready ? 1 : 0 ,
458- transition : "opacity 300ms ease-in" ,
483+ border : "1px solid var(--color-border-glass)" ,
484+ background : "var(--surface-primary)" ,
459485 } }
460- title = { title }
461- />
486+ >
487+ { /* Animated gradient border top */ }
488+ < div
489+ style = { {
490+ height : 2 ,
491+ background : "linear-gradient(90deg, var(--color-lilac), var(--color-mint), var(--color-lilac))" ,
492+ backgroundSize : "200% 100%" ,
493+ animation : "shimmer 1.5s ease-in-out infinite" ,
494+ } }
495+ />
496+ < div className = "flex items-center gap-3 px-4 py-3" >
497+ { /* Spinning icon */ }
498+ < div
499+ style = { {
500+ width : 18 ,
501+ height : 18 ,
502+ borderRadius : "50%" ,
503+ border : "2px solid var(--color-border-light)" ,
504+ borderTopColor : "var(--color-lilac-dark)" ,
505+ animation : "spin 0.8s linear infinite" ,
506+ flexShrink : 0 ,
507+ } }
508+ />
509+ < span
510+ className = "text-[13px] font-medium"
511+ style = { { color : "var(--text-secondary)" } }
512+ >
513+ { loadingPhrase } ...
514+ </ span >
515+ </ div >
516+ </ div >
462517 ) }
518+ { /* Iframe: always mounted so ref is stable; srcdoc set imperatively.
519+ No srcDoc React prop — prevents React from reloading the iframe
520+ on parent re-renders. */ }
521+ < iframe
522+ ref = { iframeRef }
523+ sandbox = "allow-scripts allow-same-origin"
524+ className = "w-full border-0"
525+ onLoad = { ( ) => setLoaded ( true ) }
526+ style = { {
527+ height : ready ? height : 0 ,
528+ overflow : "hidden" ,
529+ background : "transparent" ,
530+ opacity : ready ? 1 : 0 ,
531+ transition : "opacity 300ms ease-in" ,
532+ display : html ? undefined : "none" ,
533+ } }
534+ title = { title }
535+ />
463536 </ div >
464537 ) ;
465538}
0 commit comments