diff --git a/components/artifacts/demo-card.tsx b/components/artifacts/demo-card.tsx index cae2f3a..bbb6b50 100644 --- a/components/artifacts/demo-card.tsx +++ b/components/artifacts/demo-card.tsx @@ -1,10 +1,257 @@ -import type { ArtifactPartProps } from "@/src/core/contracts"; +"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): sandboxed iframe rendering DemoResult.html with - * the auto-injected resize/error bridge (contracts.ts "Demo iframe bridge"). - * While input-available: job activity card (poll /api/jobs). + * 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. */ -export function DemoCard(_props: ArtifactPartProps) { - return null; + +// ─── 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} + /> + )} +