import { IconAlertTriangle } from "@tabler/icons-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { AGENT_NAVIGATE_MESSAGE_TYPE } from "./embed.js"; export interface IframeEmbedProps { src?: string; aspect?: string; title?: string; height?: number; } const ALLOWED_ASPECTS = new Set(["16/9", "4/3", "1/1", "21/9", "3/2", "2/1"]); function isSameOriginSrc(src: string): boolean { if (typeof window === "undefined") return false; const trimmed = src.trim(); if (!trimmed) return false; if (trimmed.startsWith("javascript:") || trimmed.startsWith("data:")) { return false; } if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return true; if (trimmed.startsWith("./") || trimmed.startsWith("../")) return true; try { const url = new URL(trimmed, window.location.origin); return url.origin === window.location.origin; } catch { return false; } } function aspectToPaddingBottom(aspect: string): string { const [w, h] = aspect.split("/").map((n) => Number(n.trim())); if (!w || !h) return "56.25%"; return `${(h / w) * 100}%`; } /** * Parses the body of a ```embed fenced block. Accepts simple `key: value` * lines, ignoring blanks and unknown keys. No YAML — keeps the surface small. */ export function parseEmbedBody(body: string): Partial { const out: Partial = {}; for (const raw of body.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const idx = line.indexOf(":"); if (idx <= 0) continue; const key = line.slice(0, idx).trim().toLowerCase(); const value = line.slice(idx + 1).trim(); if (!value) continue; if (key === "src") out.src = value; else if (key === "aspect") out.aspect = value; else if (key === "title") out.title = value; else if (key === "height") { const n = Number(value); if (Number.isFinite(n) && n > 0) out.height = n; } } return out; } function BlockedEmbed({ reason, src }: { reason: string; src?: string }) { return (
Embed blocked
{reason}
{src && (
{src}
)}
); } /** * Inline iframe embed for assistant chat. Rendered from a ```embed fenced * code block. Same-origin paths only; sandboxed. */ export function IframeEmbed({ src, aspect, title, height }: IframeEmbedProps) { const [loaded, setLoaded] = useState(false); const iframeRef = useRef(null); const resolvedAspect = aspect && ALLOWED_ASPECTS.has(aspect) ? aspect : "16/9"; const displayTitle = title?.trim() || "Embedded content"; const paddingBottom = useMemo( () => aspectToPaddingBottom(resolvedAspect), [resolvedAspect], ); useEffect(() => { function onMessage(e: MessageEvent) { if (e.origin !== window.location.origin) return; const iframe = iframeRef.current; if (!iframe || e.source !== iframe.contentWindow) return; const data = e.data as { type?: unknown; path?: unknown } | null; if (!data || data.type !== AGENT_NAVIGATE_MESSAGE_TYPE) return; if (typeof data.path !== "string" || !data.path.startsWith("/")) return; if (data.path.startsWith("//")) return; window.history.pushState({}, "", data.path); window.dispatchEvent(new PopStateEvent("popstate")); } window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, []); if (!src) { return ; } if (!isSameOriginSrc(src)) { return ; } const style: React.CSSProperties = height ? { height: `${height}px` } : { paddingBottom }; return (
Preview
{!loaded && (
)}