Skip to content

Commit 245b314

Browse files
breadonceejerelvelarde
authored andcommitted
feat: progressive streaming rendering for widget previews
Replace the hide-everything-then-reveal approach with progressive streaming that shows content building up as the LLM streams HTML tokens. The iframe shell loads once and content updates are sent via postMessage instead of full srcdoc reloads. - Loading phrases shown above the widget during streaming - Save Template button only appears after streaming settles - Debounced htmlSettled detection (800ms of no changes) - Smooth fade-out transitions for streaming indicator
1 parent 50d00e2 commit 245b314

1 file changed

Lines changed: 143 additions & 63 deletions

File tree

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

Lines changed: 143 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -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
350372
function 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 */
364387
function 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

Comments
 (0)