"use client"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { ChatActionsContext } from "@/components/chat"; import type { ArtifactPartProps, DemoResult, JobRecord } from "@/src/core/contracts"; /** * Demo artifact card (W6): while the part is input-available, a job activity * card (poll /api/jobs — the job-card idiom); when output-available, the demo * runs in a SANDBOXED iframe (sandbox="allow-scripts", no allow-same-origin → * no access to app storage/cookies/DOM). * * The bridge script (contracts.ts "Demo iframe bridge") is auto-injected into * every demo by buildDemoSrcDoc — never the model's job: ResizeObserver → * postMessage {type:"demo-resize",height} (parent sizes the iframe) and * window.onerror → {type:"demo-error",message} (error chip on the card, with * a "Send to teacher" button that feeds the error back into the lesson — the * fix loop). * * Bridge message handling is a pure function (reduceDemoBridgeState) so * tests cover it without a DOM. */ // ─── bridge injection + message handling (pure, testable) ─────────────────── export const DEMO_BRIDGE_SCRIPT = ``; /** Inject the bridge before (or at the end of a body-less document). */ export function buildDemoSrcDoc(html: string): string { if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${DEMO_BRIDGE_SCRIPT}`); return `${html}${DEMO_BRIDGE_SCRIPT}`; } export interface DemoBridgeState { height: number | null; error: string | null; } export const INITIAL_DEMO_BRIDGE_STATE: DemoBridgeState = { height: null, error: null }; const MIN_HEIGHT = 80; const MAX_HEIGHT = 2400; /** Pure reducer over incoming postMessage data (contracts.ts DemoBridgeMessage). */ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): DemoBridgeState { if (typeof data !== "object" || data === null) return state; const message = data as { type?: unknown; height?: unknown; message?: unknown }; if (message.type === "demo-resize") { if (typeof message.height !== "number" || !Number.isFinite(message.height) || message.height <= 0) return state; const height = Math.min(Math.max(Math.round(message.height), MIN_HEIGHT), MAX_HEIGHT); return height === state.height ? state : { ...state, height }; } if (message.type === "demo-error") { const text = typeof message.message === "string" && message.message.trim() ? message.message : "unknown error"; return text === state.error ? state : { ...state, error: text }; } return state; } // ─── shared job activity card (input-available state) ─────────────────────── /** Live activity card for pending job parts — the job-card.tsx idiom, * parameterized by label. Exported for the visual card (also W6-owned). */ export function JobActivityCard({ toolCallId, label, testId, }: { toolCallId: string; label: string; testId?: string; }) { const actions = useContext(ChatActionsContext); const [job, setJob] = useState(null); const finishedRef = useRef(false); useEffect(() => { if (!actions) return; let cancelled = false; const poll = async () => { if (finishedRef.current) return true; try { const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(actions.chatId)}`); if (!res.ok) return false; const { jobs } = (await res.json()) as { jobs?: JobRecord[] }; const match = jobs?.find((j) => j.toolCallId === toolCallId) ?? null; if (cancelled) return false; setJob(match); if (match && match.status !== "running") { // The part has (or is about to be) fulfilled server-side — pull the // fresh messages once; the continuation resumes the teacher. finishedRef.current = true; void actions.syncFromServer(); return true; } } catch { /* transient fetch failure — keep polling */ } return false; }; const timer = setInterval(() => { if (finishedRef.current) { clearInterval(timer); return; } void poll().then((stop) => { if (stop) clearInterval(timer); }); }, 2000); void poll().then((stop) => { if (stop) clearInterval(timer); }); return () => { cancelled = true; clearInterval(timer); }; }, [actions, toolCallId]); return (
{label}
{job?.activity && (
{job.activity}
)}
); } // ─── the demo card ────────────────────────────────────────────────────────── export function DemoCard({ part }: ArtifactPartProps) { if (part.state === "output-available") { return ; } return ; } function DemoOutputCard({ output }: { output: unknown }) { const result = output as DemoResult | undefined; if (!result || typeof result.html !== "string" || typeof result.title !== "string") { return (
Demo completed, but the result could not be displayed.
); } const failed = result.html.length === 0; if (failed) { return (
Interactive demo failed

{result.notes ?? "The demo could not be built."}

); } return ; } function DemoFrame({ title, html, notes }: { title: string; html: string; notes?: string }) { const actions = useContext(ChatActionsContext); const iframeRef = useRef(null); const [state, setState] = useState(INITIAL_DEMO_BRIDGE_STATE); const srcDoc = useMemo(() => buildDemoSrcDoc(html), [html]); useEffect(() => { const onMessage = (event: MessageEvent) => { // Only messages from THIS demo's iframe (the sandbox gives it an // opaque origin; the window reference still matches). if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return; setState((prev) => reduceDemoBridgeState(prev, event.data)); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, []); return (
{title}
interactive demo
{state.error && ( actions.sendText(`The demo "${title}" threw an error: ${state.error}`) : undefined} /> )}