@tavus/cvi-ui 0.0.4-beta.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +10 -10
  2. package/package.json +7 -7
package/dist/index.js CHANGED
@@ -386,16 +386,16 @@ var hair_check_01_default$1 = {
386
386
  //#region src/templates/tsx/components/magic-canvas.json
387
387
  var magic_canvas_default$1 = {
388
388
  type: "components",
389
- content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';\nimport { useObservableEvent, useSendAppMessage } from '../../hooks/cvi-events-hooks';\n\nimport styles from './magic-canvas.module.css';\nimport { closeBridge } from './bridge';\nimport {\n applyCanvasCommand,\n buildCanvasModelContextAppend,\n buildHostContext,\n CANVAS_LAYOUT_SLOTS,\n createCanvasInstance,\n extractCanvasInteraction,\n extractTextContent,\n isCanvasToolCallMessage,\n MAGIC_CANVAS_MAX_HEIGHT_PX,\n normalizeInteraction,\n parseCanvasConfig,\n parseCanvasControlCommand,\n parseToolArguments,\n resolveCanvasLayout,\n resolveCanvasSidecarLayout,\n} from './runtime';\nimport type {\n CanvasDisplayMode,\n CanvasErrorCode,\n CanvasErrorEvent,\n CanvasInstance,\n CanvasInteractionEvent,\n CanvasLayoutSlot,\n CanvasModelContextUpdate,\n CanvasResolvedLayout,\n CanvasSidecarLayout,\n CanvasViewport,\n CanvasToolCallProperties,\n} from './runtime';\n\nimport {\n createCanvasCompletionScheduler,\n deliverCanvasInteraction,\n NativeCanvasHost,\n resolveCanvasRenderer,\n} from './native-host';\nimport type { CanvasRenderRegistry } from './native-host';\n\nexport {\n type CanvasErrorCode,\n type CanvasErrorEvent,\n type CanvasInteractionEvent,\n type CanvasSidecarLayout,\n} from './runtime';\nexport {\n canvasRendererKey,\n NativeCanvasHost,\n resolveCanvasRenderer,\n type CanvasComponentRenderer,\n type CanvasRenderRegistry,\n type CanvasRendererProps,\n} from './native-host';\n\ntype MagicCanvasProps = {\n className?: string;\n onInteraction?: (event: CanvasInteractionEvent) => void | Promise<void>;\n onError?: (event: CanvasErrorEvent) => void;\n onLayoutEffectChange?: (layout: CanvasSidecarLayout) => void;\n /**\n * Optional registry of native renderers keyed by `\"<component>@<version>\"`.\n * Matching instances render via NativeCanvasHost; otherwise the iframe\n * sandbox is used unchanged (default-off — omit and behavior is identical).\n */\n renderComponent?: CanvasRenderRegistry;\n};\n\nconst MODEL_CONTEXT_RELAY_INTERVAL_MS = 1000;\n\n// Floor for app-REPORTED heights. The runtime's MAGIC_CANVAS_MIN_HEIGHT_PX\n// (240) is a layout default, not a floor for real reports — flooring there\n// padded short components (e.g. a 182px input card) with dead space below\n// the content. Real reports are trusted; this only guards degenerate values\n// from a mid-load ResizeObserver tick. Mirrors the tavus-deployment host.\nconst CANVAS_REPORTED_HEIGHT_FLOOR_PX = 48;\n\n// Per-component iframe sandbox policy, owned by the host (not server-supplied).\n// Default is locked-down allow-scripts; Calendly's scheduling_embed needs its\n// own origin, popups, and form submission to complete a booking.\nconst DEFAULT_IFRAME_SANDBOX = 'allow-scripts';\nconst COMPONENT_IFRAME_SANDBOX: Record<string, string> = {\n 'canvas.scheduling_embed': 'allow-scripts allow-same-origin allow-popups allow-forms',\n};\n\n// Trust a bridge message only when it comes from THIS iframe's LIVE\n// contentWindow (read at message time, not captured earlier) and is JSON-RPC\n// shaped: accepts the component's post-navigation messages while rejecting\n// forged JSON-RPC from other frames and cross-talk between sibling canvases.\nexport function isTrustedCanvasFrameMessage(\n event: Pick<MessageEvent, 'source' | 'data'>,\n iframe: Pick<HTMLIFrameElement, 'contentWindow'>\n): boolean {\n if (!iframe.contentWindow || event.source !== iframe.contentWindow) return false;\n const data = event.data as { jsonrpc?: unknown } | null | undefined;\n return Boolean(data) && typeof data === 'object' && data?.jsonrpc === '2.0';\n}\n\n// Host-side JSON-RPC transport for the component iframe. We connect\n// SYNCHRONOUSLY, before the iframe navigates, to catch the app's\n// `ui/initialize` handshake (fires during module-script execution, before\n// `load`). The SDK's PostMessageTransport can't do this: it compares\n// `event.source` against a window captured at construct time, the stale\n// pre-navigation about:blank window, and would drop the handshake. This\n// transport validates against the LIVE `iframe.contentWindow` at receive\n// time. Mirrors the tavus-deployment host.\nexport class CanvasFrameTransport {\n onmessage?: (message: unknown) => void;\n onerror?: (error: Error) => void;\n onclose?: () => void;\n\n #iframe: HTMLIFrameElement;\n // `| undefined` (not the `?` optional marker): the TS->JS template\n // converter strips types but keeps the optional marker on private class\n // fields, which would emit invalid JS (`#listener?;`).\n #listener: ((event: MessageEvent) => void) | undefined = undefined;\n\n constructor(iframe: HTMLIFrameElement) {\n this.#iframe = iframe;\n }\n\n start(): Promise<void> {\n const listener = (event: MessageEvent) => {\n if (!isTrustedCanvasFrameMessage(event, this.#iframe)) return;\n this.onmessage?.(event.data);\n };\n this.#listener = listener;\n globalThis.addEventListener('message', listener);\n return Promise.resolve();\n }\n\n send(message: unknown): Promise<void> {\n // Same `\"*\"` target origin the SDK transport uses; the receiver validates\n // source rather than relying on a target-origin match (the sandboxed app\n // has an opaque origin).\n this.#iframe.contentWindow?.postMessage(message, '*');\n return Promise.resolve();\n }\n\n close(): Promise<void> {\n if (this.#listener) globalThis.removeEventListener('message', this.#listener);\n this.#listener = undefined;\n this.onclose?.();\n return Promise.resolve();\n }\n}\n\n// Navigate the iframe and wire the bridge connect. Connect synchronously,\n// BEFORE the iframe navigates: the app sends `ui/initialize` during\n// module-script execution, before `load`, and postMessage does not queue for\n// late listeners, so attaching only on `load` makes connect() time out\n// (\"Unable to connect to host\"). `connectBridge` is idempotent, so the `load`\n// listener stays as a rare-case fallback. Returns the listener disposer.\nexport function wireCanvasFrameConnect(\n iframe: Pick<HTMLIFrameElement, 'addEventListener' | 'removeEventListener'> & { src: string },\n targetHref: string,\n connectBridge: () => void\n): () => void {\n iframe.addEventListener('load', connectBridge);\n iframe.src = targetHref;\n connectBridge();\n return () => iframe.removeEventListener('load', connectBridge);\n}\n\nexport const MagicCanvas = memo(\n ({\n className,\n onInteraction,\n onError,\n onLayoutEffectChange,\n renderComponent,\n }: MagicCanvasProps) => {\n const [instances, setInstances] = useState<CanvasInstance[]>([]);\n const viewport = useCanvasViewport();\n const onErrorRef = useRef(onError);\n\n useEffect(() => {\n onErrorRef.current = onError;\n }, [onError]);\n\n const reportError = useCallback((event: CanvasErrorEvent) => {\n onErrorRef.current?.(event);\n }, []);\n\n useObservableEvent<CanvasToolCallProperties>(\n useCallback(\n (event) => {\n if (!isCanvasToolCallMessage(event)) return;\n\n try {\n if (event.canvas_config === undefined || event.canvas_config === null) {\n const controlCommand = parseCanvasControlCommand(event);\n if (!controlCommand) return;\n setInstances((current) => applyCanvasCommand(current, controlCommand));\n return;\n }\n\n const canvasConfig = parseCanvasConfig(event.canvas_config);\n if (!canvasConfig) {\n reportError({\n code: 'malformed_canvas_config',\n message: 'Received malformed Magic Canvas config.',\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n return;\n }\n\n const toolCallId = event.properties.tool_call_id;\n if (!toolCallId) {\n reportError({\n code: 'missing_tool_call_id',\n message: 'Received Magic Canvas tool call without tool_call_id.',\n conversation_id: event.conversation_id,\n component: canvasConfig.component,\n });\n return;\n }\n\n const parsedArguments = parseToolArguments(event.properties.arguments);\n if (!parsedArguments.ok) {\n reportError({\n code: 'invalid_tool_arguments',\n message: parsedArguments.error.message,\n conversation_id: event.conversation_id,\n tool_call_id: toolCallId,\n component: canvasConfig.component,\n cause: parsedArguments.error,\n });\n return;\n }\n\n const instance = createCanvasInstance({\n conversationId: event.conversation_id,\n toolCallId,\n args: parsedArguments.value,\n canvasConfig,\n });\n\n setInstances((current) => applyCanvasCommand(current, { kind: 'show', instance }));\n } catch (error) {\n reportError({\n code: 'invalid_tool_arguments',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n }\n },\n [reportError]\n )\n );\n\n const resolvedInstances = instances.map((instance) => ({\n instance,\n layout: resolveCanvasLayout(instance, viewport),\n }));\n const sidecarLayout = resolveCanvasSidecarLayout(\n resolvedInstances.map(({ layout }) => layout),\n viewport\n );\n const fullscreenToolCallId = [...resolvedInstances]\n .reverse()\n .find(({ layout }) => layout.display_mode === 'fullscreen')?.instance.tool_call_id;\n\n useEffect(() => {\n onLayoutEffectChange?.(sidecarLayout);\n }, [\n onLayoutEffectChange,\n sidecarLayout.active,\n sidecarLayout.side,\n sidecarLayout.video_shift_x,\n sidecarLayout.backdrop.type,\n sidecarLayout.safe_area?.x,\n sidecarLayout.safe_area?.y,\n sidecarLayout.safe_area?.width,\n sidecarLayout.safe_area?.height,\n ]);\n\n if (instances.length === 0) return null;\n\n return (\n <div className={[styles.container, className].filter(Boolean).join(' ')}>\n {fullscreenToolCallId && <div className={styles.backdrop} />}\n {CANVAS_LAYOUT_SLOTS.map((slot) => {\n const slotInstances = resolvedInstances.filter(\n ({ layout }) => canvasRenderSlot(layout) === slot\n );\n if (slotInstances.length === 0) return null;\n\n return (\n <div key={slot} className={`${styles.slot} ${slotClassName(slot)}`}>\n {slotInstances.map(({ instance, layout }) => {\n const dimmed = Boolean(\n fullscreenToolCallId && fullscreenToolCallId !== instance.tool_call_id\n );\n const onComplete = () => {\n setInstances((current) => current.filter((item) => item.id !== instance.id));\n };\n\n // Default-off: native renderer only when the registry has an entry\n // for this component@version; otherwise the iframe path, unchanged.\n const renderer = resolveCanvasRenderer(renderComponent, instance);\n if (renderer) {\n return (\n <NativeCanvasHost\n key={instance.id}\n instance={instance}\n layout={layout}\n render={renderer}\n dimmed={dimmed}\n onComplete={onComplete}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n }\n\n return (\n <CanvasFrame\n key={instance.id}\n instance={instance}\n layout={layout}\n dimmed={dimmed}\n onComplete={onComplete}\n onDisplayModeChange={(displayMode) => {\n setInstances((current) =>\n current.map((item) =>\n item.tool_call_id === instance.tool_call_id\n ? { ...item, layout: { ...item.layout, display_mode: displayMode } }\n : item\n )\n );\n }}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n })}\n </div>\n );\n })}\n </div>\n );\n }\n);\n\nMagicCanvas.displayName = 'MagicCanvas';\n\ntype CanvasFrameProps = {\n instance: CanvasInstance;\n layout: CanvasResolvedLayout;\n dimmed?: boolean;\n onComplete?: () => void;\n onDisplayModeChange?: (displayMode: CanvasDisplayMode) => void;\n onInteraction?: (event: CanvasInteractionEvent) => void | Promise<void>;\n onError?: (event: CanvasErrorEvent) => void;\n};\n\nconst CanvasFrame = memo(\n ({\n instance,\n layout,\n dimmed,\n onComplete,\n onDisplayModeChange,\n onInteraction,\n onError,\n }: CanvasFrameProps) => {\n const iframeRef = useRef<HTMLIFrameElement>(null);\n const bridgeRef = useRef<AppBridge | null>(null);\n const instanceRef = useRef(instance);\n const layoutRef = useRef(layout);\n const lastSentRevisionRef = useRef(-1);\n const sendAppMessage = useSendAppMessage();\n const onCompleteRef = useRef(onComplete);\n const onDisplayModeChangeRef = useRef(onDisplayModeChange);\n const onInteractionRef = useRef(onInteraction);\n const onErrorRef = useRef(onError);\n const sendAppMessageRef = useRef(sendAppMessage);\n const [frameHeight, setFrameHeight] = useState(320);\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n instanceRef.current = instance;\n }, [instance]);\n\n useEffect(() => {\n layoutRef.current = layout;\n bridgeRef.current?.setHostContext(buildHostContext(instanceRef.current, layout));\n }, [layout]);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n onDisplayModeChangeRef.current = onDisplayModeChange;\n onInteractionRef.current = onInteraction;\n onErrorRef.current = onError;\n sendAppMessageRef.current = sendAppMessage;\n }, [onComplete, onDisplayModeChange, onInteraction, onError, sendAppMessage]);\n\n useEffect(() => {\n const iframe = iframeRef.current;\n if (!iframe) return;\n\n let closed = false;\n let bridge: AppBridge | null = null;\n let pendingModelContextUpdate: CanvasModelContextUpdate | null = null;\n let modelContextRelayTimer: ReturnType<typeof setTimeout> | null = null;\n // Decision 17 deferred-submit completion, shared with NativeCanvasHost.\n const completionScheduler = createCanvasCompletionScheduler(() => closed);\n const targetHref = new URL(instance.canvas_config.sandbox_url, globalThis.location?.href)\n .href;\n\n const reportError = (event: CanvasErrorEvent) => {\n if (!closed) onErrorRef.current?.(event);\n };\n\n const flushModelContextUpdate = () => {\n if (closed || !pendingModelContextUpdate) return;\n\n const currentInstance = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.append_llm_context',\n conversation_id: currentInstance.conversation_id,\n properties: {\n context: buildCanvasModelContextAppend(currentInstance, pendingModelContextUpdate),\n },\n });\n pendingModelContextUpdate = null;\n };\n\n const buildFrameError = (\n code: CanvasErrorCode,\n defaultMessage: string,\n cause: unknown\n ): CanvasErrorEvent => ({\n code,\n message: cause instanceof Error && cause.message ? cause.message : defaultMessage,\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause,\n });\n\n const connectBridge = () => {\n if (bridge || iframe.src !== targetHref) return;\n if (closed || !iframe.contentWindow) return;\n\n const nextBridge = new AppBridge(\n null,\n { name: '@tavus/cvi-ui', version: '0.0.0' },\n {\n logging: {},\n message: {\n text: {},\n structuredContent: {},\n },\n updateModelContext: {\n text: {},\n structuredContent: {},\n },\n },\n {\n hostContext: buildHostContext(instance, layoutRef.current),\n }\n );\n bridge = nextBridge;\n bridgeRef.current = nextBridge;\n\n nextBridge.oninitialized = () => {\n if (closed) return;\n setReady(true);\n lastSentRevisionRef.current = instanceRef.current.revision;\n void nextBridge\n .sendToolInput({ arguments: instanceRef.current.arguments })\n .catch((error) => {\n reportError(\n buildFrameError(\n 'send_tool_input_failed',\n 'Magic Canvas failed to send tool input to the iframe.',\n error\n )\n );\n });\n };\n\n nextBridge.onsizechange = ({ height }) => {\n if (!closed && typeof height === 'number' && Number.isFinite(height)) {\n setFrameHeight(\n Math.min(\n Math.max(height, CANVAS_REPORTED_HEIGHT_FLOOR_PX),\n MAGIC_CANVAS_MAX_HEIGHT_PX\n )\n );\n }\n };\n\n nextBridge.onmessage = async (message) => {\n let interaction: CanvasInteractionEvent | null = null;\n const interactionPayload = extractCanvasInteraction(message);\n const responseText = extractTextContent(message);\n\n if (interactionPayload) {\n try {\n interaction = normalizeInteraction(instanceRef.current, interactionPayload);\n } catch (error) {\n reportError(\n buildFrameError(\n 'interaction_normalization_failed',\n 'Magic Canvas failed to normalize an interaction.',\n error\n )\n );\n }\n }\n\n if (responseText && !interaction) {\n reportError({\n code: 'missing_interaction_metadata',\n message:\n 'Magic Canvas message included text without interaction metadata; no interaction row was recorded.',\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n });\n return {};\n }\n\n if (interaction) {\n const delivery = await deliverCanvasInteraction({\n interaction,\n canvasConfig: instance.canvas_config,\n onInteraction: onInteractionRef.current,\n reportError,\n buildError: buildFrameError,\n });\n\n // Don't tell the embedded app the interaction succeeded when the\n // POST failed: surface the error instead of continuing to\n // respond/complete, so the card stays visible and the app keeps\n // a retry signal. Mirrors the tavus-deployment host.\n if (!delivery.ok) {\n return {\n isError: true,\n code: delivery.error.code,\n message: delivery.error.message,\n };\n }\n }\n\n if (responseText) {\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.respond',\n conversation_id: instance.conversation_id,\n properties: {\n text: responseText,\n },\n });\n }\n\n // Decision 17: submit defers by CANVAS_SUBMIT_TEARDOWN_DELAY_MS so\n // the embedded app's confirmation stays visible; skip/dismiss/clear\n // complete instantly. Failed deliveries returned above and never\n // reach this.\n if (interaction)\n completionScheduler.complete(interaction, () => onCompleteRef.current?.());\n\n return {};\n };\n\n nextBridge.onupdatemodelcontext = async (modelContextUpdate) => {\n pendingModelContextUpdate = modelContextUpdate;\n if (!modelContextRelayTimer) {\n modelContextRelayTimer = setTimeout(() => {\n modelContextRelayTimer = null;\n flushModelContextUpdate();\n }, MODEL_CONTEXT_RELAY_INTERVAL_MS);\n }\n\n return {};\n };\n\n nextBridge.onrequestdisplaymode = async ({ mode }) => {\n const nextMode = mode === 'fullscreen' ? 'fullscreen' : 'inline';\n onDisplayModeChangeRef.current?.(nextMode);\n layoutRef.current = {\n ...layoutRef.current,\n display_mode: nextMode,\n viable_slot: nextMode === 'fullscreen' ? 'full' : layoutRef.current.viable_slot,\n };\n nextBridge.setHostContext(buildHostContext(instanceRef.current, layoutRef.current));\n return { mode: nextMode };\n };\n\n // See the CanvasFrameTransport class comment for why the SDK's\n // PostMessageTransport can't be used for this pre-navigation connect.\n void nextBridge.connect(new CanvasFrameTransport(iframe)).catch((error) => {\n reportError(\n buildFrameError(\n 'bridge_connect_failed',\n 'Magic Canvas iframe bridge failed to connect.',\n error\n )\n );\n });\n };\n\n const disconnectFrame = wireCanvasFrameConnect(iframe, targetHref, connectBridge);\n\n return () => {\n if (modelContextRelayTimer) {\n clearTimeout(modelContextRelayTimer);\n modelContextRelayTimer = null;\n }\n flushModelContextUpdate();\n closed = true;\n completionScheduler.cancel();\n disconnectFrame();\n bridgeRef.current = null;\n if (bridge) void closeBridge(bridge);\n };\n }, [instance.id, instance.canvas_config.sandbox_url]);\n\n useEffect(() => {\n if (!ready || instance.revision === 0) return;\n if (instance.revision <= lastSentRevisionRef.current) return;\n lastSentRevisionRef.current = instance.revision;\n void bridgeRef.current?.sendToolInput({ arguments: instance.arguments }).catch((error) => {\n onErrorRef.current?.({\n code: 'send_tool_input_failed',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause: error,\n });\n });\n }, [instance, ready]);\n\n const isFull = layout.viable_slot === 'full' || layout.display_mode === 'fullscreen';\n\n return (\n <div\n className={[\n styles.frameShell,\n ready ? styles.frameShellReady : '',\n isFull ? styles.frameShellFull : '',\n dimmed ? styles.frameShellDimmed : '',\n ]\n .filter(Boolean)\n .join(' ')}\n >\n <iframe\n ref={iframeRef}\n title={`${instance.canvas_config.component} ${instance.canvas_config.component_version}`}\n className={styles.frame}\n sandbox={\n COMPONENT_IFRAME_SANDBOX[instance.canvas_config.component] ?? DEFAULT_IFRAME_SANDBOX\n }\n style={{ height: isFull ? '100%' : frameHeight }}\n />\n </div>\n );\n }\n);\n\nCanvasFrame.displayName = 'CanvasFrame';\n\nfunction useCanvasViewport(): CanvasViewport {\n const [viewport, setViewport] = useState<CanvasViewport>(() => readCanvasViewport());\n\n useEffect(() => {\n const updateViewport = () => setViewport(readCanvasViewport());\n\n window.addEventListener('resize', updateViewport);\n window.visualViewport?.addEventListener('resize', updateViewport);\n\n return () => {\n window.removeEventListener('resize', updateViewport);\n window.visualViewport?.removeEventListener('resize', updateViewport);\n };\n }, []);\n\n return viewport;\n}\n\nfunction readCanvasViewport(): CanvasViewport {\n return {\n width: window.innerWidth || 1024,\n height: window.innerHeight || 768,\n visualViewportHeight: window.visualViewport?.height,\n };\n}\n\nfunction slotClassName(slot: CanvasLayoutSlot) {\n switch (slot) {\n case 'safe-area-left':\n return styles.slotLeft;\n case 'safe-area-right':\n return styles.slotRight;\n case 'safe-area-bottom':\n return styles.slotBottom;\n case 'full':\n return styles.slotFull;\n }\n}\n\nfunction canvasRenderSlot(layout: CanvasResolvedLayout) {\n if (layout.display_mode === 'fullscreen' && layout.preferred_slot !== 'full') {\n return layout.preferred_slot;\n }\n\n return layout.viable_slot;\n}\n",
390
- styles: ".container {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n}\n\n.backdrop {\n position: absolute;\n inset: 0;\n background: rgba(2, 6, 23, 0.48);\n}\n\n.slot {\n position: absolute;\n display: flex;\n gap: 0.75rem;\n pointer-events: none;\n}\n\n.slotRight,\n.slotLeft {\n top: max(1rem, env(safe-area-inset-top));\n bottom: max(1rem, env(safe-area-inset-bottom));\n width: min(28rem, calc(100vw - 2rem));\n flex-direction: column;\n justify-content: center;\n}\n\n.slotRight {\n right: max(1rem, env(safe-area-inset-right));\n}\n\n.slotLeft {\n left: max(1rem, env(safe-area-inset-left));\n}\n\n.slotBottom {\n right: max(1rem, env(safe-area-inset-right));\n bottom: max(1rem, env(safe-area-inset-bottom));\n left: max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: center;\n justify-content: flex-end;\n}\n\n.slotFull {\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: stretch;\n justify-content: stretch;\n}\n\n.frameShell {\n width: 100%;\n /* Degenerate-value guard only — the host trusts app-reported heights, so\n short components (e.g. a 182px input) are not padded to a fixed floor. */\n min-height: 3rem;\n overflow: hidden;\n border: 1px solid rgba(15, 23, 42, 0.14);\n border-radius: 8px;\n background: #ffffff;\n box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);\n opacity: 0.96;\n pointer-events: auto;\n}\n\n.frameShellReady {\n opacity: 1;\n}\n\n.frameShellFull {\n position: fixed;\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n z-index: 1;\n min-height: 0;\n}\n\n.frameShellDimmed {\n opacity: 0.4;\n transform: scale(0.98);\n}\n\n.frame {\n display: block;\n width: 100%;\n min-height: 3rem;\n border: 0;\n background: transparent;\n}\n\n.frameShellFull .frame {\n height: 100%;\n min-height: 0;\n}\n",
389
+ content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';\nimport { useObservableEvent, useSendAppMessage } from '../../hooks/cvi-events-hooks';\n\nimport styles from './magic-canvas.module.css';\nimport { closeBridge } from './bridge';\nimport {\n applyCanvasCommand,\n buildCanvasModelContextAppend,\n buildHostContext,\n CANVAS_LAYOUT_SLOTS,\n createCanvasInstance,\n extractCanvasInteraction,\n extractTextContent,\n isCanvasToolCallMessage,\n MAGIC_CANVAS_MAX_HEIGHT_PX,\n MIN_CANVAS_DISPLAY_SCALE,\n normalizeInteraction,\n parseCanvasConfig,\n parseCanvasControlCommand,\n parseToolArguments,\n resolveCanvasLayout,\n resolveCanvasSidecarLayout,\n} from './runtime';\nimport type {\n CanvasDisplayMode,\n CanvasErrorCode,\n CanvasErrorEvent,\n CanvasInstance,\n CanvasInteractionEvent,\n CanvasLayoutSlot,\n CanvasModelContextUpdate,\n CanvasResolvedLayout,\n CanvasSidecarLayout,\n CanvasViewport,\n CanvasToolCallProperties,\n} from './runtime';\n\nimport {\n createCanvasCompletionScheduler,\n deliverCanvasInteraction,\n NativeCanvasHost,\n resolveCanvasRenderer,\n} from './native-host';\nimport type { CanvasRenderRegistry } from './native-host';\n\nexport {\n type CanvasErrorCode,\n type CanvasErrorEvent,\n type CanvasInteractionEvent,\n type CanvasSidecarLayout,\n} from './runtime';\nexport {\n canvasRendererKey,\n NativeCanvasHost,\n resolveCanvasRenderer,\n type CanvasComponentRenderer,\n type CanvasRenderRegistry,\n type CanvasRendererProps,\n} from './native-host';\n\ntype MagicCanvasProps = {\n className?: string;\n onInteraction?: (event: CanvasInteractionEvent) => void | Promise<void>;\n onError?: (event: CanvasErrorEvent) => void;\n onLayoutEffectChange?: (layout: CanvasSidecarLayout) => void;\n /**\n * Optional registry of native renderers keyed by `\"<component>@<version>\"`.\n * Matching instances render via NativeCanvasHost; otherwise the iframe\n * sandbox is used unchanged (default-off — omit and behavior is identical).\n */\n renderComponent?: CanvasRenderRegistry;\n};\n\nconst MODEL_CONTEXT_RELAY_INTERVAL_MS = 1000;\n\n// Floor for app-REPORTED heights. The runtime's MAGIC_CANVAS_MIN_HEIGHT_PX\n// (240) is a layout default, not a floor for real reports — flooring there\n// padded short components (e.g. a 182px input card) with dead space below\n// the content. Real reports are trusted; this only guards degenerate values\n// from a mid-load ResizeObserver tick. Mirrors the tavus-deployment host.\nconst CANVAS_REPORTED_HEIGHT_FLOOR_PX = 48;\n\n// Per-component iframe sandbox policy, owned by the host (not server-supplied).\n// Default is locked-down allow-scripts; Calendly's scheduling_embed needs its\n// own origin, popups, and form submission to complete a booking.\nconst DEFAULT_IFRAME_SANDBOX = 'allow-scripts';\nconst COMPONENT_IFRAME_SANDBOX: Record<string, string> = {\n 'canvas.scheduling_embed': 'allow-scripts allow-same-origin allow-popups allow-forms',\n};\n\n// Trust a bridge message only when it comes from THIS iframe's LIVE\n// contentWindow (read at message time, not captured earlier) and is JSON-RPC\n// shaped: accepts the component's post-navigation messages while rejecting\n// forged JSON-RPC from other frames and cross-talk between sibling canvases.\nexport function isTrustedCanvasFrameMessage(\n event: Pick<MessageEvent, 'source' | 'data'>,\n iframe: Pick<HTMLIFrameElement, 'contentWindow'>\n): boolean {\n if (!iframe.contentWindow || event.source !== iframe.contentWindow) return false;\n const data = event.data as { jsonrpc?: unknown } | null | undefined;\n return Boolean(data) && typeof data === 'object' && data?.jsonrpc === '2.0';\n}\n\n// Host-side JSON-RPC transport for the component iframe. We connect\n// SYNCHRONOUSLY, before the iframe navigates, to catch the app's\n// `ui/initialize` handshake (fires during module-script execution, before\n// `load`). The SDK's PostMessageTransport can't do this: it compares\n// `event.source` against a window captured at construct time, the stale\n// pre-navigation about:blank window, and would drop the handshake. This\n// transport validates against the LIVE `iframe.contentWindow` at receive\n// time. Mirrors the tavus-deployment host.\nexport class CanvasFrameTransport {\n onmessage?: (message: unknown) => void;\n onerror?: (error: Error) => void;\n onclose?: () => void;\n\n #iframe: HTMLIFrameElement;\n // `| undefined` (not the `?` optional marker): the TS->JS template\n // converter strips types but keeps the optional marker on private class\n // fields, which would emit invalid JS (`#listener?;`).\n #listener: ((event: MessageEvent) => void) | undefined = undefined;\n\n constructor(iframe: HTMLIFrameElement) {\n this.#iframe = iframe;\n }\n\n start(): Promise<void> {\n const listener = (event: MessageEvent) => {\n if (!isTrustedCanvasFrameMessage(event, this.#iframe)) return;\n this.onmessage?.(event.data);\n };\n this.#listener = listener;\n globalThis.addEventListener('message', listener);\n return Promise.resolve();\n }\n\n send(message: unknown): Promise<void> {\n // Same `\"*\"` target origin the SDK transport uses; the receiver validates\n // source rather than relying on a target-origin match (the sandboxed app\n // has an opaque origin).\n this.#iframe.contentWindow?.postMessage(message, '*');\n return Promise.resolve();\n }\n\n close(): Promise<void> {\n if (this.#listener) globalThis.removeEventListener('message', this.#listener);\n this.#listener = undefined;\n this.onclose?.();\n return Promise.resolve();\n }\n}\n\n// Navigate the iframe and wire the bridge connect. Connect synchronously,\n// BEFORE the iframe navigates: the app sends `ui/initialize` during\n// module-script execution, before `load`, and postMessage does not queue for\n// late listeners, so attaching only on `load` makes connect() time out\n// (\"Unable to connect to host\"). `connectBridge` is idempotent, so the `load`\n// listener stays as a rare-case fallback. Returns the listener disposer.\nexport function wireCanvasFrameConnect(\n iframe: Pick<HTMLIFrameElement, 'addEventListener' | 'removeEventListener'> & { src: string },\n targetHref: string,\n connectBridge: () => void\n): () => void {\n iframe.addEventListener('load', connectBridge);\n iframe.src = targetHref;\n connectBridge();\n return () => iframe.removeEventListener('load', connectBridge);\n}\n\nexport const MagicCanvas = memo(\n ({\n className,\n onInteraction,\n onError,\n onLayoutEffectChange,\n renderComponent,\n }: MagicCanvasProps) => {\n const [instances, setInstances] = useState<CanvasInstance[]>([]);\n const viewport = useCanvasViewport();\n const onErrorRef = useRef(onError);\n\n useEffect(() => {\n onErrorRef.current = onError;\n }, [onError]);\n\n const reportError = useCallback((event: CanvasErrorEvent) => {\n onErrorRef.current?.(event);\n }, []);\n\n useObservableEvent<CanvasToolCallProperties>(\n useCallback(\n (event) => {\n if (!isCanvasToolCallMessage(event)) return;\n\n try {\n if (event.canvas_config === undefined || event.canvas_config === null) {\n const controlCommand = parseCanvasControlCommand(event);\n if (!controlCommand) return;\n setInstances((current) => applyCanvasCommand(current, controlCommand));\n return;\n }\n\n const canvasConfig = parseCanvasConfig(event.canvas_config);\n if (!canvasConfig) {\n reportError({\n code: 'malformed_canvas_config',\n message: 'Received malformed Magic Canvas config.',\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n return;\n }\n\n const toolCallId = event.properties.tool_call_id;\n if (!toolCallId) {\n reportError({\n code: 'missing_tool_call_id',\n message: 'Received Magic Canvas tool call without tool_call_id.',\n conversation_id: event.conversation_id,\n component: canvasConfig.component,\n });\n return;\n }\n\n const parsedArguments = parseToolArguments(event.properties.arguments);\n if (!parsedArguments.ok) {\n reportError({\n code: 'invalid_tool_arguments',\n message: parsedArguments.error.message,\n conversation_id: event.conversation_id,\n tool_call_id: toolCallId,\n component: canvasConfig.component,\n cause: parsedArguments.error,\n });\n return;\n }\n\n const instance = createCanvasInstance({\n conversationId: event.conversation_id,\n toolCallId,\n args: parsedArguments.value,\n canvasConfig,\n });\n\n setInstances((current) => applyCanvasCommand(current, { kind: 'show', instance }));\n } catch (error) {\n reportError({\n code: 'invalid_tool_arguments',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n }\n },\n [reportError]\n )\n );\n\n const resolvedInstances = instances.map((instance) => ({\n instance,\n layout: resolveCanvasLayout(instance, viewport),\n }));\n const sidecarLayout = resolveCanvasSidecarLayout(\n resolvedInstances.map(({ layout }) => layout),\n viewport\n );\n const fullscreenToolCallId = [...resolvedInstances]\n .reverse()\n .find(({ layout }) => layout.display_mode === 'fullscreen')?.instance.tool_call_id;\n\n useEffect(() => {\n onLayoutEffectChange?.(sidecarLayout);\n }, [\n onLayoutEffectChange,\n sidecarLayout.active,\n sidecarLayout.side,\n sidecarLayout.video_shift_x,\n sidecarLayout.backdrop.type,\n sidecarLayout.safe_area?.x,\n sidecarLayout.safe_area?.y,\n sidecarLayout.safe_area?.width,\n sidecarLayout.safe_area?.height,\n ]);\n\n if (instances.length === 0) return null;\n\n return (\n <div className={[styles.container, className].filter(Boolean).join(' ')}>\n {fullscreenToolCallId && <div className={styles.backdrop} />}\n {CANVAS_LAYOUT_SLOTS.map((slot) => {\n const slotInstances = resolvedInstances.filter(\n ({ layout }) => canvasRenderSlot(layout) === slot\n );\n if (slotInstances.length === 0) return null;\n\n return (\n <CanvasSlot key={slot} slot={slot}>\n {(availableHeight) =>\n slotInstances.map(({ instance, layout }) => {\n const dimmed = Boolean(\n fullscreenToolCallId && fullscreenToolCallId !== instance.tool_call_id\n );\n const onComplete = () => {\n setInstances((current) => current.filter((item) => item.id !== instance.id));\n };\n\n // Default-off: native renderer only when the registry has an entry\n // for this component@version; otherwise the iframe path, unchanged.\n const renderer = resolveCanvasRenderer(renderComponent, instance);\n if (renderer) {\n return (\n <NativeCanvasHost\n key={instance.id}\n instance={instance}\n layout={layout}\n render={renderer}\n dimmed={dimmed}\n onComplete={onComplete}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n }\n\n return (\n <CanvasFrame\n key={instance.id}\n availableHeight={availableHeight}\n instance={instance}\n layout={layout}\n dimmed={dimmed}\n onComplete={onComplete}\n onDisplayModeChange={(displayMode) => {\n setInstances((current) =>\n current.map((item) =>\n item.tool_call_id === instance.tool_call_id\n ? { ...item, layout: { ...item.layout, display_mode: displayMode } }\n : item\n )\n );\n }}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n })\n }\n </CanvasSlot>\n );\n })}\n </div>\n );\n }\n);\n\nMagicCanvas.displayName = 'MagicCanvas';\n\ntype CanvasFrameProps = {\n availableHeight?: number;\n instance: CanvasInstance;\n layout: CanvasResolvedLayout;\n dimmed?: boolean;\n onComplete?: () => void;\n onDisplayModeChange?: (displayMode: CanvasDisplayMode) => void;\n onInteraction?: (event: CanvasInteractionEvent) => void | Promise<void>;\n onError?: (event: CanvasErrorEvent) => void;\n};\n\nconst CanvasFrame = memo(\n ({\n availableHeight,\n instance,\n layout,\n dimmed,\n onComplete,\n onDisplayModeChange,\n onInteraction,\n onError,\n }: CanvasFrameProps) => {\n const iframeRef = useRef<HTMLIFrameElement>(null);\n const bridgeRef = useRef<AppBridge | null>(null);\n const instanceRef = useRef(instance);\n const layoutRef = useRef(layout);\n const lastSentRevisionRef = useRef(-1);\n const sendAppMessage = useSendAppMessage();\n const onCompleteRef = useRef(onComplete);\n const onDisplayModeChangeRef = useRef(onDisplayModeChange);\n const onInteractionRef = useRef(onInteraction);\n const onErrorRef = useRef(onError);\n const sendAppMessageRef = useRef(sendAppMessage);\n const [frameHeight, setFrameHeight] = useState(320);\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n instanceRef.current = instance;\n }, [instance]);\n\n useEffect(() => {\n layoutRef.current = layout;\n bridgeRef.current?.setHostContext(buildHostContext(instanceRef.current, layout));\n }, [layout]);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n onDisplayModeChangeRef.current = onDisplayModeChange;\n onInteractionRef.current = onInteraction;\n onErrorRef.current = onError;\n sendAppMessageRef.current = sendAppMessage;\n }, [onComplete, onDisplayModeChange, onInteraction, onError, sendAppMessage]);\n\n useEffect(() => {\n const iframe = iframeRef.current;\n if (!iframe) return;\n\n let closed = false;\n let bridge: AppBridge | null = null;\n let pendingModelContextUpdate: CanvasModelContextUpdate | null = null;\n let modelContextRelayTimer: ReturnType<typeof setTimeout> | null = null;\n // Decision 17 deferred-submit completion, shared with NativeCanvasHost.\n const completionScheduler = createCanvasCompletionScheduler(() => closed);\n const targetHref = new URL(instance.canvas_config.sandbox_url, globalThis.location?.href)\n .href;\n\n const reportError = (event: CanvasErrorEvent) => {\n if (!closed) onErrorRef.current?.(event);\n };\n\n const flushModelContextUpdate = () => {\n if (closed || !pendingModelContextUpdate) return;\n\n const currentInstance = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.append_llm_context',\n conversation_id: currentInstance.conversation_id,\n properties: {\n context: buildCanvasModelContextAppend(currentInstance, pendingModelContextUpdate),\n },\n });\n pendingModelContextUpdate = null;\n };\n\n const buildFrameError = (\n code: CanvasErrorCode,\n defaultMessage: string,\n cause: unknown\n ): CanvasErrorEvent => ({\n code,\n message: cause instanceof Error && cause.message ? cause.message : defaultMessage,\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause,\n });\n\n const connectBridge = () => {\n if (bridge || iframe.src !== targetHref) return;\n if (closed || !iframe.contentWindow) return;\n\n const nextBridge = new AppBridge(\n null,\n { name: '@tavus/cvi-ui', version: '0.0.0' },\n {\n logging: {},\n message: {\n text: {},\n structuredContent: {},\n },\n updateModelContext: {\n text: {},\n structuredContent: {},\n },\n },\n {\n hostContext: buildHostContext(instance, layoutRef.current),\n }\n );\n bridge = nextBridge;\n bridgeRef.current = nextBridge;\n\n nextBridge.oninitialized = () => {\n if (closed) return;\n setReady(true);\n lastSentRevisionRef.current = instanceRef.current.revision;\n void nextBridge\n .sendToolInput({ arguments: instanceRef.current.arguments })\n .catch((error) => {\n reportError(\n buildFrameError(\n 'send_tool_input_failed',\n 'Magic Canvas failed to send tool input to the iframe.',\n error\n )\n );\n });\n };\n\n nextBridge.onsizechange = ({ height }) => {\n if (!closed && typeof height === 'number' && Number.isFinite(height)) {\n setFrameHeight(\n Math.min(\n Math.max(height, CANVAS_REPORTED_HEIGHT_FLOOR_PX),\n MAGIC_CANVAS_MAX_HEIGHT_PX\n )\n );\n }\n };\n\n nextBridge.onmessage = async (message) => {\n let interaction: CanvasInteractionEvent | null = null;\n const interactionPayload = extractCanvasInteraction(message);\n const responseText = extractTextContent(message);\n\n if (interactionPayload) {\n try {\n interaction = normalizeInteraction(instanceRef.current, interactionPayload);\n } catch (error) {\n reportError(\n buildFrameError(\n 'interaction_normalization_failed',\n 'Magic Canvas failed to normalize an interaction.',\n error\n )\n );\n }\n }\n\n if (responseText && !interaction) {\n reportError({\n code: 'missing_interaction_metadata',\n message:\n 'Magic Canvas message included text without interaction metadata; no interaction row was recorded.',\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n });\n return {};\n }\n\n if (interaction) {\n const delivery = await deliverCanvasInteraction({\n interaction,\n canvasConfig: instance.canvas_config,\n onInteraction: onInteractionRef.current,\n reportError,\n buildError: buildFrameError,\n });\n\n // Don't tell the embedded app the interaction succeeded when the\n // POST failed: surface the error instead of continuing to\n // respond/complete, so the card stays visible and the app keeps\n // a retry signal. Mirrors the tavus-deployment host.\n if (!delivery.ok) {\n return {\n isError: true,\n code: delivery.error.code,\n message: delivery.error.message,\n };\n }\n }\n\n if (responseText) {\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.respond',\n conversation_id: instance.conversation_id,\n properties: {\n text: responseText,\n },\n });\n }\n\n // Decision 17: submit defers by CANVAS_SUBMIT_TEARDOWN_DELAY_MS so\n // the embedded app's confirmation stays visible; skip/dismiss/clear\n // complete instantly. Failed deliveries returned above and never\n // reach this.\n if (interaction)\n completionScheduler.complete(interaction, () => onCompleteRef.current?.());\n\n return {};\n };\n\n nextBridge.onupdatemodelcontext = async (modelContextUpdate) => {\n pendingModelContextUpdate = modelContextUpdate;\n if (!modelContextRelayTimer) {\n modelContextRelayTimer = setTimeout(() => {\n modelContextRelayTimer = null;\n flushModelContextUpdate();\n }, MODEL_CONTEXT_RELAY_INTERVAL_MS);\n }\n\n return {};\n };\n\n nextBridge.onrequestdisplaymode = async ({ mode }) => {\n const nextMode = mode === 'fullscreen' ? 'fullscreen' : 'inline';\n onDisplayModeChangeRef.current?.(nextMode);\n layoutRef.current = {\n ...layoutRef.current,\n display_mode: nextMode,\n viable_slot: nextMode === 'fullscreen' ? 'full' : layoutRef.current.viable_slot,\n };\n nextBridge.setHostContext(buildHostContext(instanceRef.current, layoutRef.current));\n return { mode: nextMode };\n };\n\n // See the CanvasFrameTransport class comment for why the SDK's\n // PostMessageTransport can't be used for this pre-navigation connect.\n void nextBridge.connect(new CanvasFrameTransport(iframe)).catch((error) => {\n reportError(\n buildFrameError(\n 'bridge_connect_failed',\n 'Magic Canvas iframe bridge failed to connect.',\n error\n )\n );\n });\n };\n\n const disconnectFrame = wireCanvasFrameConnect(iframe, targetHref, connectBridge);\n\n return () => {\n if (modelContextRelayTimer) {\n clearTimeout(modelContextRelayTimer);\n modelContextRelayTimer = null;\n }\n flushModelContextUpdate();\n closed = true;\n completionScheduler.cancel();\n disconnectFrame();\n bridgeRef.current = null;\n if (bridge) void closeBridge(bridge);\n };\n }, [instance.id, instance.canvas_config.sandbox_url]);\n\n useEffect(() => {\n if (!ready || instance.revision === 0) return;\n if (instance.revision <= lastSentRevisionRef.current) return;\n lastSentRevisionRef.current = instance.revision;\n void bridgeRef.current?.sendToolInput({ arguments: instance.arguments }).catch((error) => {\n onErrorRef.current?.({\n code: 'send_tool_input_failed',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause: error,\n });\n });\n }, [instance, ready]);\n\n const isFull = layout.viable_slot === 'full' || layout.display_mode === 'fullscreen';\n const inlineFit = isFull ? null : resolveInlineFrameFit(frameHeight, availableHeight);\n const placeholderStyle = inlineFit?.placeholderStyle;\n const cardStyle = inlineFit?.cardStyle;\n const iframeStyle: React.CSSProperties = isFull\n ? { height: '100%' }\n : (inlineFit?.iframeStyle ?? { height: frameHeight });\n\n const card = (\n <div\n className={[\n styles.frameShell,\n ready ? styles.frameShellReady : '',\n isFull ? styles.frameShellFull : '',\n dimmed ? styles.frameShellDimmed : '',\n ]\n .filter(Boolean)\n .join(' ')}\n style={cardStyle}\n data-canvas-card={layout.viable_slot}\n >\n <iframe\n ref={iframeRef}\n title={`${instance.canvas_config.component} ${instance.canvas_config.component_version}`}\n className={styles.frame}\n sandbox={\n COMPONENT_IFRAME_SANDBOX[instance.canvas_config.component] ?? DEFAULT_IFRAME_SANDBOX\n }\n style={iframeStyle}\n />\n </div>\n );\n\n // Full-screen cards own the viewport; otherwise wrap in a placeholder that\n // reserves the (possibly scaled) layout height so a scaled card does not\n // overlap siblings. Mirrors the tavus-deployment host.\n if (isFull) return card;\n\n return (\n <div className={styles.framePlaceholder} style={placeholderStyle}>\n {card}\n </div>\n );\n }\n);\n\nCanvasFrame.displayName = 'CanvasFrame';\n\ntype CanvasSlotProps = {\n slot: CanvasLayoutSlot;\n children: (availableHeight?: number) => React.ReactNode;\n};\n\n// Wraps a layout slot, measuring its available height so the frame inside can\n// scale-to-fit (and fall back to scroll) when the content is taller than the\n// band. Side slots scroll (overflow-y) and center their card with an inner\n// `my-auto`-style stack that collapses when the content overflows, so a too-tall\n// card scrolls from the top instead of being clipped by the slot.\nfunction CanvasSlot({ slot, children }: CanvasSlotProps) {\n const [ref, availableHeight] = useCanvasSlotHeight();\n const isSide = slot === 'safe-area-left' || slot === 'safe-area-right';\n const content = children(slot === 'full' ? undefined : availableHeight);\n\n return (\n <div ref={ref} className={`${styles.slot} ${slotClassName(slot)}`} data-canvas-slot={slot}>\n {isSide ? <div className={styles.sideStack}>{content}</div> : content}\n </div>\n );\n}\n\nfunction useCanvasSlotHeight() {\n const ref = useRef<HTMLDivElement>(null);\n const [height, setHeight] = useState<number>();\n\n useEffect(() => {\n const element = ref.current;\n if (!element) return;\n\n const setMeasuredHeight = (nextHeight?: number) => {\n const measuredHeight = nextHeight ?? element.getBoundingClientRect().height;\n if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) return;\n setHeight(Math.floor(measuredHeight));\n };\n const updateFromLayout = () => setMeasuredHeight();\n\n updateFromLayout();\n\n if (typeof ResizeObserver === 'undefined') {\n globalThis.addEventListener('resize', updateFromLayout);\n return () => globalThis.removeEventListener('resize', updateFromLayout);\n }\n\n const observer = new ResizeObserver((entries) => {\n setMeasuredHeight(entries[0]?.contentRect.height);\n });\n observer.observe(element);\n\n return () => observer.disconnect();\n }, []);\n\n return [ref, height] as const;\n}\n\n// Fit the app-reported (capped) card height into the slot's available height:\n// render natural when it fits, scale the card down to MIN_CANVAS_DISPLAY_SCALE\n// when slightly too tall, and fall back to a band-height iframe (native scroll)\n// when it is too tall even scaled. The iframe width MUST stay 100% in every\n// branch — feeding the app a per-scale viewport can trigger a width-breakpoint\n// feedback loop. Mirrors resolveInlineFrameFit in the tavus-deployment host.\nfunction resolveInlineFrameFit(naturalHeight: number, availableHeight?: number) {\n if (availableHeight === undefined || !Number.isFinite(availableHeight) || availableHeight <= 0) {\n return null;\n }\n\n const clampedNatural = Math.min(naturalHeight, MAGIC_CANVAS_MAX_HEIGHT_PX);\n\n if (clampedNatural <= availableHeight) {\n return {\n displayScale: 1,\n placeholderStyle: { maxHeight: `${availableHeight}px` } as React.CSSProperties,\n cardStyle: undefined as React.CSSProperties | undefined,\n iframeStyle: { width: '100%', height: `${clampedNatural}px` } as React.CSSProperties,\n };\n }\n\n const ratio = availableHeight / clampedNatural;\n\n if (ratio >= MIN_CANVAS_DISPLAY_SCALE) {\n const scaledHeight = clampedNatural * ratio;\n return {\n displayScale: ratio,\n placeholderStyle: {\n height: `${scaledHeight}px`,\n maxHeight: `${availableHeight}px`,\n overflow: 'hidden',\n } as React.CSSProperties,\n cardStyle: {\n height: `${clampedNatural}px`,\n transform: `scale(${ratio})`,\n transformOrigin: 'top center',\n } as React.CSSProperties,\n iframeStyle: { width: '100%', height: `${clampedNatural}px` } as React.CSSProperties,\n };\n }\n\n return {\n displayScale: 1,\n placeholderStyle: { maxHeight: `${availableHeight}px` } as React.CSSProperties,\n cardStyle: undefined as React.CSSProperties | undefined,\n iframeStyle: { width: '100%', height: `${availableHeight}px` } as React.CSSProperties,\n };\n}\n\nfunction useCanvasViewport(): CanvasViewport {\n const [viewport, setViewport] = useState<CanvasViewport>(() => readCanvasViewport());\n\n useEffect(() => {\n const updateViewport = () => setViewport(readCanvasViewport());\n\n window.addEventListener('resize', updateViewport);\n window.visualViewport?.addEventListener('resize', updateViewport);\n\n return () => {\n window.removeEventListener('resize', updateViewport);\n window.visualViewport?.removeEventListener('resize', updateViewport);\n };\n }, []);\n\n return viewport;\n}\n\nfunction readCanvasViewport(): CanvasViewport {\n return {\n width: window.innerWidth || 1024,\n height: window.innerHeight || 768,\n visualViewportHeight: window.visualViewport?.height,\n };\n}\n\nfunction slotClassName(slot: CanvasLayoutSlot) {\n switch (slot) {\n case 'safe-area-left':\n return styles.slotLeft;\n case 'safe-area-right':\n return styles.slotRight;\n case 'safe-area-bottom':\n return styles.slotBottom;\n case 'full':\n return styles.slotFull;\n }\n}\n\nfunction canvasRenderSlot(layout: CanvasResolvedLayout) {\n if (layout.display_mode === 'fullscreen' && layout.preferred_slot !== 'full') {\n return layout.preferred_slot;\n }\n\n return layout.viable_slot;\n}\n",
390
+ styles: ".container {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n}\n\n.backdrop {\n position: absolute;\n inset: 0;\n background: rgba(2, 6, 23, 0.48);\n}\n\n.slot {\n position: absolute;\n display: flex;\n gap: 0.75rem;\n pointer-events: none;\n}\n\n.slotRight,\n.slotLeft {\n top: max(1rem, env(safe-area-inset-top));\n bottom: max(1rem, env(safe-area-inset-bottom));\n width: min(28rem, calc(100vw - 2rem));\n flex-direction: column;\n /* Top-anchored + scrollable: a card taller than the band scrolls instead of\n being clipped. Centering moves to the inner .sideStack (margin-block: auto)\n so a card that fits still centers but collapses when it overflows, letting\n the slot scroll from the top. A justify-content: center slot would clip. */\n overflow-y: auto;\n}\n\n.sideStack {\n display: flex;\n width: 100%;\n flex-direction: column;\n gap: 0.75rem;\n margin-block: auto;\n}\n\n.slotRight {\n right: max(1rem, env(safe-area-inset-right));\n}\n\n.slotLeft {\n left: max(1rem, env(safe-area-inset-left));\n}\n\n.slotBottom {\n right: max(1rem, env(safe-area-inset-right));\n bottom: max(1rem, env(safe-area-inset-bottom));\n left: max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: center;\n justify-content: flex-end;\n}\n\n.slotFull {\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: stretch;\n justify-content: stretch;\n}\n\n/* Reserves the (possibly scaled) layout height for an inline card so a\n scaled-down card does not overlap its siblings. */\n.framePlaceholder {\n display: block;\n width: 100%;\n}\n\n.frameShell {\n width: 100%;\n /* Degenerate-value guard only — the host trusts app-reported heights, so\n short components (e.g. a 182px input) are not padded to a fixed floor. */\n min-height: 3rem;\n overflow: hidden;\n border: 1px solid rgba(15, 23, 42, 0.14);\n border-radius: 8px;\n background: #ffffff;\n box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);\n opacity: 0.96;\n pointer-events: auto;\n}\n\n.frameShellReady {\n opacity: 1;\n}\n\n.frameShellFull {\n position: fixed;\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n z-index: 1;\n min-height: 0;\n}\n\n.frameShellDimmed {\n opacity: 0.4;\n transform: scale(0.98);\n}\n\n.frame {\n display: block;\n width: 100%;\n min-height: 3rem;\n border: 0;\n background: transparent;\n}\n\n.frameShellFull .frame {\n height: 100%;\n min-height: 0;\n}\n",
391
391
  extraFiles: [
392
392
  {
393
393
  "path": "allowlists.ts",
394
- "content": "// Magic Canvas URL allowlists.\n// `sandbox_url` / `mcp_server_url` / `api_base_url` / `interaction_url` arrive\n// from the wire and decide which origins the iframe loads from and where\n// interactions get POSTed. M0 ships a hard allowlist; customers self-hosting\n// against a non-tavusapi.com backend should fork these constants until M1\n// adds a configurable surface.\n\nconst ALLOWED_CANVAS_SANDBOX_HOSTS = new Set([\n 'mcp-ui.tavus.io',\n // Preview host: the components worker runs the same code as prod and has a\n // valid cert while mcp-ui.tavus.io's cert provisioning is pending. Additive\n // so prod keeps working; remove once the prod host is live.\n 'mcp-ui.tavus-preview.io',\n]);\nconst ALLOWED_CANVAS_SANDBOX_HOST_SUFFIXES = ['.sandbox-tavus.io'];\nconst ALLOWED_CANVAS_API_HOSTS = new Set(['tavusapi.com']);\n\nexport function isAllowedCanvasSandboxUrl(rawUrl: string) {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return false;\n }\n\n if (isLocalCanvasSandboxUrl(url)) return true;\n if (url.protocol !== 'https:') return false;\n if (ALLOWED_CANVAS_SANDBOX_HOSTS.has(url.hostname)) return true;\n return ALLOWED_CANVAS_SANDBOX_HOST_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix));\n}\n\nexport function isAllowedCanvasMcpUrl(rawUrl: string) {\n return isAllowedCanvasSandboxUrl(rawUrl);\n}\n\nexport function isAllowedCanvasApiUrl(rawUrl: string) {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return false;\n }\n\n if (isLocalCanvasUrl(url)) return true;\n return url.protocol === 'https:' && ALLOWED_CANVAS_API_HOSTS.has(url.hostname);\n}\n\nfunction isLocalCanvasSandboxUrl(url: URL) {\n return isLocalCanvasUrl(url);\n}\n\nfunction isLocalCanvasUrl(url: URL) {\n if (!isLocalHostPage()) return false;\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;\n return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname);\n}\n\nfunction isLocalHostPage() {\n try {\n const hostname = globalThis.location?.hostname;\n return (\n typeof hostname === 'string' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname)\n );\n } catch {\n return false;\n }\n}\n"
394
+ "content": "// Magic Canvas URL allowlists.\n// `sandbox_url` / `mcp_server_url` / `api_base_url` / `interaction_url` arrive\n// from the wire and decide which origins the iframe loads from and where\n// interactions get POSTed. M0 ships a hard allowlist; customers self-hosting\n// against a non-tavusapi.com backend should fork these constants until M1\n// adds a configurable surface.\n\nconst ALLOWED_CANVAS_SANDBOX_HOSTS = new Set([\n \"mcp-ui.tavus.io\",\n // Preview host: the components worker runs the same code as prod and has a\n // valid cert while mcp-ui.tavus.io's cert provisioning is pending. Additive\n // so prod keeps working; remove once the prod host is live.\n \"mcp-ui.tavus-preview.io\",\n]);\nconst ALLOWED_CANVAS_SANDBOX_HOST_SUFFIXES = [\".sandbox-tavus.io\"];\nconst ALLOWED_CANVAS_API_HOSTS = new Set([\"tavusapi.com\"]);\n\nexport function isAllowedCanvasSandboxUrl(rawUrl: string) {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return false;\n }\n\n if (isLocalCanvasSandboxUrl(url)) return true;\n if (url.protocol !== \"https:\") return false;\n if (ALLOWED_CANVAS_SANDBOX_HOSTS.has(url.hostname)) return true;\n return ALLOWED_CANVAS_SANDBOX_HOST_SUFFIXES.some((suffix) =>\n url.hostname.endsWith(suffix),\n );\n}\n\nexport function isAllowedCanvasMcpUrl(rawUrl: string) {\n return isAllowedCanvasSandboxUrl(rawUrl);\n}\n\nexport function isAllowedCanvasApiUrl(rawUrl: string) {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return false;\n }\n\n if (isLocalCanvasUrl(url)) return true;\n return (\n url.protocol === \"https:\" && ALLOWED_CANVAS_API_HOSTS.has(url.hostname)\n );\n}\n\nfunction isLocalCanvasSandboxUrl(url: URL) {\n return isLocalCanvasUrl(url);\n}\n\nfunction isLocalCanvasUrl(url: URL) {\n if (!isLocalHostPage()) return false;\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") return false;\n return [\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"].includes(url.hostname);\n}\n\nfunction isLocalHostPage() {\n try {\n const hostname = globalThis.location?.hostname;\n return (\n typeof hostname === \"string\" &&\n [\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"].includes(hostname)\n );\n } catch {\n return false;\n }\n}\n"
395
395
  },
396
396
  {
397
397
  "path": "bridge.ts",
398
- "content": "// Interaction POST + AppBridge teardown helpers. Network and SDK-shutdown\n// concerns live here so the React surface in `index.tsx` can stay focused on\n// lifecycle wiring.\n\nimport type { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';\n\nimport type { CanvasConfig, CanvasInteractionEvent } from './runtime.js';\n\nconst TAVUS_API_BASE_URL = 'https://tavusapi.com';\nconst CANVAS_INTERACTION_TIMEOUT_MS = 5000;\n\nexport async function postInteraction(event: CanvasInteractionEvent, canvasConfig: CanvasConfig) {\n const endpoint = getInteractionEndpoint(event, canvasConfig);\n const response = await fetchWithTimeout(endpoint, {\n method: 'POST',\n redirect: 'error',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n interaction_id: event.interaction_id,\n tool_call_id: event.tool_call_id,\n component: event.component,\n component_version: event.component_version,\n type: event.type,\n value: event.value,\n metadata: event.metadata,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Magic Canvas interaction POST failed with ${response.status}.`);\n }\n}\n\nexport async function closeBridge(bridge: AppBridge) {\n try {\n await bridge.teardownResource({}, { timeout: 1000 });\n } catch {\n // The app may not acknowledge teardown; the bridge transport is closed below either way.\n }\n try {\n await bridge.close();\n } catch {\n // Transport may already be torn down by the iframe unmount; ignore.\n }\n}\n\nfunction getInteractionEndpoint(event: CanvasInteractionEvent, canvasConfig: CanvasConfig) {\n if (canvasConfig.interaction_url) {\n return canvasConfig.interaction_url.replace(\n '{conversation_id}',\n encodeURIComponent(event.conversation_id)\n );\n }\n\n const apiBaseUrl = (canvasConfig.api_base_url ?? TAVUS_API_BASE_URL).replace(/\\/$/, '');\n return `${apiBaseUrl}/v2/conversations/${encodeURIComponent(event.conversation_id)}/canvas/interactions`;\n}\n\nasync function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit) {\n const controller = new AbortController();\n const timeout = globalThis.setTimeout(() => controller.abort(), CANVAS_INTERACTION_TIMEOUT_MS);\n\n try {\n return await fetch(input, { ...init, signal: controller.signal });\n } finally {\n globalThis.clearTimeout(timeout);\n }\n}\n"
398
+ "content": "// Interaction POST + AppBridge teardown helpers. Network and SDK-shutdown\n// concerns live here so the React surface in `index.tsx` can stay focused on\n// lifecycle wiring.\n\nimport type { CanvasConfig, CanvasInteractionEvent } from \"./runtime.js\";\n\n/**\n * The minimal AppBridge surface `closeBridge` needs, declared locally rather\n * than imported from `@modelcontextprotocol/ext-apps`. This keeps the package\n * free of any ext-apps type dependency, so it resolves under any consumer's\n * module resolution — a shipped global `declare module` augmentation does not\n * reliably merge from node_modules under a consumer's `nodenext`. Consumers\n * pass their real `AppBridge`, which structurally satisfies this. `close` is\n * optional because the published ext-apps types historically omit it even\n * though the transport implements it at runtime.\n */\nexport interface CanvasBridgeHandle {\n teardownResource(\n resource: Record<string, never>,\n options: { timeout: number },\n ): unknown;\n close?(): unknown;\n}\n\nconst TAVUS_API_BASE_URL = \"https://tavusapi.com\";\nconst CANVAS_INTERACTION_TIMEOUT_MS = 5000;\n\nexport async function postInteraction(\n event: CanvasInteractionEvent,\n canvasConfig: CanvasConfig,\n) {\n const endpoint = getInteractionEndpoint(event, canvasConfig);\n const response = await fetchWithTimeout(endpoint, {\n method: \"POST\",\n redirect: \"error\",\n headers: {\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n interaction_id: event.interaction_id,\n tool_call_id: event.tool_call_id,\n component: event.component,\n component_version: event.component_version,\n type: event.type,\n value: event.value,\n metadata: event.metadata,\n }),\n });\n\n if (!response.ok) {\n throw new Error(\n `Magic Canvas interaction POST failed with ${response.status}.`,\n );\n }\n}\n\nexport async function closeBridge(bridge: CanvasBridgeHandle) {\n try {\n await bridge.teardownResource({}, { timeout: 1000 });\n } catch {\n // The app may not acknowledge teardown; the bridge transport is closed below either way.\n }\n try {\n await bridge.close?.();\n } catch {\n // Transport may already be torn down by the iframe unmount; ignore.\n }\n}\n\nfunction getInteractionEndpoint(\n event: CanvasInteractionEvent,\n canvasConfig: CanvasConfig,\n) {\n if (canvasConfig.interaction_url) {\n return canvasConfig.interaction_url.replace(\n \"{conversation_id}\",\n encodeURIComponent(event.conversation_id),\n );\n }\n\n const apiBaseUrl = (canvasConfig.api_base_url ?? TAVUS_API_BASE_URL).replace(\n /\\/$/,\n \"\",\n );\n return `${apiBaseUrl}/v2/conversations/${encodeURIComponent(event.conversation_id)}/canvas/interactions`;\n}\n\nasync function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit) {\n const controller = new AbortController();\n const timeout = globalThis.setTimeout(\n () => controller.abort(),\n CANVAS_INTERACTION_TIMEOUT_MS,\n );\n\n try {\n return await fetch(input, { ...init, signal: controller.signal });\n } finally {\n globalThis.clearTimeout(timeout);\n }\n}\n"
399
399
  },
400
400
  {
401
401
  "path": "native-host.tsx",
@@ -403,7 +403,7 @@ var magic_canvas_default$1 = {
403
403
  },
404
404
  {
405
405
  "path": "runtime.ts",
406
- "content": "// Magic Canvas runtime: pure parsing, validation, normalization, and the\n// constants the React surface and bridge code both need. Nothing in this file\n// touches React, fetch, or the AppBridge so it stays trivially testable and\n// portable across host runtimes.\n\nimport {\n isAllowedCanvasApiUrl,\n isAllowedCanvasMcpUrl,\n isAllowedCanvasSandboxUrl,\n} from './allowlists.js';\n\nexport const CANVAS_INTERACTION_META_KEY = 'tavus.canvas.interaction';\nexport const SUPPORTED_CANVAS_CONFIG_VERSION = 1;\nexport const MAGIC_CANVAS_MIN_HEIGHT_PX = 240;\nexport const MAGIC_CANVAS_MAX_HEIGHT_PX = 720;\nexport const MIN_CANVAS_DISPLAY_SCALE = 0.85;\nexport const MAX_CANVAS_INSTANCES = 3;\nexport const CANVAS_CLEAR_TOOL_NAME = 'canvas_clear';\nexport const CANVAS_UPDATE_TOOL_NAME = 'update_component';\n\nexport const CANVAS_LAYOUT_SLOTS = [\n 'safe-area-right',\n 'safe-area-left',\n 'safe-area-bottom',\n 'full',\n] as const;\nexport const DEFAULT_CANVAS_LAYOUT_SLOT: CanvasLayoutSlot = 'safe-area-right';\nexport const DEFAULT_CANVAS_SAFE_AREA: CanvasSafeArea = {\n x: 275 / 1280,\n y: 111 / 720,\n width: 730 / 1280,\n height: 609 / 720,\n};\nexport const DEFAULT_CANVAS_BACKDROP: CanvasBackdropConfig = { type: 'snapshot_mirror' };\nexport const MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX = 448;\nexport const MAGIC_CANVAS_SIDE_PANEL_INSET_PX = 16;\nexport const MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX = 24;\nexport const MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX = 900;\n\nexport type JsonRecord = Record<string, unknown>;\nexport type CanvasLayoutSlot = (typeof CANVAS_LAYOUT_SLOTS)[number];\nexport type CanvasDisplayMode = 'inline' | 'fullscreen';\nexport type CanvasBackdropType = 'snapshot_mirror' | 'none';\n\nexport type CanvasSafeArea = {\n x: number;\n y: number;\n width: number;\n height: number;\n};\n\nexport type CanvasBackdropConfig = {\n type: CanvasBackdropType;\n};\n\nexport type CanvasLayoutConfig = {\n preferred_slot?: CanvasLayoutSlot;\n avoid_safe_area?: boolean;\n safe_area?: CanvasSafeArea;\n backdrop?: CanvasBackdropConfig;\n};\n\nexport type CanvasLayoutState = {\n preferred_slot: CanvasLayoutSlot;\n display_mode: CanvasDisplayMode;\n avoid_safe_area: boolean;\n safe_area: CanvasSafeArea;\n backdrop: CanvasBackdropConfig;\n};\n\nexport type CanvasResolvedLayout = CanvasLayoutState & {\n viable_slot: CanvasLayoutSlot;\n};\n\nexport type CanvasViewport = {\n width: number;\n height: number;\n visualViewportHeight?: number;\n};\n\nexport type CanvasSidecarLayout = {\n active: boolean;\n side?: 'left' | 'right';\n video_shift_x: number;\n safe_area?: CanvasSafeArea;\n backdrop: CanvasBackdropConfig;\n};\n\nexport type CanvasConfig = {\n version: number;\n component: string;\n component_version: string;\n resource_uri?: string;\n sandbox_url: string;\n mcp_server_url?: string;\n mcp_tool_name?: string;\n api_base_url?: string;\n interaction_url?: string;\n layout?: CanvasLayoutConfig;\n host_context?: JsonRecord;\n};\n\nexport type CanvasToolCallProperties = {\n name?: string;\n arguments?: string | JsonRecord;\n tool_call_id?: string;\n};\n\nexport type CanvasToolCallMessage = {\n message_type: 'conversation';\n event_type: 'conversation.tool_call';\n conversation_id: string;\n properties: CanvasToolCallProperties;\n canvas_config?: unknown;\n};\n\nexport type CanvasInstance = {\n id: string;\n conversation_id: string;\n tool_call_id: string;\n arguments: JsonRecord;\n canvas_config: CanvasConfig;\n layout: CanvasLayoutState;\n revision: number;\n};\n\nexport type CanvasShowCommand = {\n kind: 'show';\n instance: CanvasInstance;\n};\n\nexport type CanvasUpdateCommand = {\n kind: 'update';\n conversation_id: string;\n tool_call_id: string;\n updates: JsonRecord;\n};\n\nexport type CanvasClearCommand = {\n kind: 'clear';\n conversation_id: string;\n tool_call_id?: string;\n reason?: string;\n};\n\nexport type CanvasCommand = CanvasShowCommand | CanvasUpdateCommand | CanvasClearCommand;\n\nexport type CanvasInteractionEvent = {\n interaction_id: string;\n conversation_id: string;\n tool_call_id: string;\n component: string;\n component_version: string;\n type: string;\n value: unknown;\n metadata: JsonRecord;\n};\n\nexport type CanvasErrorCode =\n | 'malformed_canvas_config'\n | 'missing_tool_call_id'\n | 'invalid_tool_arguments'\n | 'missing_interaction_metadata'\n | 'interaction_normalization_failed'\n | 'interaction_post_failed'\n | 'on_interaction_callback_failed'\n | 'bridge_connect_failed'\n | 'send_tool_input_failed';\n\nexport type CanvasErrorEvent = {\n code: CanvasErrorCode;\n message: string;\n conversation_id?: string;\n tool_call_id?: string;\n component?: string;\n cause?: unknown;\n};\n\nexport type PendingInteraction = {\n interaction_id?: string;\n component?: string;\n component_version?: string;\n type?: string;\n value?: unknown;\n metadata?: JsonRecord;\n};\n\nexport type CanvasModelContextUpdate = {\n content?: unknown;\n structuredContent?: unknown;\n};\n\nexport function isCanvasToolCallMessage(value: unknown): value is CanvasToolCallMessage {\n if (!isRecord(value)) return false;\n\n return (\n value.message_type === 'conversation' &&\n value.event_type === 'conversation.tool_call' &&\n typeof value.conversation_id === 'string' &&\n isRecord(value.properties)\n );\n}\n\nexport function parseCanvasConfig(value: unknown): CanvasConfig | null {\n if (!isRecord(value)) return null;\n\n const component = readString(value, 'component');\n const componentVersion = readString(value, 'component_version');\n const sandboxUrl = readString(value, 'sandbox_url');\n const mcpServerUrl = readOptionalAllowedUrl(value, 'mcp_server_url', isAllowedCanvasMcpUrl);\n const apiBaseUrl = readOptionalAllowedUrl(value, 'api_base_url', isAllowedCanvasApiUrl);\n const interactionUrl = readOptionalAllowedUrl(value, 'interaction_url', isAllowedCanvasApiUrl);\n const layout = parseCanvasLayoutConfig(value.layout);\n const version = value.version;\n\n if (version !== SUPPORTED_CANVAS_CONFIG_VERSION) return null;\n if (!component || !componentVersion || !sandboxUrl) return null;\n if (!isAllowedCanvasSandboxUrl(sandboxUrl)) return null;\n if (mcpServerUrl === null || apiBaseUrl === null || interactionUrl === null) return null;\n if (layout === null) return null;\n\n return {\n version,\n component,\n component_version: componentVersion,\n resource_uri: readString(value, 'resource_uri'),\n sandbox_url: sandboxUrl,\n mcp_server_url: mcpServerUrl,\n mcp_tool_name: readString(value, 'mcp_tool_name'),\n api_base_url: apiBaseUrl,\n interaction_url: interactionUrl,\n layout,\n host_context: isRecord(value.host_context) ? value.host_context : undefined,\n };\n}\n\nexport function parseToolArguments(\n value: CanvasToolCallProperties['arguments']\n): { ok: true; value: JsonRecord } | { ok: false; error: Error } {\n if (value === undefined) return { ok: true, value: {} };\n if (isRecord(value)) return { ok: true, value };\n\n if (typeof value !== 'string') {\n return {\n ok: false,\n error: new Error('Magic Canvas tool arguments must be an object or JSON string.'),\n };\n }\n\n try {\n const parsed = JSON.parse(value);\n\n if (!isRecord(parsed)) {\n return {\n ok: false,\n error: new Error('Magic Canvas tool arguments must decode to an object.'),\n };\n }\n\n return { ok: true, value: parsed };\n } catch (error) {\n return { ok: false, error: toError(error) };\n }\n}\n\nexport function parseCanvasControlCommand(\n message: CanvasToolCallMessage\n): CanvasUpdateCommand | CanvasClearCommand | null {\n const toolName = message.properties.name;\n\n if (toolName !== CANVAS_CLEAR_TOOL_NAME && toolName !== CANVAS_UPDATE_TOOL_NAME) return null;\n\n const parsedArguments = parseToolArguments(message.properties.arguments);\n if (!parsedArguments.ok) throw parsedArguments.error;\n\n if (toolName === CANVAS_CLEAR_TOOL_NAME) {\n const toolCallId = parsedArguments.value.tool_call_id;\n const reason = parsedArguments.value.reason;\n\n if (toolCallId !== undefined && typeof toolCallId !== 'string') {\n throw new Error('Magic Canvas clear tool_call_id must be a string when provided.');\n }\n if (reason !== undefined && typeof reason !== 'string') {\n throw new Error('Magic Canvas clear reason must be a string when provided.');\n }\n\n return {\n kind: 'clear',\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n reason,\n };\n }\n\n const toolCallId = parsedArguments.value.tool_call_id;\n const updates = parsedArguments.value.updates;\n\n if (typeof toolCallId !== 'string' || toolCallId.length === 0) {\n throw new Error('Magic Canvas update_component requires a target tool_call_id.');\n }\n if (!isRecord(updates)) {\n throw new Error('Magic Canvas update_component requires an object updates payload.');\n }\n\n return {\n kind: 'update',\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n updates,\n };\n}\n\nfunction canvasEffectiveSlot(layout: CanvasLayoutState): CanvasLayoutSlot {\n return layout.display_mode === 'fullscreen' ? 'full' : layout.preferred_slot;\n}\n\nfunction isCanvasTakeoverSlot(slot: CanvasLayoutSlot): boolean {\n return slot === 'full' || slot === 'safe-area-bottom';\n}\n\n// Slot exclusivity: a takeover slot clears everything else; a side slot\n// replaces whatever occupied that side.\nfunction enforceCanvasSlotExclusivity(\n others: CanvasInstance[],\n target: CanvasInstance\n): CanvasInstance[] {\n const slot = canvasEffectiveSlot(target.layout);\n if (isCanvasTakeoverSlot(slot)) {\n return [target]; // fullscreen / underneath: the only component on screen\n }\n // side (left/right): keep only the opposite side; drop same side + any takeover\n const kept = others.filter((item) => {\n const itemSlot = canvasEffectiveSlot(item.layout);\n return !isCanvasTakeoverSlot(itemSlot) && itemSlot !== slot;\n });\n return [...kept, target];\n}\n\nexport function applyCanvasCommand(\n current: CanvasInstance[],\n command: CanvasCommand\n): CanvasInstance[] {\n switch (command.kind) {\n case 'show': {\n const existing = current.find((item) => item.id === command.instance.id);\n const nextInstance = existing\n ? { ...command.instance, revision: existing.revision + 1 }\n : command.instance;\n const others = current.filter((item) => item.id !== command.instance.id);\n return enforceCanvasSlotExclusivity(others, nextInstance);\n }\n case 'update': {\n // Commands are conversation-scoped: a stale or cross-conversation\n // command whose tool_call_id happens to collide must not mutate the\n // current canvas (tool_call_ids are LLM-generated and not unique\n // across conversations).\n const matchesTarget = (item: CanvasInstance) =>\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id;\n const update = splitCanvasRuntimeArguments(command.updates);\n let updated: CanvasInstance | undefined;\n const mapped = current.map((item) => {\n if (!matchesTarget(item)) return item;\n const next: CanvasInstance = {\n ...item,\n arguments: { ...item.arguments, ...update.componentArguments },\n layout: {\n preferred_slot: update.preferredSlot ?? item.layout.preferred_slot,\n display_mode: update.displayMode ?? item.layout.display_mode,\n avoid_safe_area: update.avoidSafeArea ?? item.layout.avoid_safe_area,\n safe_area: update.safeArea ?? item.layout.safe_area,\n backdrop: update.backdrop ?? item.layout.backdrop,\n },\n revision: item.revision + 1,\n };\n updated = next;\n return next;\n });\n if (updated === undefined) return mapped;\n const prev = current.find(matchesTarget);\n if (prev && canvasEffectiveSlot(prev.layout) === canvasEffectiveSlot(updated.layout)) {\n return mapped;\n }\n const rest = mapped.filter((item) => !matchesTarget(item));\n return enforceCanvasSlotExclusivity(rest, updated);\n }\n case 'clear':\n // Clear-all is scoped to the command's conversation; targeted clear\n // must match both conversation and tool_call_id.\n if (!command.tool_call_id)\n return current.filter((item) => item.conversation_id !== command.conversation_id);\n return current.filter(\n (item) =>\n !(\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id\n )\n );\n }\n}\n\nexport function extractCanvasInteraction(message: unknown): PendingInteraction | null {\n if (!isRecord(message) || !Array.isArray(message.content)) return null;\n\n for (const block of message.content) {\n if (!isRecord(block)) continue;\n const meta = isRecord(block._meta) ? block._meta : null;\n const interaction = meta?.[CANVAS_INTERACTION_META_KEY];\n\n if (isRecord(interaction)) {\n return toPendingInteraction(interaction);\n }\n }\n\n return null;\n}\n\nexport function normalizeInteraction(\n instance: CanvasInstance,\n payload: PendingInteraction\n): CanvasInteractionEvent {\n const interactionType = payload.type;\n const value = payload.value;\n\n if (!interactionType) {\n throw new Error('Magic Canvas interaction is missing type.');\n }\n\n if (value === undefined) {\n throw new Error('Magic Canvas interaction is missing value.');\n }\n\n return {\n interaction_id:\n payload.interaction_id ?? createInteractionId(instance.tool_call_id, interactionType),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: payload.component ?? instance.canvas_config.component,\n component_version: payload.component_version ?? instance.canvas_config.component_version,\n type: interactionType,\n value,\n metadata: isRecord(payload.metadata) ? payload.metadata : {},\n };\n}\n\nexport function shouldCompleteCanvasInteraction(event: CanvasInteractionEvent) {\n if (event.type === 'dismiss' || event.type === 'clear') return true;\n if (event.type !== 'submit' && event.type !== 'skip') return false;\n return (\n event.component === 'canvas.question' ||\n event.component === 'canvas.input' ||\n event.component === 'canvas.calendar'\n );\n}\n\nexport function extractTextContent(message: unknown) {\n if (!isRecord(message) || !Array.isArray(message.content)) return '';\n\n return message.content\n .map((block) =>\n isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : ''\n )\n .filter(Boolean)\n .join('\\n')\n .trim();\n}\n\nexport function buildCanvasModelContextAppend(\n instance: CanvasInstance,\n update: CanvasModelContextUpdate\n) {\n const structuredContent = isRecord(update.structuredContent)\n ? update.structuredContent\n : undefined;\n const contentText = extractContentText(update.content);\n const state = typeof structuredContent?.state === 'string' ? structuredContent.state : 'updated';\n const summary =\n typeof structuredContent?.summary === 'string' ? structuredContent.summary : contentText;\n const sections = [\n `Magic Canvas state update for ${instance.canvas_config.component} (${instance.tool_call_id}).`,\n `State: ${state}.`,\n ];\n\n if (summary) sections.push(`Summary: ${summary}`);\n if (structuredContent) {\n sections.push(`Structured state: ${safeStringify(structuredContent)}`);\n }\n if (contentText && contentText !== summary) sections.push(contentText);\n\n return sections.join('\\n');\n}\n\nexport function buildHostContext(\n instance: CanvasInstance,\n layout = resolveCanvasLayout(instance, defaultCanvasViewport())\n): JsonRecord {\n return {\n ...instance.canvas_config.host_context,\n displayMode: layout.display_mode,\n availableDisplayModes: ['inline', 'fullscreen'],\n containerDimensions: canvasContainerDimensions(layout),\n layout: {\n preferred_slot: layout.preferred_slot,\n viable_slot: layout.viable_slot,\n avoid_safe_area: layout.avoid_safe_area,\n safe_area: layout.safe_area,\n backdrop: layout.backdrop,\n },\n userAgent: '@tavus/cvi-ui magic-canvas',\n platform: 'web',\n };\n}\n\nexport function createCanvasInstance({\n conversationId,\n toolCallId,\n args,\n canvasConfig,\n}: {\n conversationId: string;\n toolCallId: string;\n args: JsonRecord;\n canvasConfig: CanvasConfig;\n}): CanvasInstance {\n const runtimeArgs = splitCanvasRuntimeArguments(args);\n\n return {\n id: toolCallId,\n conversation_id: conversationId,\n tool_call_id: toolCallId,\n arguments: runtimeArgs.componentArguments,\n canvas_config: canvasConfig,\n layout: {\n preferred_slot:\n runtimeArgs.preferredSlot ??\n canvasConfig.layout?.preferred_slot ??\n defaultLayoutSlotForComponent(canvasConfig.component),\n display_mode: runtimeArgs.displayMode ?? 'inline',\n avoid_safe_area: runtimeArgs.avoidSafeArea ?? canvasConfig.layout?.avoid_safe_area ?? true,\n safe_area: runtimeArgs.safeArea ?? canvasConfig.layout?.safe_area ?? DEFAULT_CANVAS_SAFE_AREA,\n backdrop: runtimeArgs.backdrop ?? canvasConfig.layout?.backdrop ?? DEFAULT_CANVAS_BACKDROP,\n },\n revision: 0,\n };\n}\n\nexport function resolveCanvasLayout(\n instance: CanvasInstance,\n viewport: CanvasViewport\n): CanvasResolvedLayout {\n const displayMode = instance.layout.display_mode;\n\n return {\n ...instance.layout,\n display_mode: displayMode,\n viable_slot:\n displayMode === 'fullscreen'\n ? 'full'\n : resolveCanvasLayoutSlot(instance.layout.preferred_slot, viewport),\n };\n}\n\nexport function resolveCanvasSidecarLayout(\n layouts: CanvasResolvedLayout[],\n viewport: CanvasViewport\n): CanvasSidecarLayout {\n const sideLayouts = layouts.filter((layout) => {\n if (!layout.avoid_safe_area || layout.display_mode === 'fullscreen') return false;\n return layout.viable_slot === 'safe-area-left' || layout.viable_slot === 'safe-area-right';\n });\n const hasLeft = sideLayouts.some((layout) => layout.viable_slot === 'safe-area-left');\n const hasRight = sideLayouts.some((layout) => layout.viable_slot === 'safe-area-right');\n // When canvases occupy both sides, shifting the video toward either one\n // would just unbalance the persona. Keep it centered, let the cards sit on\n // top of the video edges, and skip the mirror backdrop since there is no\n // single gap to fill.\n if (hasLeft && hasRight) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n const activeLayout = [...sideLayouts].reverse()[0];\n\n if (!activeLayout || viewport.width < MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n\n const panelWidth = Math.min(\n MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX,\n Math.max(0, viewport.width - MAGIC_CANVAS_SIDE_PANEL_INSET_PX * 2)\n );\n const safeArea = activeLayout.safe_area;\n const safeAreaLeft = safeArea.x * viewport.width;\n const safeAreaRight = (safeArea.x + safeArea.width) * viewport.width;\n const maxShift = Math.min(360, viewport.width * 0.3);\n let videoShiftX = 0;\n\n if (activeLayout.viable_slot === 'safe-area-left') {\n const panelRight =\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX + panelWidth + MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = clamp(panelRight - safeAreaLeft, 0, maxShift);\n } else {\n const panelLeft =\n viewport.width -\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX -\n panelWidth -\n MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = -clamp(safeAreaRight - panelLeft, 0, maxShift);\n }\n\n return {\n active: true,\n side: activeLayout.viable_slot === 'safe-area-left' ? 'left' : 'right',\n video_shift_x: Math.round(videoShiftX),\n safe_area: safeArea,\n backdrop: activeLayout.backdrop,\n };\n}\n\nexport function resolveCanvasLayoutSlot(\n preferredSlot: CanvasLayoutSlot,\n viewport: CanvasViewport\n): CanvasLayoutSlot {\n if (preferredSlot === 'full') return 'full';\n\n if (\n preferredSlot === 'safe-area-bottom' &&\n viewport.visualViewportHeight !== undefined &&\n viewport.visualViewportHeight < viewport.height * 0.75\n ) {\n return 'full';\n }\n\n if (\n (preferredSlot === 'safe-area-left' || preferredSlot === 'safe-area-right') &&\n viewport.width < 768 &&\n viewport.height >= viewport.width\n ) {\n return 'safe-area-bottom';\n }\n\n return preferredSlot;\n}\n\nexport function splitCanvasRuntimeArguments(args: JsonRecord): {\n componentArguments: JsonRecord;\n preferredSlot?: CanvasLayoutSlot;\n displayMode?: CanvasDisplayMode;\n avoidSafeArea?: boolean;\n safeArea?: CanvasSafeArea;\n backdrop?: CanvasBackdropConfig;\n} {\n const componentArguments: JsonRecord = {};\n\n for (const [key, value] of Object.entries(args)) {\n if (\n key === 'layout' ||\n key === 'preferred_slot' ||\n key === 'preferredSlot' ||\n key === 'display_mode' ||\n key === 'displayMode' ||\n key === 'presentation' ||\n key === 'fullscreen' ||\n key === 'avoid_safe_area' ||\n key === 'avoidSafeArea' ||\n key === 'safe_area' ||\n key === 'safeArea' ||\n key === 'backdrop'\n ) {\n continue;\n }\n\n componentArguments[key] = value;\n }\n\n return {\n componentArguments,\n preferredSlot:\n parseCanvasLayoutPreference(args.layout) ??\n parseCanvasLayoutPreference(args.preferred_slot) ??\n parseCanvasLayoutPreference(args.preferredSlot),\n displayMode: parseCanvasDisplayMode(args),\n avoidSafeArea: parseCanvasAvoidSafeArea(args),\n safeArea: parseCanvasSafeAreaFromArguments(args),\n backdrop: parseCanvasBackdropFromArguments(args),\n };\n}\n\nexport function defaultLayoutSlotForComponent(component: string): CanvasLayoutSlot {\n switch (component) {\n case 'canvas.alert':\n return 'safe-area-bottom';\n case 'canvas.chart':\n case 'canvas.image':\n case 'canvas.video':\n return 'full';\n default:\n return DEFAULT_CANVAS_LAYOUT_SLOT;\n }\n}\n\nexport function canvasContainerDimensions(\n layout: CanvasResolvedLayout,\n options?: { maxHeight?: number; displayScale?: number }\n) {\n const displayScale = options?.displayScale ?? 1;\n\n if (layout.display_mode === 'fullscreen' || layout.viable_slot === 'full') {\n return {\n maxWidth: undefined,\n maxHeight: undefined,\n displayScale,\n };\n }\n\n const maxHeight = options?.maxHeight ?? MAGIC_CANVAS_MAX_HEIGHT_PX;\n\n if (layout.viable_slot === 'safe-area-bottom') {\n return {\n maxWidth: 720,\n maxHeight,\n displayScale,\n };\n }\n\n return {\n width: 384,\n maxHeight,\n displayScale,\n };\n}\n\nexport function createInteractionId(toolCallId: string, interactionType: string) {\n const safeToolCallId = toolCallId.replace(/[^a-zA-Z0-9_-]/g, '_');\n const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10);\n\n return `ci_${safeToolCallId}_${interactionType}_${random}`;\n}\n\nexport function isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport function toError(error: unknown) {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction extractContentText(content: unknown) {\n if (!Array.isArray(content)) return '';\n\n return content\n .map((block) =>\n isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : ''\n )\n .filter(Boolean)\n .join('\\n')\n .trim();\n}\n\nfunction safeStringify(value: unknown) {\n const serialized = JSON.stringify(value);\n if (!serialized) return '{}';\n if (serialized.length <= 2000) return serialized;\n return `${serialized.slice(0, 2000)}...`;\n}\n\nfunction parseCanvasLayoutConfig(value: unknown): CanvasLayoutConfig | undefined | null {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const preferredSlot = parseCanvasLayoutPreference(value);\n const safeArea = parseCanvasSafeArea(value.safe_area ?? value.safeArea);\n const backdrop = parseCanvasBackdrop(value.backdrop);\n\n if (safeArea === null || backdrop === null) return null;\n\n return {\n ...(preferredSlot ? { preferred_slot: preferredSlot } : {}),\n ...parseOptionalBooleanConfig(value.avoid_safe_area ?? value.avoidSafeArea, 'avoid_safe_area'),\n ...(safeArea ? { safe_area: safeArea } : {}),\n ...(backdrop ? { backdrop } : {}),\n };\n}\n\nfunction parseCanvasLayoutPreference(value: unknown): CanvasLayoutSlot | undefined {\n if (typeof value === 'string' && isCanvasLayoutSlot(value)) return value;\n if (!isRecord(value)) return undefined;\n\n const preferredSlot = value.preferred_slot ?? value.preferredSlot ?? value.slot;\n return typeof preferredSlot === 'string' && isCanvasLayoutSlot(preferredSlot)\n ? preferredSlot\n : undefined;\n}\n\nfunction parseCanvasDisplayMode(args: JsonRecord): CanvasDisplayMode | undefined {\n if (args.presentation === true || args.fullscreen === true) return 'fullscreen';\n if (args.presentation === false || args.fullscreen === false) return 'inline';\n\n const displayMode = args.display_mode ?? args.displayMode;\n return displayMode === 'fullscreen' || displayMode === 'inline' ? displayMode : undefined;\n}\n\nfunction parseCanvasAvoidSafeArea(args: JsonRecord): boolean | undefined {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const value =\n args.avoid_safe_area ?? args.avoidSafeArea ?? layout?.avoid_safe_area ?? layout?.avoidSafeArea;\n return typeof value === 'boolean' ? value : undefined;\n}\n\nfunction parseCanvasSafeAreaFromArguments(args: JsonRecord): CanvasSafeArea | undefined {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasSafeArea(\n args.safe_area ?? args.safeArea ?? layout?.safe_area ?? layout?.safeArea\n );\n return parsed ?? undefined;\n}\n\nfunction parseCanvasBackdropFromArguments(args: JsonRecord): CanvasBackdropConfig | undefined {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasBackdrop(args.backdrop ?? layout?.backdrop);\n return parsed ?? undefined;\n}\n\nfunction parseCanvasSafeArea(value: unknown): CanvasSafeArea | undefined | null {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const x = readNumber(value, 'x');\n const y = readNumber(value, 'y');\n const width = readNumber(value, 'width');\n const height = readNumber(value, 'height');\n\n if (x === undefined || y === undefined || width === undefined || height === undefined) {\n return null;\n }\n if (x < 0 || y < 0 || width <= 0 || height <= 0) return null;\n if (x + width > 1.001 || y + height > 1.001) return null;\n\n return {\n x: clamp(x, 0, 1),\n y: clamp(y, 0, 1),\n width: clamp(width, 0, 1),\n height: clamp(height, 0, 1),\n };\n}\n\nfunction parseCanvasBackdrop(value: unknown): CanvasBackdropConfig | undefined | null {\n if (value === undefined) return undefined;\n if (value === 'snapshot_mirror' || value === 'none') return { type: value };\n if (!isRecord(value)) return null;\n\n const type = value.type;\n if (type === 'snapshot_mirror' || type === 'none') return { type };\n return null;\n}\n\nfunction parseOptionalBooleanConfig(\n value: unknown,\n key: 'avoid_safe_area'\n): Pick<CanvasLayoutConfig, typeof key> {\n return typeof value === 'boolean' ? { [key]: value } : {};\n}\n\nfunction isCanvasLayoutSlot(value: string): value is CanvasLayoutSlot {\n return CANVAS_LAYOUT_SLOTS.includes(value as CanvasLayoutSlot);\n}\n\nfunction defaultCanvasViewport(): CanvasViewport {\n return {\n width: globalThis.innerWidth || 1024,\n height: globalThis.innerHeight || 768,\n visualViewportHeight: globalThis.visualViewport?.height,\n };\n}\n\nfunction readString(value: JsonRecord, key: string) {\n const field = value[key];\n return typeof field === 'string' && field.length > 0 ? field : undefined;\n}\n\nfunction readNumber(value: JsonRecord, key: string) {\n const field = value[key];\n return typeof field === 'number' && Number.isFinite(field) ? field : undefined;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max);\n}\n\nfunction readOptionalAllowedUrl(\n value: JsonRecord,\n key: string,\n isAllowed: (rawUrl: string) => boolean\n) {\n const url = readString(value, key);\n if (!url) return undefined;\n return isAllowed(url) ? url : null;\n}\n\nfunction toPendingInteraction(value: JsonRecord): PendingInteraction | null {\n if (value.interaction_id !== undefined && typeof value.interaction_id !== 'string') return null;\n if (value.component !== undefined && typeof value.component !== 'string') return null;\n if (value.component_version !== undefined && typeof value.component_version !== 'string')\n return null;\n if (value.type !== undefined && typeof value.type !== 'string') return null;\n if (value.metadata !== undefined && !isRecord(value.metadata)) return null;\n\n return value as PendingInteraction;\n}\n"
406
+ "content": "// Magic Canvas runtime: pure parsing, validation, normalization, and the\n// constants the React surface and bridge code both need. Nothing in this file\n// touches React, fetch, or the AppBridge so it stays trivially testable and\n// portable across host runtimes.\n\nimport {\n isAllowedCanvasApiUrl,\n isAllowedCanvasMcpUrl,\n isAllowedCanvasSandboxUrl,\n} from \"./allowlists.js\";\n\nexport const CANVAS_INTERACTION_META_KEY = \"tavus.canvas.interaction\";\nexport const SUPPORTED_CANVAS_CONFIG_VERSION = 1;\nexport const MAGIC_CANVAS_MIN_HEIGHT_PX = 240;\nexport const MAGIC_CANVAS_MAX_HEIGHT_PX = 720;\nexport const MIN_CANVAS_DISPLAY_SCALE = 0.85;\nexport const MAX_CANVAS_INSTANCES = 3;\nexport const CANVAS_CLEAR_TOOL_NAME = \"canvas_clear\";\nexport const CANVAS_UPDATE_TOOL_NAME = \"update_component\";\n\nexport const CANVAS_LAYOUT_SLOTS = [\n \"safe-area-right\",\n \"safe-area-left\",\n \"safe-area-bottom\",\n \"full\",\n] as const;\nexport const DEFAULT_CANVAS_LAYOUT_SLOT: CanvasLayoutSlot = \"safe-area-right\";\nexport const DEFAULT_CANVAS_SAFE_AREA: CanvasSafeArea = {\n x: 275 / 1280,\n y: 111 / 720,\n width: 730 / 1280,\n height: 609 / 720,\n};\nexport const DEFAULT_CANVAS_BACKDROP: CanvasBackdropConfig = {\n type: \"snapshot_mirror\",\n};\nexport const MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX = 448;\nexport const MAGIC_CANVAS_SIDE_PANEL_INSET_PX = 16;\nexport const MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX = 24;\nexport const MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX = 900;\n\nexport type JsonRecord = Record<string, unknown>;\nexport type CanvasLayoutSlot = (typeof CANVAS_LAYOUT_SLOTS)[number];\nexport type CanvasDisplayMode = \"inline\" | \"fullscreen\";\nexport type CanvasBackdropType = \"snapshot_mirror\" | \"none\";\n\nexport type CanvasSafeArea = {\n x: number;\n y: number;\n width: number;\n height: number;\n};\n\nexport type CanvasBackdropConfig = {\n type: CanvasBackdropType;\n};\n\nexport type CanvasLayoutConfig = {\n preferred_slot?: CanvasLayoutSlot;\n avoid_safe_area?: boolean;\n safe_area?: CanvasSafeArea;\n backdrop?: CanvasBackdropConfig;\n};\n\nexport type CanvasLayoutState = {\n preferred_slot: CanvasLayoutSlot;\n display_mode: CanvasDisplayMode;\n avoid_safe_area: boolean;\n safe_area: CanvasSafeArea;\n backdrop: CanvasBackdropConfig;\n};\n\nexport type CanvasResolvedLayout = CanvasLayoutState & {\n viable_slot: CanvasLayoutSlot;\n};\n\nexport type CanvasViewport = {\n width: number;\n height: number;\n visualViewportHeight?: number;\n};\n\nexport type CanvasSidecarLayout = {\n active: boolean;\n side?: \"left\" | \"right\";\n video_shift_x: number;\n safe_area?: CanvasSafeArea;\n backdrop: CanvasBackdropConfig;\n};\n\nexport type CanvasConfig = {\n version: number;\n component: string;\n component_version: string;\n resource_uri?: string;\n sandbox_url: string;\n mcp_server_url?: string;\n mcp_tool_name?: string;\n api_base_url?: string;\n interaction_url?: string;\n layout?: CanvasLayoutConfig;\n host_context?: JsonRecord;\n};\n\nexport type CanvasToolCallProperties = {\n name?: string;\n arguments?: string | JsonRecord;\n tool_call_id?: string;\n};\n\nexport type CanvasToolCallMessage = {\n message_type: \"conversation\";\n event_type: \"conversation.tool_call\";\n conversation_id: string;\n properties: CanvasToolCallProperties;\n canvas_config?: unknown;\n};\n\nexport type CanvasInstance = {\n id: string;\n conversation_id: string;\n tool_call_id: string;\n arguments: JsonRecord;\n canvas_config: CanvasConfig;\n layout: CanvasLayoutState;\n revision: number;\n};\n\nexport type CanvasShowCommand = {\n kind: \"show\";\n instance: CanvasInstance;\n};\n\nexport type CanvasUpdateCommand = {\n kind: \"update\";\n conversation_id: string;\n tool_call_id: string;\n updates: JsonRecord;\n};\n\nexport type CanvasClearCommand = {\n kind: \"clear\";\n conversation_id: string;\n tool_call_id?: string;\n reason?: string;\n};\n\nexport type CanvasCommand =\n | CanvasShowCommand\n | CanvasUpdateCommand\n | CanvasClearCommand;\n\nexport type CanvasInteractionEvent = {\n interaction_id: string;\n conversation_id: string;\n tool_call_id: string;\n component: string;\n component_version: string;\n type: string;\n value: unknown;\n metadata: JsonRecord;\n};\n\nexport type CanvasErrorCode =\n | \"malformed_canvas_config\"\n | \"missing_tool_call_id\"\n | \"invalid_tool_arguments\"\n | \"missing_interaction_metadata\"\n | \"interaction_normalization_failed\"\n | \"interaction_post_failed\"\n | \"on_interaction_callback_failed\"\n | \"bridge_connect_failed\"\n | \"send_tool_input_failed\";\n\nexport type CanvasErrorEvent = {\n code: CanvasErrorCode;\n message: string;\n conversation_id?: string;\n tool_call_id?: string;\n component?: string;\n cause?: unknown;\n};\n\nexport type PendingInteraction = {\n interaction_id?: string;\n component?: string;\n component_version?: string;\n type?: string;\n value?: unknown;\n metadata?: JsonRecord;\n};\n\nexport type CanvasModelContextUpdate = {\n content?: unknown;\n structuredContent?: unknown;\n};\n\nexport function isCanvasToolCallMessage(\n value: unknown,\n): value is CanvasToolCallMessage {\n if (!isRecord(value)) return false;\n\n return (\n value.message_type === \"conversation\" &&\n value.event_type === \"conversation.tool_call\" &&\n typeof value.conversation_id === \"string\" &&\n isRecord(value.properties)\n );\n}\n\nexport function parseCanvasConfig(value: unknown): CanvasConfig | null {\n if (!isRecord(value)) return null;\n\n const component = readString(value, \"component\");\n const componentVersion = readString(value, \"component_version\");\n const sandboxUrl = readString(value, \"sandbox_url\");\n const mcpServerUrl = readOptionalAllowedUrl(\n value,\n \"mcp_server_url\",\n isAllowedCanvasMcpUrl,\n );\n const apiBaseUrl = readOptionalAllowedUrl(\n value,\n \"api_base_url\",\n isAllowedCanvasApiUrl,\n );\n const interactionUrl = readOptionalAllowedUrl(\n value,\n \"interaction_url\",\n isAllowedCanvasApiUrl,\n );\n const layout = parseCanvasLayoutConfig(value.layout);\n const version = value.version;\n\n if (version !== SUPPORTED_CANVAS_CONFIG_VERSION) return null;\n if (!component || !componentVersion || !sandboxUrl) return null;\n if (!isAllowedCanvasSandboxUrl(sandboxUrl)) return null;\n if (mcpServerUrl === null || apiBaseUrl === null || interactionUrl === null)\n return null;\n if (layout === null) return null;\n\n return {\n version,\n component,\n component_version: componentVersion,\n resource_uri: readString(value, \"resource_uri\"),\n sandbox_url: sandboxUrl,\n mcp_server_url: mcpServerUrl,\n mcp_tool_name: readString(value, \"mcp_tool_name\"),\n api_base_url: apiBaseUrl,\n interaction_url: interactionUrl,\n layout,\n host_context: isRecord(value.host_context) ? value.host_context : undefined,\n };\n}\n\n/**\n * Returns the component id (e.g. \"canvas.alert\") of a Magic Canvas show\n * tool-call message, or null when the message is not a canvas show. Pure over\n * the tool-call message; host surfaces (e.g. the dev-portal post-test recap)\n * use it to record which on-screen element was displayed. Promoted into the\n * canonical runtime from the dev-portal-local copy (PROD-3563).\n */\nexport function getShownCanvasComponent(value: unknown): string | null {\n if (!isCanvasToolCallMessage(value)) return null;\n if (value.canvas_config === undefined || value.canvas_config === null)\n return null;\n return parseCanvasConfig(value.canvas_config)?.component ?? null;\n}\n\nexport function parseToolArguments(\n value: CanvasToolCallProperties[\"arguments\"],\n): { ok: true; value: JsonRecord } | { ok: false; error: Error } {\n if (value === undefined) return { ok: true, value: {} };\n if (isRecord(value)) return { ok: true, value };\n\n if (typeof value !== \"string\") {\n return {\n ok: false,\n error: new Error(\n \"Magic Canvas tool arguments must be an object or JSON string.\",\n ),\n };\n }\n\n try {\n const parsed = JSON.parse(value);\n\n if (!isRecord(parsed)) {\n return {\n ok: false,\n error: new Error(\n \"Magic Canvas tool arguments must decode to an object.\",\n ),\n };\n }\n\n return { ok: true, value: parsed };\n } catch (error) {\n return { ok: false, error: toError(error) };\n }\n}\n\nexport function parseCanvasControlCommand(\n message: CanvasToolCallMessage,\n): CanvasUpdateCommand | CanvasClearCommand | null {\n const toolName = message.properties.name;\n\n if (\n toolName !== CANVAS_CLEAR_TOOL_NAME &&\n toolName !== CANVAS_UPDATE_TOOL_NAME\n )\n return null;\n\n const parsedArguments = parseToolArguments(message.properties.arguments);\n if (!parsedArguments.ok) throw parsedArguments.error;\n\n if (toolName === CANVAS_CLEAR_TOOL_NAME) {\n const toolCallId = parsedArguments.value.tool_call_id;\n const reason = parsedArguments.value.reason;\n\n if (toolCallId !== undefined && typeof toolCallId !== \"string\") {\n throw new Error(\n \"Magic Canvas clear tool_call_id must be a string when provided.\",\n );\n }\n if (reason !== undefined && typeof reason !== \"string\") {\n throw new Error(\n \"Magic Canvas clear reason must be a string when provided.\",\n );\n }\n\n return {\n kind: \"clear\",\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n reason,\n };\n }\n\n const toolCallId = parsedArguments.value.tool_call_id;\n const updates = parsedArguments.value.updates;\n\n if (typeof toolCallId !== \"string\" || toolCallId.length === 0) {\n throw new Error(\n \"Magic Canvas update_component requires a target tool_call_id.\",\n );\n }\n if (!isRecord(updates)) {\n throw new Error(\n \"Magic Canvas update_component requires an object updates payload.\",\n );\n }\n\n return {\n kind: \"update\",\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n updates,\n };\n}\n\nconst CANVAS_MODEL_SELECTABLE_SLOTS: readonly CanvasLayoutSlot[] = [\n \"safe-area-left\",\n \"safe-area-right\",\n];\n\nfunction clampCanvasLayout(layout: CanvasLayoutState): CanvasLayoutState {\n const preferred_slot = CANVAS_MODEL_SELECTABLE_SLOTS.includes(\n layout.preferred_slot,\n )\n ? layout.preferred_slot\n : DEFAULT_CANVAS_LAYOUT_SLOT;\n if (\n preferred_slot === layout.preferred_slot &&\n layout.display_mode === \"inline\"\n ) {\n return layout;\n }\n return { ...layout, preferred_slot, display_mode: \"inline\" };\n}\n\nfunction canvasEffectiveSlot(layout: CanvasLayoutState): CanvasLayoutSlot {\n return layout.display_mode === \"fullscreen\" ? \"full\" : layout.preferred_slot;\n}\n\n// Single-card policy: a newly shown or updated card replaces what was on screen.\nfunction enforceCanvasSlotExclusivity(\n _others: CanvasInstance[],\n target: CanvasInstance,\n): CanvasInstance[] {\n return [target];\n}\n\nexport function applyCanvasCommand(\n current: CanvasInstance[],\n command: CanvasCommand,\n): CanvasInstance[] {\n switch (command.kind) {\n case \"show\": {\n const existing = current.find((item) => item.id === command.instance.id);\n const nextInstance = existing\n ? { ...command.instance, revision: existing.revision + 1 }\n : command.instance;\n const others = current.filter((item) => item.id !== command.instance.id);\n return enforceCanvasSlotExclusivity(others, nextInstance);\n }\n case \"update\": {\n // Commands are conversation-scoped: a stale or cross-conversation\n // command whose tool_call_id happens to collide must not mutate the\n // current canvas (tool_call_ids are LLM-generated and not unique\n // across conversations).\n const matchesTarget = (item: CanvasInstance) =>\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id;\n const update = splitCanvasRuntimeArguments(command.updates);\n let updated: CanvasInstance | undefined;\n const mapped = current.map((item) => {\n if (!matchesTarget(item)) return item;\n const next: CanvasInstance = {\n ...item,\n arguments: { ...item.arguments, ...update.componentArguments },\n layout: clampCanvasLayout({\n preferred_slot: update.preferredSlot ?? item.layout.preferred_slot,\n display_mode: update.displayMode ?? item.layout.display_mode,\n avoid_safe_area:\n update.avoidSafeArea ?? item.layout.avoid_safe_area,\n safe_area: update.safeArea ?? item.layout.safe_area,\n backdrop: update.backdrop ?? item.layout.backdrop,\n }),\n revision: item.revision + 1,\n };\n updated = next;\n return next;\n });\n if (updated === undefined) return mapped;\n const prev = current.find(matchesTarget);\n if (\n prev &&\n canvasEffectiveSlot(prev.layout) === canvasEffectiveSlot(updated.layout)\n ) {\n return mapped;\n }\n const rest = mapped.filter((item) => !matchesTarget(item));\n return enforceCanvasSlotExclusivity(rest, updated);\n }\n case \"clear\":\n // Clear-all is scoped to the command's conversation; targeted clear\n // must match both conversation and tool_call_id.\n if (!command.tool_call_id)\n return current.filter(\n (item) => item.conversation_id !== command.conversation_id,\n );\n return current.filter(\n (item) =>\n !(\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id\n ),\n );\n }\n}\n\nexport function extractCanvasInteraction(\n message: unknown,\n): PendingInteraction | null {\n if (!isRecord(message) || !Array.isArray(message.content)) return null;\n\n for (const block of message.content) {\n if (!isRecord(block)) continue;\n const meta = isRecord(block._meta) ? block._meta : null;\n const interaction = meta?.[CANVAS_INTERACTION_META_KEY];\n\n if (isRecord(interaction)) {\n return toPendingInteraction(interaction);\n }\n }\n\n return null;\n}\n\nexport function normalizeInteraction(\n instance: CanvasInstance,\n payload: PendingInteraction,\n): CanvasInteractionEvent {\n const interactionType = payload.type;\n const value = payload.value;\n\n if (!interactionType) {\n throw new Error(\"Magic Canvas interaction is missing type.\");\n }\n\n if (value === undefined) {\n throw new Error(\"Magic Canvas interaction is missing value.\");\n }\n\n return {\n interaction_id:\n payload.interaction_id ??\n createInteractionId(instance.tool_call_id, interactionType),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: payload.component ?? instance.canvas_config.component,\n component_version:\n payload.component_version ?? instance.canvas_config.component_version,\n type: interactionType,\n value,\n metadata: isRecord(payload.metadata) ? payload.metadata : {},\n };\n}\n\nexport function shouldCompleteCanvasInteraction(event: CanvasInteractionEvent) {\n if (event.type === \"dismiss\" || event.type === \"clear\") return true;\n if (event.type !== \"submit\" && event.type !== \"skip\") return false;\n return (\n event.component === \"canvas.question\" ||\n event.component === \"canvas.input\" ||\n event.component === \"canvas.calendar\"\n );\n}\n\nexport function extractTextContent(message: unknown) {\n if (!isRecord(message) || !Array.isArray(message.content)) return \"\";\n\n return message.content\n .map((block) =>\n isRecord(block) && block.type === \"text\" && typeof block.text === \"string\"\n ? block.text\n : \"\",\n )\n .filter(Boolean)\n .join(\"\\n\")\n .trim();\n}\n\nexport function buildCanvasModelContextAppend(\n instance: CanvasInstance,\n update: CanvasModelContextUpdate,\n) {\n const structuredContent = isRecord(update.structuredContent)\n ? update.structuredContent\n : undefined;\n const contentText = extractContentText(update.content);\n const state =\n typeof structuredContent?.state === \"string\"\n ? structuredContent.state\n : \"updated\";\n const summary =\n typeof structuredContent?.summary === \"string\"\n ? structuredContent.summary\n : contentText;\n const sections = [\n `Magic Canvas state update for ${instance.canvas_config.component} (${instance.tool_call_id}).`,\n `State: ${state}.`,\n ];\n\n if (summary) sections.push(`Summary: ${summary}`);\n if (structuredContent) {\n sections.push(`Structured state: ${safeStringify(structuredContent)}`);\n }\n if (contentText && contentText !== summary) sections.push(contentText);\n\n return sections.join(\"\\n\");\n}\n\nexport function buildHostContext(\n instance: CanvasInstance,\n layout = resolveCanvasLayout(instance, defaultCanvasViewport()),\n): JsonRecord {\n return {\n ...instance.canvas_config.host_context,\n displayMode: layout.display_mode,\n availableDisplayModes: [\"inline\", \"fullscreen\"],\n containerDimensions: canvasContainerDimensions(layout),\n layout: {\n preferred_slot: layout.preferred_slot,\n viable_slot: layout.viable_slot,\n avoid_safe_area: layout.avoid_safe_area,\n safe_area: layout.safe_area,\n backdrop: layout.backdrop,\n },\n userAgent: \"@tavus/cvi-ui magic-canvas\",\n platform: \"web\",\n };\n}\n\nexport function createCanvasInstance({\n conversationId,\n toolCallId,\n args,\n canvasConfig,\n}: {\n conversationId: string;\n toolCallId: string;\n args: JsonRecord;\n canvasConfig: CanvasConfig;\n}): CanvasInstance {\n const runtimeArgs = splitCanvasRuntimeArguments(args);\n\n return {\n id: toolCallId,\n conversation_id: conversationId,\n tool_call_id: toolCallId,\n arguments: runtimeArgs.componentArguments,\n canvas_config: canvasConfig,\n layout: clampCanvasLayout({\n preferred_slot:\n runtimeArgs.preferredSlot ??\n canvasConfig.layout?.preferred_slot ??\n defaultLayoutSlotForComponent(canvasConfig.component),\n display_mode: runtimeArgs.displayMode ?? \"inline\",\n avoid_safe_area:\n runtimeArgs.avoidSafeArea ??\n canvasConfig.layout?.avoid_safe_area ??\n true,\n safe_area:\n runtimeArgs.safeArea ??\n canvasConfig.layout?.safe_area ??\n DEFAULT_CANVAS_SAFE_AREA,\n backdrop:\n runtimeArgs.backdrop ??\n canvasConfig.layout?.backdrop ??\n DEFAULT_CANVAS_BACKDROP,\n }),\n revision: 0,\n };\n}\n\nexport function resolveCanvasLayout(\n instance: CanvasInstance,\n viewport: CanvasViewport,\n): CanvasResolvedLayout {\n const displayMode = instance.layout.display_mode;\n\n return {\n ...instance.layout,\n display_mode: displayMode,\n viable_slot:\n displayMode === \"fullscreen\"\n ? \"full\"\n : resolveCanvasLayoutSlot(instance.layout.preferred_slot, viewport),\n };\n}\n\nexport function resolveCanvasSidecarLayout(\n layouts: CanvasResolvedLayout[],\n viewport: CanvasViewport,\n): CanvasSidecarLayout {\n const sideLayouts = layouts.filter((layout) => {\n if (!layout.avoid_safe_area || layout.display_mode === \"fullscreen\")\n return false;\n return (\n layout.viable_slot === \"safe-area-left\" ||\n layout.viable_slot === \"safe-area-right\"\n );\n });\n const hasLeft = sideLayouts.some(\n (layout) => layout.viable_slot === \"safe-area-left\",\n );\n const hasRight = sideLayouts.some(\n (layout) => layout.viable_slot === \"safe-area-right\",\n );\n // When canvases occupy both sides, shifting the video toward either one\n // would just unbalance the persona. Keep it centered, let the cards sit on\n // top of the video edges, and skip the mirror backdrop since there is no\n // single gap to fill.\n if (hasLeft && hasRight) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n const activeLayout = [...sideLayouts].reverse()[0];\n\n if (\n !activeLayout ||\n viewport.width < MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX\n ) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n\n const panelWidth = Math.min(\n MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX,\n Math.max(0, viewport.width - MAGIC_CANVAS_SIDE_PANEL_INSET_PX * 2),\n );\n const safeArea = activeLayout.safe_area;\n const safeAreaLeft = safeArea.x * viewport.width;\n const safeAreaRight = (safeArea.x + safeArea.width) * viewport.width;\n const maxShift = Math.min(360, viewport.width * 0.3);\n let videoShiftX = 0;\n\n if (activeLayout.viable_slot === \"safe-area-left\") {\n const panelRight =\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX +\n panelWidth +\n MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = clamp(panelRight - safeAreaLeft, 0, maxShift);\n } else {\n const panelLeft =\n viewport.width -\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX -\n panelWidth -\n MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = -clamp(safeAreaRight - panelLeft, 0, maxShift);\n }\n\n return {\n active: true,\n side: activeLayout.viable_slot === \"safe-area-left\" ? \"left\" : \"right\",\n video_shift_x: Math.round(videoShiftX),\n safe_area: safeArea,\n backdrop: activeLayout.backdrop,\n };\n}\n\nexport function resolveCanvasLayoutSlot(\n preferredSlot: CanvasLayoutSlot,\n viewport: CanvasViewport,\n): CanvasLayoutSlot {\n if (preferredSlot === \"full\") return \"full\";\n\n if (\n preferredSlot === \"safe-area-bottom\" &&\n viewport.visualViewportHeight !== undefined &&\n viewport.visualViewportHeight < viewport.height * 0.75\n ) {\n return \"full\";\n }\n\n if (\n (preferredSlot === \"safe-area-left\" ||\n preferredSlot === \"safe-area-right\") &&\n viewport.width < 768 &&\n viewport.height >= viewport.width\n ) {\n return \"safe-area-bottom\";\n }\n\n return preferredSlot;\n}\n\nexport function splitCanvasRuntimeArguments(args: JsonRecord): {\n componentArguments: JsonRecord;\n preferredSlot?: CanvasLayoutSlot;\n displayMode?: CanvasDisplayMode;\n avoidSafeArea?: boolean;\n safeArea?: CanvasSafeArea;\n backdrop?: CanvasBackdropConfig;\n} {\n const componentArguments: JsonRecord = {};\n\n for (const [key, value] of Object.entries(args)) {\n if (\n key === \"layout\" ||\n key === \"preferred_slot\" ||\n key === \"preferredSlot\" ||\n key === \"display_mode\" ||\n key === \"displayMode\" ||\n key === \"presentation\" ||\n key === \"fullscreen\" ||\n key === \"avoid_safe_area\" ||\n key === \"avoidSafeArea\" ||\n key === \"safe_area\" ||\n key === \"safeArea\" ||\n key === \"backdrop\"\n ) {\n continue;\n }\n\n componentArguments[key] = value;\n }\n\n return {\n componentArguments,\n preferredSlot:\n parseCanvasLayoutPreference(args.layout) ??\n parseCanvasLayoutPreference(args.preferred_slot) ??\n parseCanvasLayoutPreference(args.preferredSlot),\n displayMode: parseCanvasDisplayMode(args),\n avoidSafeArea: parseCanvasAvoidSafeArea(args),\n safeArea: parseCanvasSafeAreaFromArguments(args),\n backdrop: parseCanvasBackdropFromArguments(args),\n };\n}\n\nexport function defaultLayoutSlotForComponent(\n component: string,\n): CanvasLayoutSlot {\n switch (component) {\n case \"canvas.alert\":\n return \"safe-area-bottom\";\n case \"canvas.chart\":\n case \"canvas.image\":\n case \"canvas.video\":\n return \"full\";\n default:\n return DEFAULT_CANVAS_LAYOUT_SLOT;\n }\n}\n\nexport function canvasContainerDimensions(\n layout: CanvasResolvedLayout,\n options?: { maxHeight?: number; displayScale?: number },\n) {\n const displayScale = options?.displayScale ?? 1;\n\n if (layout.display_mode === \"fullscreen\" || layout.viable_slot === \"full\") {\n return {\n maxWidth: undefined,\n maxHeight: undefined,\n displayScale,\n };\n }\n\n const maxHeight = options?.maxHeight ?? MAGIC_CANVAS_MAX_HEIGHT_PX;\n\n if (layout.viable_slot === \"safe-area-bottom\") {\n return {\n maxWidth: 720,\n maxHeight,\n displayScale,\n };\n }\n\n return {\n width: 384,\n maxHeight,\n displayScale,\n };\n}\n\nexport function createInteractionId(\n toolCallId: string,\n interactionType: string,\n) {\n const safeToolCallId = toolCallId.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n const random =\n globalThis.crypto?.randomUUID?.() ??\n Math.random().toString(36).slice(2, 10);\n\n return `ci_${safeToolCallId}_${interactionType}_${random}`;\n}\n\nexport function isRecord(value: unknown): value is JsonRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nexport function toError(error: unknown) {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction extractContentText(content: unknown) {\n if (!Array.isArray(content)) return \"\";\n\n return content\n .map((block) =>\n isRecord(block) && block.type === \"text\" && typeof block.text === \"string\"\n ? block.text\n : \"\",\n )\n .filter(Boolean)\n .join(\"\\n\")\n .trim();\n}\n\nfunction safeStringify(value: unknown) {\n const serialized = JSON.stringify(value);\n if (!serialized) return \"{}\";\n if (serialized.length <= 2000) return serialized;\n return `${serialized.slice(0, 2000)}...`;\n}\n\nfunction parseCanvasLayoutConfig(\n value: unknown,\n): CanvasLayoutConfig | undefined | null {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const preferredSlot = parseCanvasLayoutPreference(value);\n const safeArea = parseCanvasSafeArea(value.safe_area ?? value.safeArea);\n const backdrop = parseCanvasBackdrop(value.backdrop);\n\n if (safeArea === null || backdrop === null) return null;\n\n return {\n ...(preferredSlot ? { preferred_slot: preferredSlot } : {}),\n ...parseOptionalBooleanConfig(\n value.avoid_safe_area ?? value.avoidSafeArea,\n \"avoid_safe_area\",\n ),\n ...(safeArea ? { safe_area: safeArea } : {}),\n ...(backdrop ? { backdrop } : {}),\n };\n}\n\nfunction parseCanvasLayoutPreference(\n value: unknown,\n): CanvasLayoutSlot | undefined {\n if (typeof value === \"string\" && isCanvasLayoutSlot(value)) return value;\n if (!isRecord(value)) return undefined;\n\n const preferredSlot =\n value.preferred_slot ?? value.preferredSlot ?? value.slot;\n return typeof preferredSlot === \"string\" && isCanvasLayoutSlot(preferredSlot)\n ? preferredSlot\n : undefined;\n}\n\nfunction parseCanvasDisplayMode(\n args: JsonRecord,\n): CanvasDisplayMode | undefined {\n if (args.presentation === true || args.fullscreen === true)\n return \"fullscreen\";\n if (args.presentation === false || args.fullscreen === false) return \"inline\";\n\n const displayMode = args.display_mode ?? args.displayMode;\n return displayMode === \"fullscreen\" || displayMode === \"inline\"\n ? displayMode\n : undefined;\n}\n\nfunction parseCanvasAvoidSafeArea(args: JsonRecord): boolean | undefined {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const value =\n args.avoid_safe_area ??\n args.avoidSafeArea ??\n layout?.avoid_safe_area ??\n layout?.avoidSafeArea;\n return typeof value === \"boolean\" ? value : undefined;\n}\n\nfunction parseCanvasSafeAreaFromArguments(\n args: JsonRecord,\n): CanvasSafeArea | undefined {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasSafeArea(\n args.safe_area ?? args.safeArea ?? layout?.safe_area ?? layout?.safeArea,\n );\n return parsed ?? undefined;\n}\n\nfunction parseCanvasBackdropFromArguments(\n args: JsonRecord,\n): CanvasBackdropConfig | undefined {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasBackdrop(args.backdrop ?? layout?.backdrop);\n return parsed ?? undefined;\n}\n\nfunction parseCanvasSafeArea(\n value: unknown,\n): CanvasSafeArea | undefined | null {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const x = readNumber(value, \"x\");\n const y = readNumber(value, \"y\");\n const width = readNumber(value, \"width\");\n const height = readNumber(value, \"height\");\n\n if (\n x === undefined ||\n y === undefined ||\n width === undefined ||\n height === undefined\n ) {\n return null;\n }\n if (x < 0 || y < 0 || width <= 0 || height <= 0) return null;\n if (x + width > 1.001 || y + height > 1.001) return null;\n\n return {\n x: clamp(x, 0, 1),\n y: clamp(y, 0, 1),\n width: clamp(width, 0, 1),\n height: clamp(height, 0, 1),\n };\n}\n\nfunction parseCanvasBackdrop(\n value: unknown,\n): CanvasBackdropConfig | undefined | null {\n if (value === undefined) return undefined;\n if (value === \"snapshot_mirror\" || value === \"none\") return { type: value };\n if (!isRecord(value)) return null;\n\n const type = value.type;\n if (type === \"snapshot_mirror\" || type === \"none\") return { type };\n return null;\n}\n\nfunction parseOptionalBooleanConfig(\n value: unknown,\n key: \"avoid_safe_area\",\n): Pick<CanvasLayoutConfig, typeof key> {\n return typeof value === \"boolean\" ? { [key]: value } : {};\n}\n\nfunction isCanvasLayoutSlot(value: string): value is CanvasLayoutSlot {\n return CANVAS_LAYOUT_SLOTS.includes(value as CanvasLayoutSlot);\n}\n\nfunction defaultCanvasViewport(): CanvasViewport {\n return {\n width: globalThis.innerWidth || 1024,\n height: globalThis.innerHeight || 768,\n visualViewportHeight: globalThis.visualViewport?.height,\n };\n}\n\nfunction readString(value: JsonRecord, key: string) {\n const field = value[key];\n return typeof field === \"string\" && field.length > 0 ? field : undefined;\n}\n\nfunction readNumber(value: JsonRecord, key: string) {\n const field = value[key];\n return typeof field === \"number\" && Number.isFinite(field)\n ? field\n : undefined;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max);\n}\n\nfunction readOptionalAllowedUrl(\n value: JsonRecord,\n key: string,\n isAllowed: (rawUrl: string) => boolean,\n) {\n const url = readString(value, key);\n if (!url) return undefined;\n return isAllowed(url) ? url : null;\n}\n\nfunction toPendingInteraction(value: JsonRecord): PendingInteraction | null {\n if (\n value.interaction_id !== undefined &&\n typeof value.interaction_id !== \"string\"\n )\n return null;\n if (value.component !== undefined && typeof value.component !== \"string\")\n return null;\n if (\n value.component_version !== undefined &&\n typeof value.component_version !== \"string\"\n )\n return null;\n if (value.type !== undefined && typeof value.type !== \"string\") return null;\n if (value.metadata !== undefined && !isRecord(value.metadata)) return null;\n\n return value as PendingInteraction;\n}\n"
407
407
  }
408
408
  ],
409
409
  componentsDependencies: ["cvi-events-hooks"],
@@ -628,8 +628,8 @@ var hair_check_01_default = {
628
628
  //#region src/templates/jsx/components/magic-canvas.json
629
629
  var magic_canvas_default = {
630
630
  type: "components",
631
- content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';\nimport { useObservableEvent, useSendAppMessage } from '../../hooks/cvi-events-hooks';\n\nimport styles from './magic-canvas.module.css';\nimport { closeBridge } from './bridge';\nimport {\n applyCanvasCommand,\n buildCanvasModelContextAppend,\n buildHostContext,\n CANVAS_LAYOUT_SLOTS,\n createCanvasInstance,\n extractCanvasInteraction,\n extractTextContent,\n isCanvasToolCallMessage,\n MAGIC_CANVAS_MAX_HEIGHT_PX,\n normalizeInteraction,\n parseCanvasConfig,\n parseCanvasControlCommand,\n parseToolArguments,\n resolveCanvasLayout,\n resolveCanvasSidecarLayout,\n} from './runtime';\n\nimport {\n createCanvasCompletionScheduler,\n deliverCanvasInteraction,\n NativeCanvasHost,\n resolveCanvasRenderer,\n} from './native-host';\n\nexport { canvasRendererKey, NativeCanvasHost, resolveCanvasRenderer } from './native-host';\n\nconst MODEL_CONTEXT_RELAY_INTERVAL_MS = 1000;\n\n// Floor for app-REPORTED heights. The runtime's MAGIC_CANVAS_MIN_HEIGHT_PX\n// (240) is a layout default, not a floor for real reports — flooring there\n// padded short components (e.g. a 182px input card) with dead space below\n// the content. Real reports are trusted; this only guards degenerate values\n// from a mid-load ResizeObserver tick. Mirrors the tavus-deployment host.\nconst CANVAS_REPORTED_HEIGHT_FLOOR_PX = 48;\n\n// Per-component iframe sandbox policy, owned by the host (not server-supplied).\n// Default is locked-down allow-scripts; Calendly's scheduling_embed needs its\n// own origin, popups, and form submission to complete a booking.\nconst DEFAULT_IFRAME_SANDBOX = 'allow-scripts';\nconst COMPONENT_IFRAME_SANDBOX = {\n 'canvas.scheduling_embed': 'allow-scripts allow-same-origin allow-popups allow-forms',\n};\n\n// Trust a bridge message only when it comes from THIS iframe's LIVE\n// contentWindow (read at message time, not captured earlier) and is JSON-RPC\n// shaped: accepts the component's post-navigation messages while rejecting\n// forged JSON-RPC from other frames and cross-talk between sibling canvases.\nexport function isTrustedCanvasFrameMessage(event, iframe) {\n if (!iframe.contentWindow || event.source !== iframe.contentWindow) return false;\n const data = event.data;\n return Boolean(data) && typeof data === 'object' && data?.jsonrpc === '2.0';\n}\n\n// Host-side JSON-RPC transport for the component iframe. We connect\n// SYNCHRONOUSLY, before the iframe navigates, to catch the app's\n// `ui/initialize` handshake (fires during module-script execution, before\n// `load`). The SDK's PostMessageTransport can't do this: it compares\n// `event.source` against a window captured at construct time, the stale\n// pre-navigation about:blank window, and would drop the handshake. This\n// transport validates against the LIVE `iframe.contentWindow` at receive\n// time. Mirrors the tavus-deployment host.\nexport class CanvasFrameTransport {\n onmessage;\n onerror;\n onclose;\n\n #iframe;\n // `| undefined` (not the `?` optional marker): the TS->JS template\n // converter strips types but keeps the optional marker on private class\n // fields, which would emit invalid JS (`#listener?;`).\n #listener = undefined;\n\n constructor(iframe) {\n this.#iframe = iframe;\n }\n\n start() {\n const listener = (event) => {\n if (!isTrustedCanvasFrameMessage(event, this.#iframe)) return;\n this.onmessage?.(event.data);\n };\n this.#listener = listener;\n globalThis.addEventListener('message', listener);\n return Promise.resolve();\n }\n\n send(message) {\n // Same `\"*\"` target origin the SDK transport uses; the receiver validates\n // source rather than relying on a target-origin match (the sandboxed app\n // has an opaque origin).\n this.#iframe.contentWindow?.postMessage(message, '*');\n return Promise.resolve();\n }\n\n close() {\n if (this.#listener) globalThis.removeEventListener('message', this.#listener);\n this.#listener = undefined;\n this.onclose?.();\n return Promise.resolve();\n }\n}\n\n// Navigate the iframe and wire the bridge connect. Connect synchronously,\n// BEFORE the iframe navigates: the app sends `ui/initialize` during\n// module-script execution, before `load`, and postMessage does not queue for\n// late listeners, so attaching only on `load` makes connect() time out\n// (\"Unable to connect to host\"). `connectBridge` is idempotent, so the `load`\n// listener stays as a rare-case fallback. Returns the listener disposer.\nexport function wireCanvasFrameConnect(iframe, targetHref, connectBridge) {\n iframe.addEventListener('load', connectBridge);\n iframe.src = targetHref;\n connectBridge();\n return () => iframe.removeEventListener('load', connectBridge);\n}\n\nexport const MagicCanvas = memo(\n ({ className, onInteraction, onError, onLayoutEffectChange, renderComponent }) => {\n const [instances, setInstances] = useState([]);\n const viewport = useCanvasViewport();\n const onErrorRef = useRef(onError);\n\n useEffect(() => {\n onErrorRef.current = onError;\n }, [onError]);\n\n const reportError = useCallback((event) => {\n onErrorRef.current?.(event);\n }, []);\n\n useObservableEvent(\n useCallback(\n (event) => {\n if (!isCanvasToolCallMessage(event)) return;\n\n try {\n if (event.canvas_config === undefined || event.canvas_config === null) {\n const controlCommand = parseCanvasControlCommand(event);\n if (!controlCommand) return;\n setInstances((current) => applyCanvasCommand(current, controlCommand));\n return;\n }\n\n const canvasConfig = parseCanvasConfig(event.canvas_config);\n if (!canvasConfig) {\n reportError({\n code: 'malformed_canvas_config',\n message: 'Received malformed Magic Canvas config.',\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n return;\n }\n\n const toolCallId = event.properties.tool_call_id;\n if (!toolCallId) {\n reportError({\n code: 'missing_tool_call_id',\n message: 'Received Magic Canvas tool call without tool_call_id.',\n conversation_id: event.conversation_id,\n component: canvasConfig.component,\n });\n return;\n }\n\n const parsedArguments = parseToolArguments(event.properties.arguments);\n if (!parsedArguments.ok) {\n reportError({\n code: 'invalid_tool_arguments',\n message: parsedArguments.error.message,\n conversation_id: event.conversation_id,\n tool_call_id: toolCallId,\n component: canvasConfig.component,\n cause: parsedArguments.error,\n });\n return;\n }\n\n const instance = createCanvasInstance({\n conversationId: event.conversation_id,\n toolCallId,\n args: parsedArguments.value,\n canvasConfig,\n });\n\n setInstances((current) => applyCanvasCommand(current, { kind: 'show', instance }));\n } catch (error) {\n reportError({\n code: 'invalid_tool_arguments',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n }\n },\n [reportError]\n )\n );\n\n const resolvedInstances = instances.map((instance) => ({\n instance,\n layout: resolveCanvasLayout(instance, viewport),\n }));\n const sidecarLayout = resolveCanvasSidecarLayout(\n resolvedInstances.map(({ layout }) => layout),\n viewport\n );\n const fullscreenToolCallId = [...resolvedInstances]\n .reverse()\n .find(({ layout }) => layout.display_mode === 'fullscreen')?.instance.tool_call_id;\n\n useEffect(() => {\n onLayoutEffectChange?.(sidecarLayout);\n }, [\n onLayoutEffectChange,\n sidecarLayout.active,\n sidecarLayout.side,\n sidecarLayout.video_shift_x,\n sidecarLayout.backdrop.type,\n sidecarLayout.safe_area?.x,\n sidecarLayout.safe_area?.y,\n sidecarLayout.safe_area?.width,\n sidecarLayout.safe_area?.height,\n ]);\n\n if (instances.length === 0) return null;\n\n return (\n <div className={[styles.container, className].filter(Boolean).join(' ')}>\n {fullscreenToolCallId && <div className={styles.backdrop} />}\n {CANVAS_LAYOUT_SLOTS.map((slot) => {\n const slotInstances = resolvedInstances.filter(\n ({ layout }) => canvasRenderSlot(layout) === slot\n );\n if (slotInstances.length === 0) return null;\n\n return (\n <div key={slot} className={`${styles.slot} ${slotClassName(slot)}`}>\n {slotInstances.map(({ instance, layout }) => {\n const dimmed = Boolean(\n fullscreenToolCallId && fullscreenToolCallId !== instance.tool_call_id\n );\n const onComplete = () => {\n setInstances((current) => current.filter((item) => item.id !== instance.id));\n };\n\n // Default-off: native renderer only when the registry has an entry\n // for this component@version; otherwise the iframe path, unchanged.\n const renderer = resolveCanvasRenderer(renderComponent, instance);\n if (renderer) {\n return (\n <NativeCanvasHost\n key={instance.id}\n instance={instance}\n layout={layout}\n render={renderer}\n dimmed={dimmed}\n onComplete={onComplete}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n }\n\n return (\n <CanvasFrame\n key={instance.id}\n instance={instance}\n layout={layout}\n dimmed={dimmed}\n onComplete={onComplete}\n onDisplayModeChange={(displayMode) => {\n setInstances((current) =>\n current.map((item) =>\n item.tool_call_id === instance.tool_call_id\n ? { ...item, layout: { ...item.layout, display_mode: displayMode } }\n : item\n )\n );\n }}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n })}\n </div>\n );\n })}\n </div>\n );\n }\n);\n\nMagicCanvas.displayName = 'MagicCanvas';\n\nconst CanvasFrame = memo(\n ({ instance, layout, dimmed, onComplete, onDisplayModeChange, onInteraction, onError }) => {\n const iframeRef = useRef(null);\n const bridgeRef = useRef(null);\n const instanceRef = useRef(instance);\n const layoutRef = useRef(layout);\n const lastSentRevisionRef = useRef(-1);\n const sendAppMessage = useSendAppMessage();\n const onCompleteRef = useRef(onComplete);\n const onDisplayModeChangeRef = useRef(onDisplayModeChange);\n const onInteractionRef = useRef(onInteraction);\n const onErrorRef = useRef(onError);\n const sendAppMessageRef = useRef(sendAppMessage);\n const [frameHeight, setFrameHeight] = useState(320);\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n instanceRef.current = instance;\n }, [instance]);\n\n useEffect(() => {\n layoutRef.current = layout;\n bridgeRef.current?.setHostContext(buildHostContext(instanceRef.current, layout));\n }, [layout]);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n onDisplayModeChangeRef.current = onDisplayModeChange;\n onInteractionRef.current = onInteraction;\n onErrorRef.current = onError;\n sendAppMessageRef.current = sendAppMessage;\n }, [onComplete, onDisplayModeChange, onInteraction, onError, sendAppMessage]);\n\n useEffect(() => {\n const iframe = iframeRef.current;\n if (!iframe) return;\n\n let closed = false;\n let bridge = null;\n let pendingModelContextUpdate = null;\n let modelContextRelayTimer = null;\n // Decision 17 deferred-submit completion, shared with NativeCanvasHost.\n const completionScheduler = createCanvasCompletionScheduler(() => closed);\n const targetHref = new URL(instance.canvas_config.sandbox_url, globalThis.location?.href)\n .href;\n\n const reportError = (event) => {\n if (!closed) onErrorRef.current?.(event);\n };\n\n const flushModelContextUpdate = () => {\n if (closed || !pendingModelContextUpdate) return;\n\n const currentInstance = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.append_llm_context',\n conversation_id: currentInstance.conversation_id,\n properties: {\n context: buildCanvasModelContextAppend(currentInstance, pendingModelContextUpdate),\n },\n });\n pendingModelContextUpdate = null;\n };\n\n const buildFrameError = (code, defaultMessage, cause) => ({\n code,\n message: cause instanceof Error && cause.message ? cause.message : defaultMessage,\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause,\n });\n\n const connectBridge = () => {\n if (bridge || iframe.src !== targetHref) return;\n if (closed || !iframe.contentWindow) return;\n\n const nextBridge = new AppBridge(\n null,\n { name: '@tavus/cvi-ui', version: '0.0.0' },\n {\n logging: {},\n message: {\n text: {},\n structuredContent: {},\n },\n updateModelContext: {\n text: {},\n structuredContent: {},\n },\n },\n {\n hostContext: buildHostContext(instance, layoutRef.current),\n }\n );\n bridge = nextBridge;\n bridgeRef.current = nextBridge;\n\n nextBridge.oninitialized = () => {\n if (closed) return;\n setReady(true);\n lastSentRevisionRef.current = instanceRef.current.revision;\n void nextBridge\n .sendToolInput({ arguments: instanceRef.current.arguments })\n .catch((error) => {\n reportError(\n buildFrameError(\n 'send_tool_input_failed',\n 'Magic Canvas failed to send tool input to the iframe.',\n error\n )\n );\n });\n };\n\n nextBridge.onsizechange = ({ height }) => {\n if (!closed && typeof height === 'number' && Number.isFinite(height)) {\n setFrameHeight(\n Math.min(\n Math.max(height, CANVAS_REPORTED_HEIGHT_FLOOR_PX),\n MAGIC_CANVAS_MAX_HEIGHT_PX\n )\n );\n }\n };\n\n nextBridge.onmessage = async (message) => {\n let interaction = null;\n const interactionPayload = extractCanvasInteraction(message);\n const responseText = extractTextContent(message);\n\n if (interactionPayload) {\n try {\n interaction = normalizeInteraction(instanceRef.current, interactionPayload);\n } catch (error) {\n reportError(\n buildFrameError(\n 'interaction_normalization_failed',\n 'Magic Canvas failed to normalize an interaction.',\n error\n )\n );\n }\n }\n\n if (responseText && !interaction) {\n reportError({\n code: 'missing_interaction_metadata',\n message:\n 'Magic Canvas message included text without interaction metadata; no interaction row was recorded.',\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n });\n return {};\n }\n\n if (interaction) {\n const delivery = await deliverCanvasInteraction({\n interaction,\n canvasConfig: instance.canvas_config,\n onInteraction: onInteractionRef.current,\n reportError,\n buildError: buildFrameError,\n });\n\n // Don't tell the embedded app the interaction succeeded when the\n // POST failed: surface the error instead of continuing to\n // respond/complete, so the card stays visible and the app keeps\n // a retry signal. Mirrors the tavus-deployment host.\n if (!delivery.ok) {\n return {\n isError: true,\n code: delivery.error.code,\n message: delivery.error.message,\n };\n }\n }\n\n if (responseText) {\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.respond',\n conversation_id: instance.conversation_id,\n properties: {\n text: responseText,\n },\n });\n }\n\n // Decision 17: submit defers by CANVAS_SUBMIT_TEARDOWN_DELAY_MS so\n // the embedded app's confirmation stays visible; skip/dismiss/clear\n // complete instantly. Failed deliveries returned above and never\n // reach this.\n if (interaction)\n completionScheduler.complete(interaction, () => onCompleteRef.current?.());\n\n return {};\n };\n\n nextBridge.onupdatemodelcontext = async (modelContextUpdate) => {\n pendingModelContextUpdate = modelContextUpdate;\n if (!modelContextRelayTimer) {\n modelContextRelayTimer = setTimeout(() => {\n modelContextRelayTimer = null;\n flushModelContextUpdate();\n }, MODEL_CONTEXT_RELAY_INTERVAL_MS);\n }\n\n return {};\n };\n\n nextBridge.onrequestdisplaymode = async ({ mode }) => {\n const nextMode = mode === 'fullscreen' ? 'fullscreen' : 'inline';\n onDisplayModeChangeRef.current?.(nextMode);\n layoutRef.current = {\n ...layoutRef.current,\n display_mode: nextMode,\n viable_slot: nextMode === 'fullscreen' ? 'full' : layoutRef.current.viable_slot,\n };\n nextBridge.setHostContext(buildHostContext(instanceRef.current, layoutRef.current));\n return { mode: nextMode };\n };\n\n // See the CanvasFrameTransport class comment for why the SDK's\n // PostMessageTransport can't be used for this pre-navigation connect.\n void nextBridge.connect(new CanvasFrameTransport(iframe)).catch((error) => {\n reportError(\n buildFrameError(\n 'bridge_connect_failed',\n 'Magic Canvas iframe bridge failed to connect.',\n error\n )\n );\n });\n };\n\n const disconnectFrame = wireCanvasFrameConnect(iframe, targetHref, connectBridge);\n\n return () => {\n if (modelContextRelayTimer) {\n clearTimeout(modelContextRelayTimer);\n modelContextRelayTimer = null;\n }\n flushModelContextUpdate();\n closed = true;\n completionScheduler.cancel();\n disconnectFrame();\n bridgeRef.current = null;\n if (bridge) void closeBridge(bridge);\n };\n }, [instance.id, instance.canvas_config.sandbox_url]);\n\n useEffect(() => {\n if (!ready || instance.revision === 0) return;\n if (instance.revision <= lastSentRevisionRef.current) return;\n lastSentRevisionRef.current = instance.revision;\n void bridgeRef.current?.sendToolInput({ arguments: instance.arguments }).catch((error) => {\n onErrorRef.current?.({\n code: 'send_tool_input_failed',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause: error,\n });\n });\n }, [instance, ready]);\n\n const isFull = layout.viable_slot === 'full' || layout.display_mode === 'fullscreen';\n\n return (\n <div\n className={[\n styles.frameShell,\n ready ? styles.frameShellReady : '',\n isFull ? styles.frameShellFull : '',\n dimmed ? styles.frameShellDimmed : '',\n ]\n .filter(Boolean)\n .join(' ')}\n >\n <iframe\n ref={iframeRef}\n title={`${instance.canvas_config.component} ${instance.canvas_config.component_version}`}\n className={styles.frame}\n sandbox={\n COMPONENT_IFRAME_SANDBOX[instance.canvas_config.component] ?? DEFAULT_IFRAME_SANDBOX\n }\n style={{ height: isFull ? '100%' : frameHeight }}\n />\n </div>\n );\n }\n);\n\nCanvasFrame.displayName = 'CanvasFrame';\n\nfunction useCanvasViewport() {\n const [viewport, setViewport] = useState(() => readCanvasViewport());\n\n useEffect(() => {\n const updateViewport = () => setViewport(readCanvasViewport());\n\n window.addEventListener('resize', updateViewport);\n window.visualViewport?.addEventListener('resize', updateViewport);\n\n return () => {\n window.removeEventListener('resize', updateViewport);\n window.visualViewport?.removeEventListener('resize', updateViewport);\n };\n }, []);\n\n return viewport;\n}\n\nfunction readCanvasViewport() {\n return {\n width: window.innerWidth || 1024,\n height: window.innerHeight || 768,\n visualViewportHeight: window.visualViewport?.height,\n };\n}\n\nfunction slotClassName(slot) {\n switch (slot) {\n case 'safe-area-left':\n return styles.slotLeft;\n case 'safe-area-right':\n return styles.slotRight;\n case 'safe-area-bottom':\n return styles.slotBottom;\n case 'full':\n return styles.slotFull;\n }\n}\n\nfunction canvasRenderSlot(layout) {\n if (layout.display_mode === 'fullscreen' && layout.preferred_slot !== 'full') {\n return layout.preferred_slot;\n }\n\n return layout.viable_slot;\n}\n",
632
- styles: ".container {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n}\n\n.backdrop {\n position: absolute;\n inset: 0;\n background: rgba(2, 6, 23, 0.48);\n}\n\n.slot {\n position: absolute;\n display: flex;\n gap: 0.75rem;\n pointer-events: none;\n}\n\n.slotRight,\n.slotLeft {\n top: max(1rem, env(safe-area-inset-top));\n bottom: max(1rem, env(safe-area-inset-bottom));\n width: min(28rem, calc(100vw - 2rem));\n flex-direction: column;\n justify-content: center;\n}\n\n.slotRight {\n right: max(1rem, env(safe-area-inset-right));\n}\n\n.slotLeft {\n left: max(1rem, env(safe-area-inset-left));\n}\n\n.slotBottom {\n right: max(1rem, env(safe-area-inset-right));\n bottom: max(1rem, env(safe-area-inset-bottom));\n left: max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: center;\n justify-content: flex-end;\n}\n\n.slotFull {\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: stretch;\n justify-content: stretch;\n}\n\n.frameShell {\n width: 100%;\n /* Degenerate-value guard only — the host trusts app-reported heights, so\n short components (e.g. a 182px input) are not padded to a fixed floor. */\n min-height: 3rem;\n overflow: hidden;\n border: 1px solid rgba(15, 23, 42, 0.14);\n border-radius: 8px;\n background: #ffffff;\n box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);\n opacity: 0.96;\n pointer-events: auto;\n}\n\n.frameShellReady {\n opacity: 1;\n}\n\n.frameShellFull {\n position: fixed;\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n z-index: 1;\n min-height: 0;\n}\n\n.frameShellDimmed {\n opacity: 0.4;\n transform: scale(0.98);\n}\n\n.frame {\n display: block;\n width: 100%;\n min-height: 3rem;\n border: 0;\n background: transparent;\n}\n\n.frameShellFull .frame {\n height: 100%;\n min-height: 0;\n}\n",
631
+ content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';\nimport { useObservableEvent, useSendAppMessage } from '../../hooks/cvi-events-hooks';\n\nimport styles from './magic-canvas.module.css';\nimport { closeBridge } from './bridge';\nimport {\n applyCanvasCommand,\n buildCanvasModelContextAppend,\n buildHostContext,\n CANVAS_LAYOUT_SLOTS,\n createCanvasInstance,\n extractCanvasInteraction,\n extractTextContent,\n isCanvasToolCallMessage,\n MAGIC_CANVAS_MAX_HEIGHT_PX,\n MIN_CANVAS_DISPLAY_SCALE,\n normalizeInteraction,\n parseCanvasConfig,\n parseCanvasControlCommand,\n parseToolArguments,\n resolveCanvasLayout,\n resolveCanvasSidecarLayout,\n} from './runtime';\n\nimport {\n createCanvasCompletionScheduler,\n deliverCanvasInteraction,\n NativeCanvasHost,\n resolveCanvasRenderer,\n} from './native-host';\n\nexport { canvasRendererKey, NativeCanvasHost, resolveCanvasRenderer } from './native-host';\n\nconst MODEL_CONTEXT_RELAY_INTERVAL_MS = 1000;\n\n// Floor for app-REPORTED heights. The runtime's MAGIC_CANVAS_MIN_HEIGHT_PX\n// (240) is a layout default, not a floor for real reports — flooring there\n// padded short components (e.g. a 182px input card) with dead space below\n// the content. Real reports are trusted; this only guards degenerate values\n// from a mid-load ResizeObserver tick. Mirrors the tavus-deployment host.\nconst CANVAS_REPORTED_HEIGHT_FLOOR_PX = 48;\n\n// Per-component iframe sandbox policy, owned by the host (not server-supplied).\n// Default is locked-down allow-scripts; Calendly's scheduling_embed needs its\n// own origin, popups, and form submission to complete a booking.\nconst DEFAULT_IFRAME_SANDBOX = 'allow-scripts';\nconst COMPONENT_IFRAME_SANDBOX = {\n 'canvas.scheduling_embed': 'allow-scripts allow-same-origin allow-popups allow-forms',\n};\n\n// Trust a bridge message only when it comes from THIS iframe's LIVE\n// contentWindow (read at message time, not captured earlier) and is JSON-RPC\n// shaped: accepts the component's post-navigation messages while rejecting\n// forged JSON-RPC from other frames and cross-talk between sibling canvases.\nexport function isTrustedCanvasFrameMessage(event, iframe) {\n if (!iframe.contentWindow || event.source !== iframe.contentWindow) return false;\n const data = event.data;\n return Boolean(data) && typeof data === 'object' && data?.jsonrpc === '2.0';\n}\n\n// Host-side JSON-RPC transport for the component iframe. We connect\n// SYNCHRONOUSLY, before the iframe navigates, to catch the app's\n// `ui/initialize` handshake (fires during module-script execution, before\n// `load`). The SDK's PostMessageTransport can't do this: it compares\n// `event.source` against a window captured at construct time, the stale\n// pre-navigation about:blank window, and would drop the handshake. This\n// transport validates against the LIVE `iframe.contentWindow` at receive\n// time. Mirrors the tavus-deployment host.\nexport class CanvasFrameTransport {\n onmessage;\n onerror;\n onclose;\n\n #iframe;\n // `| undefined` (not the `?` optional marker): the TS->JS template\n // converter strips types but keeps the optional marker on private class\n // fields, which would emit invalid JS (`#listener?;`).\n #listener = undefined;\n\n constructor(iframe) {\n this.#iframe = iframe;\n }\n\n start() {\n const listener = (event) => {\n if (!isTrustedCanvasFrameMessage(event, this.#iframe)) return;\n this.onmessage?.(event.data);\n };\n this.#listener = listener;\n globalThis.addEventListener('message', listener);\n return Promise.resolve();\n }\n\n send(message) {\n // Same `\"*\"` target origin the SDK transport uses; the receiver validates\n // source rather than relying on a target-origin match (the sandboxed app\n // has an opaque origin).\n this.#iframe.contentWindow?.postMessage(message, '*');\n return Promise.resolve();\n }\n\n close() {\n if (this.#listener) globalThis.removeEventListener('message', this.#listener);\n this.#listener = undefined;\n this.onclose?.();\n return Promise.resolve();\n }\n}\n\n// Navigate the iframe and wire the bridge connect. Connect synchronously,\n// BEFORE the iframe navigates: the app sends `ui/initialize` during\n// module-script execution, before `load`, and postMessage does not queue for\n// late listeners, so attaching only on `load` makes connect() time out\n// (\"Unable to connect to host\"). `connectBridge` is idempotent, so the `load`\n// listener stays as a rare-case fallback. Returns the listener disposer.\nexport function wireCanvasFrameConnect(iframe, targetHref, connectBridge) {\n iframe.addEventListener('load', connectBridge);\n iframe.src = targetHref;\n connectBridge();\n return () => iframe.removeEventListener('load', connectBridge);\n}\n\nexport const MagicCanvas = memo(\n ({ className, onInteraction, onError, onLayoutEffectChange, renderComponent }) => {\n const [instances, setInstances] = useState([]);\n const viewport = useCanvasViewport();\n const onErrorRef = useRef(onError);\n\n useEffect(() => {\n onErrorRef.current = onError;\n }, [onError]);\n\n const reportError = useCallback((event) => {\n onErrorRef.current?.(event);\n }, []);\n\n useObservableEvent(\n useCallback(\n (event) => {\n if (!isCanvasToolCallMessage(event)) return;\n\n try {\n if (event.canvas_config === undefined || event.canvas_config === null) {\n const controlCommand = parseCanvasControlCommand(event);\n if (!controlCommand) return;\n setInstances((current) => applyCanvasCommand(current, controlCommand));\n return;\n }\n\n const canvasConfig = parseCanvasConfig(event.canvas_config);\n if (!canvasConfig) {\n reportError({\n code: 'malformed_canvas_config',\n message: 'Received malformed Magic Canvas config.',\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n return;\n }\n\n const toolCallId = event.properties.tool_call_id;\n if (!toolCallId) {\n reportError({\n code: 'missing_tool_call_id',\n message: 'Received Magic Canvas tool call without tool_call_id.',\n conversation_id: event.conversation_id,\n component: canvasConfig.component,\n });\n return;\n }\n\n const parsedArguments = parseToolArguments(event.properties.arguments);\n if (!parsedArguments.ok) {\n reportError({\n code: 'invalid_tool_arguments',\n message: parsedArguments.error.message,\n conversation_id: event.conversation_id,\n tool_call_id: toolCallId,\n component: canvasConfig.component,\n cause: parsedArguments.error,\n });\n return;\n }\n\n const instance = createCanvasInstance({\n conversationId: event.conversation_id,\n toolCallId,\n args: parsedArguments.value,\n canvasConfig,\n });\n\n setInstances((current) => applyCanvasCommand(current, { kind: 'show', instance }));\n } catch (error) {\n reportError({\n code: 'invalid_tool_arguments',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: event.conversation_id,\n tool_call_id: event.properties.tool_call_id,\n });\n }\n },\n [reportError]\n )\n );\n\n const resolvedInstances = instances.map((instance) => ({\n instance,\n layout: resolveCanvasLayout(instance, viewport),\n }));\n const sidecarLayout = resolveCanvasSidecarLayout(\n resolvedInstances.map(({ layout }) => layout),\n viewport\n );\n const fullscreenToolCallId = [...resolvedInstances]\n .reverse()\n .find(({ layout }) => layout.display_mode === 'fullscreen')?.instance.tool_call_id;\n\n useEffect(() => {\n onLayoutEffectChange?.(sidecarLayout);\n }, [\n onLayoutEffectChange,\n sidecarLayout.active,\n sidecarLayout.side,\n sidecarLayout.video_shift_x,\n sidecarLayout.backdrop.type,\n sidecarLayout.safe_area?.x,\n sidecarLayout.safe_area?.y,\n sidecarLayout.safe_area?.width,\n sidecarLayout.safe_area?.height,\n ]);\n\n if (instances.length === 0) return null;\n\n return (\n <div className={[styles.container, className].filter(Boolean).join(' ')}>\n {fullscreenToolCallId && <div className={styles.backdrop} />}\n {CANVAS_LAYOUT_SLOTS.map((slot) => {\n const slotInstances = resolvedInstances.filter(\n ({ layout }) => canvasRenderSlot(layout) === slot\n );\n if (slotInstances.length === 0) return null;\n\n return (\n <CanvasSlot key={slot} slot={slot}>\n {(availableHeight) =>\n slotInstances.map(({ instance, layout }) => {\n const dimmed = Boolean(\n fullscreenToolCallId && fullscreenToolCallId !== instance.tool_call_id\n );\n const onComplete = () => {\n setInstances((current) => current.filter((item) => item.id !== instance.id));\n };\n\n // Default-off: native renderer only when the registry has an entry\n // for this component@version; otherwise the iframe path, unchanged.\n const renderer = resolveCanvasRenderer(renderComponent, instance);\n if (renderer) {\n return (\n <NativeCanvasHost\n key={instance.id}\n instance={instance}\n layout={layout}\n render={renderer}\n dimmed={dimmed}\n onComplete={onComplete}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n }\n\n return (\n <CanvasFrame\n key={instance.id}\n availableHeight={availableHeight}\n instance={instance}\n layout={layout}\n dimmed={dimmed}\n onComplete={onComplete}\n onDisplayModeChange={(displayMode) => {\n setInstances((current) =>\n current.map((item) =>\n item.tool_call_id === instance.tool_call_id\n ? { ...item, layout: { ...item.layout, display_mode: displayMode } }\n : item\n )\n );\n }}\n onError={onError}\n onInteraction={onInteraction}\n />\n );\n })\n }\n </CanvasSlot>\n );\n })}\n </div>\n );\n }\n);\n\nMagicCanvas.displayName = 'MagicCanvas';\n\nconst CanvasFrame = memo(\n ({\n availableHeight,\n instance,\n layout,\n dimmed,\n onComplete,\n onDisplayModeChange,\n onInteraction,\n onError,\n }) => {\n const iframeRef = useRef(null);\n const bridgeRef = useRef(null);\n const instanceRef = useRef(instance);\n const layoutRef = useRef(layout);\n const lastSentRevisionRef = useRef(-1);\n const sendAppMessage = useSendAppMessage();\n const onCompleteRef = useRef(onComplete);\n const onDisplayModeChangeRef = useRef(onDisplayModeChange);\n const onInteractionRef = useRef(onInteraction);\n const onErrorRef = useRef(onError);\n const sendAppMessageRef = useRef(sendAppMessage);\n const [frameHeight, setFrameHeight] = useState(320);\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n instanceRef.current = instance;\n }, [instance]);\n\n useEffect(() => {\n layoutRef.current = layout;\n bridgeRef.current?.setHostContext(buildHostContext(instanceRef.current, layout));\n }, [layout]);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n onDisplayModeChangeRef.current = onDisplayModeChange;\n onInteractionRef.current = onInteraction;\n onErrorRef.current = onError;\n sendAppMessageRef.current = sendAppMessage;\n }, [onComplete, onDisplayModeChange, onInteraction, onError, sendAppMessage]);\n\n useEffect(() => {\n const iframe = iframeRef.current;\n if (!iframe) return;\n\n let closed = false;\n let bridge = null;\n let pendingModelContextUpdate = null;\n let modelContextRelayTimer = null;\n // Decision 17 deferred-submit completion, shared with NativeCanvasHost.\n const completionScheduler = createCanvasCompletionScheduler(() => closed);\n const targetHref = new URL(instance.canvas_config.sandbox_url, globalThis.location?.href)\n .href;\n\n const reportError = (event) => {\n if (!closed) onErrorRef.current?.(event);\n };\n\n const flushModelContextUpdate = () => {\n if (closed || !pendingModelContextUpdate) return;\n\n const currentInstance = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.append_llm_context',\n conversation_id: currentInstance.conversation_id,\n properties: {\n context: buildCanvasModelContextAppend(currentInstance, pendingModelContextUpdate),\n },\n });\n pendingModelContextUpdate = null;\n };\n\n const buildFrameError = (code, defaultMessage, cause) => ({\n code,\n message: cause instanceof Error && cause.message ? cause.message : defaultMessage,\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause,\n });\n\n const connectBridge = () => {\n if (bridge || iframe.src !== targetHref) return;\n if (closed || !iframe.contentWindow) return;\n\n const nextBridge = new AppBridge(\n null,\n { name: '@tavus/cvi-ui', version: '0.0.0' },\n {\n logging: {},\n message: {\n text: {},\n structuredContent: {},\n },\n updateModelContext: {\n text: {},\n structuredContent: {},\n },\n },\n {\n hostContext: buildHostContext(instance, layoutRef.current),\n }\n );\n bridge = nextBridge;\n bridgeRef.current = nextBridge;\n\n nextBridge.oninitialized = () => {\n if (closed) return;\n setReady(true);\n lastSentRevisionRef.current = instanceRef.current.revision;\n void nextBridge\n .sendToolInput({ arguments: instanceRef.current.arguments })\n .catch((error) => {\n reportError(\n buildFrameError(\n 'send_tool_input_failed',\n 'Magic Canvas failed to send tool input to the iframe.',\n error\n )\n );\n });\n };\n\n nextBridge.onsizechange = ({ height }) => {\n if (!closed && typeof height === 'number' && Number.isFinite(height)) {\n setFrameHeight(\n Math.min(\n Math.max(height, CANVAS_REPORTED_HEIGHT_FLOOR_PX),\n MAGIC_CANVAS_MAX_HEIGHT_PX\n )\n );\n }\n };\n\n nextBridge.onmessage = async (message) => {\n let interaction = null;\n const interactionPayload = extractCanvasInteraction(message);\n const responseText = extractTextContent(message);\n\n if (interactionPayload) {\n try {\n interaction = normalizeInteraction(instanceRef.current, interactionPayload);\n } catch (error) {\n reportError(\n buildFrameError(\n 'interaction_normalization_failed',\n 'Magic Canvas failed to normalize an interaction.',\n error\n )\n );\n }\n }\n\n if (responseText && !interaction) {\n reportError({\n code: 'missing_interaction_metadata',\n message:\n 'Magic Canvas message included text without interaction metadata; no interaction row was recorded.',\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n });\n return {};\n }\n\n if (interaction) {\n const delivery = await deliverCanvasInteraction({\n interaction,\n canvasConfig: instance.canvas_config,\n onInteraction: onInteractionRef.current,\n reportError,\n buildError: buildFrameError,\n });\n\n // Don't tell the embedded app the interaction succeeded when the\n // POST failed: surface the error instead of continuing to\n // respond/complete, so the card stays visible and the app keeps\n // a retry signal. Mirrors the tavus-deployment host.\n if (!delivery.ok) {\n return {\n isError: true,\n code: delivery.error.code,\n message: delivery.error.message,\n };\n }\n }\n\n if (responseText) {\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.respond',\n conversation_id: instance.conversation_id,\n properties: {\n text: responseText,\n },\n });\n }\n\n // Decision 17: submit defers by CANVAS_SUBMIT_TEARDOWN_DELAY_MS so\n // the embedded app's confirmation stays visible; skip/dismiss/clear\n // complete instantly. Failed deliveries returned above and never\n // reach this.\n if (interaction)\n completionScheduler.complete(interaction, () => onCompleteRef.current?.());\n\n return {};\n };\n\n nextBridge.onupdatemodelcontext = async (modelContextUpdate) => {\n pendingModelContextUpdate = modelContextUpdate;\n if (!modelContextRelayTimer) {\n modelContextRelayTimer = setTimeout(() => {\n modelContextRelayTimer = null;\n flushModelContextUpdate();\n }, MODEL_CONTEXT_RELAY_INTERVAL_MS);\n }\n\n return {};\n };\n\n nextBridge.onrequestdisplaymode = async ({ mode }) => {\n const nextMode = mode === 'fullscreen' ? 'fullscreen' : 'inline';\n onDisplayModeChangeRef.current?.(nextMode);\n layoutRef.current = {\n ...layoutRef.current,\n display_mode: nextMode,\n viable_slot: nextMode === 'fullscreen' ? 'full' : layoutRef.current.viable_slot,\n };\n nextBridge.setHostContext(buildHostContext(instanceRef.current, layoutRef.current));\n return { mode: nextMode };\n };\n\n // See the CanvasFrameTransport class comment for why the SDK's\n // PostMessageTransport can't be used for this pre-navigation connect.\n void nextBridge.connect(new CanvasFrameTransport(iframe)).catch((error) => {\n reportError(\n buildFrameError(\n 'bridge_connect_failed',\n 'Magic Canvas iframe bridge failed to connect.',\n error\n )\n );\n });\n };\n\n const disconnectFrame = wireCanvasFrameConnect(iframe, targetHref, connectBridge);\n\n return () => {\n if (modelContextRelayTimer) {\n clearTimeout(modelContextRelayTimer);\n modelContextRelayTimer = null;\n }\n flushModelContextUpdate();\n closed = true;\n completionScheduler.cancel();\n disconnectFrame();\n bridgeRef.current = null;\n if (bridge) void closeBridge(bridge);\n };\n }, [instance.id, instance.canvas_config.sandbox_url]);\n\n useEffect(() => {\n if (!ready || instance.revision === 0) return;\n if (instance.revision <= lastSentRevisionRef.current) return;\n lastSentRevisionRef.current = instance.revision;\n void bridgeRef.current?.sendToolInput({ arguments: instance.arguments }).catch((error) => {\n onErrorRef.current?.({\n code: 'send_tool_input_failed',\n message: error instanceof Error ? error.message : String(error),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: instance.canvas_config.component,\n cause: error,\n });\n });\n }, [instance, ready]);\n\n const isFull = layout.viable_slot === 'full' || layout.display_mode === 'fullscreen';\n const inlineFit = isFull ? null : resolveInlineFrameFit(frameHeight, availableHeight);\n const placeholderStyle = inlineFit?.placeholderStyle;\n const cardStyle = inlineFit?.cardStyle;\n const iframeStyle = isFull\n ? { height: '100%' }\n : (inlineFit?.iframeStyle ?? { height: frameHeight });\n\n const card = (\n <div\n className={[\n styles.frameShell,\n ready ? styles.frameShellReady : '',\n isFull ? styles.frameShellFull : '',\n dimmed ? styles.frameShellDimmed : '',\n ]\n .filter(Boolean)\n .join(' ')}\n style={cardStyle}\n data-canvas-card={layout.viable_slot}\n >\n <iframe\n ref={iframeRef}\n title={`${instance.canvas_config.component} ${instance.canvas_config.component_version}`}\n className={styles.frame}\n sandbox={\n COMPONENT_IFRAME_SANDBOX[instance.canvas_config.component] ?? DEFAULT_IFRAME_SANDBOX\n }\n style={iframeStyle}\n />\n </div>\n );\n\n // Full-screen cards own the viewport; otherwise wrap in a placeholder that\n // reserves the (possibly scaled) layout height so a scaled card does not\n // overlap siblings. Mirrors the tavus-deployment host.\n if (isFull) return card;\n\n return (\n <div className={styles.framePlaceholder} style={placeholderStyle}>\n {card}\n </div>\n );\n }\n);\n\nCanvasFrame.displayName = 'CanvasFrame';\n\n// Wraps a layout slot, measuring its available height so the frame inside can\n// scale-to-fit (and fall back to scroll) when the content is taller than the\n// band. Side slots scroll (overflow-y) and center their card with an inner\n// `my-auto`-style stack that collapses when the content overflows, so a too-tall\n// card scrolls from the top instead of being clipped by the slot.\nfunction CanvasSlot({ slot, children }) {\n const [ref, availableHeight] = useCanvasSlotHeight();\n const isSide = slot === 'safe-area-left' || slot === 'safe-area-right';\n const content = children(slot === 'full' ? undefined : availableHeight);\n\n return (\n <div ref={ref} className={`${styles.slot} ${slotClassName(slot)}`} data-canvas-slot={slot}>\n {isSide ? <div className={styles.sideStack}>{content}</div> : content}\n </div>\n );\n}\n\nfunction useCanvasSlotHeight() {\n const ref = useRef(null);\n const [height, setHeight] = useState();\n\n useEffect(() => {\n const element = ref.current;\n if (!element) return;\n\n const setMeasuredHeight = (nextHeight) => {\n const measuredHeight = nextHeight ?? element.getBoundingClientRect().height;\n if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) return;\n setHeight(Math.floor(measuredHeight));\n };\n const updateFromLayout = () => setMeasuredHeight();\n\n updateFromLayout();\n\n if (typeof ResizeObserver === 'undefined') {\n globalThis.addEventListener('resize', updateFromLayout);\n return () => globalThis.removeEventListener('resize', updateFromLayout);\n }\n\n const observer = new ResizeObserver((entries) => {\n setMeasuredHeight(entries[0]?.contentRect.height);\n });\n observer.observe(element);\n\n return () => observer.disconnect();\n }, []);\n\n return [ref, height];\n}\n\n// Fit the app-reported (capped) card height into the slot's available height:\n// render natural when it fits, scale the card down to MIN_CANVAS_DISPLAY_SCALE\n// when slightly too tall, and fall back to a band-height iframe (native scroll)\n// when it is too tall even scaled. The iframe width MUST stay 100% in every\n// branch — feeding the app a per-scale viewport can trigger a width-breakpoint\n// feedback loop. Mirrors resolveInlineFrameFit in the tavus-deployment host.\nfunction resolveInlineFrameFit(naturalHeight, availableHeight) {\n if (availableHeight === undefined || !Number.isFinite(availableHeight) || availableHeight <= 0) {\n return null;\n }\n\n const clampedNatural = Math.min(naturalHeight, MAGIC_CANVAS_MAX_HEIGHT_PX);\n\n if (clampedNatural <= availableHeight) {\n return {\n displayScale: 1,\n placeholderStyle: { maxHeight: `${availableHeight}px` },\n cardStyle: undefined,\n iframeStyle: { width: '100%', height: `${clampedNatural}px` },\n };\n }\n\n const ratio = availableHeight / clampedNatural;\n\n if (ratio >= MIN_CANVAS_DISPLAY_SCALE) {\n const scaledHeight = clampedNatural * ratio;\n return {\n displayScale: ratio,\n placeholderStyle: {\n height: `${scaledHeight}px`,\n maxHeight: `${availableHeight}px`,\n overflow: 'hidden',\n },\n cardStyle: {\n height: `${clampedNatural}px`,\n transform: `scale(${ratio})`,\n transformOrigin: 'top center',\n },\n iframeStyle: { width: '100%', height: `${clampedNatural}px` },\n };\n }\n\n return {\n displayScale: 1,\n placeholderStyle: { maxHeight: `${availableHeight}px` },\n cardStyle: undefined,\n iframeStyle: { width: '100%', height: `${availableHeight}px` },\n };\n}\n\nfunction useCanvasViewport() {\n const [viewport, setViewport] = useState(() => readCanvasViewport());\n\n useEffect(() => {\n const updateViewport = () => setViewport(readCanvasViewport());\n\n window.addEventListener('resize', updateViewport);\n window.visualViewport?.addEventListener('resize', updateViewport);\n\n return () => {\n window.removeEventListener('resize', updateViewport);\n window.visualViewport?.removeEventListener('resize', updateViewport);\n };\n }, []);\n\n return viewport;\n}\n\nfunction readCanvasViewport() {\n return {\n width: window.innerWidth || 1024,\n height: window.innerHeight || 768,\n visualViewportHeight: window.visualViewport?.height,\n };\n}\n\nfunction slotClassName(slot) {\n switch (slot) {\n case 'safe-area-left':\n return styles.slotLeft;\n case 'safe-area-right':\n return styles.slotRight;\n case 'safe-area-bottom':\n return styles.slotBottom;\n case 'full':\n return styles.slotFull;\n }\n}\n\nfunction canvasRenderSlot(layout) {\n if (layout.display_mode === 'fullscreen' && layout.preferred_slot !== 'full') {\n return layout.preferred_slot;\n }\n\n return layout.viable_slot;\n}\n",
632
+ styles: ".container {\n position: fixed;\n inset: 0;\n z-index: 2147483000;\n pointer-events: none;\n}\n\n.backdrop {\n position: absolute;\n inset: 0;\n background: rgba(2, 6, 23, 0.48);\n}\n\n.slot {\n position: absolute;\n display: flex;\n gap: 0.75rem;\n pointer-events: none;\n}\n\n.slotRight,\n.slotLeft {\n top: max(1rem, env(safe-area-inset-top));\n bottom: max(1rem, env(safe-area-inset-bottom));\n width: min(28rem, calc(100vw - 2rem));\n flex-direction: column;\n /* Top-anchored + scrollable: a card taller than the band scrolls instead of\n being clipped. Centering moves to the inner .sideStack (margin-block: auto)\n so a card that fits still centers but collapses when it overflows, letting\n the slot scroll from the top. A justify-content: center slot would clip. */\n overflow-y: auto;\n}\n\n.sideStack {\n display: flex;\n width: 100%;\n flex-direction: column;\n gap: 0.75rem;\n margin-block: auto;\n}\n\n.slotRight {\n right: max(1rem, env(safe-area-inset-right));\n}\n\n.slotLeft {\n left: max(1rem, env(safe-area-inset-left));\n}\n\n.slotBottom {\n right: max(1rem, env(safe-area-inset-right));\n bottom: max(1rem, env(safe-area-inset-bottom));\n left: max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: center;\n justify-content: flex-end;\n}\n\n.slotFull {\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n flex-direction: column;\n align-items: stretch;\n justify-content: stretch;\n}\n\n/* Reserves the (possibly scaled) layout height for an inline card so a\n scaled-down card does not overlap its siblings. */\n.framePlaceholder {\n display: block;\n width: 100%;\n}\n\n.frameShell {\n width: 100%;\n /* Degenerate-value guard only — the host trusts app-reported heights, so\n short components (e.g. a 182px input) are not padded to a fixed floor. */\n min-height: 3rem;\n overflow: hidden;\n border: 1px solid rgba(15, 23, 42, 0.14);\n border-radius: 8px;\n background: #ffffff;\n box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);\n opacity: 0.96;\n pointer-events: auto;\n}\n\n.frameShellReady {\n opacity: 1;\n}\n\n.frameShellFull {\n position: fixed;\n inset: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))\n max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));\n z-index: 1;\n min-height: 0;\n}\n\n.frameShellDimmed {\n opacity: 0.4;\n transform: scale(0.98);\n}\n\n.frame {\n display: block;\n width: 100%;\n min-height: 3rem;\n border: 0;\n background: transparent;\n}\n\n.frameShellFull .frame {\n height: 100%;\n min-height: 0;\n}\n",
633
633
  extraFiles: [
634
634
  {
635
635
  "path": "allowlists.js",
@@ -637,7 +637,7 @@ var magic_canvas_default = {
637
637
  },
638
638
  {
639
639
  "path": "bridge.js",
640
- "content": "// Interaction POST + AppBridge teardown helpers. Network and SDK-shutdown\n// concerns live here so the React surface in `index.tsx` can stay focused on\n// lifecycle wiring.\n\nconst TAVUS_API_BASE_URL = 'https://tavusapi.com';\nconst CANVAS_INTERACTION_TIMEOUT_MS = 5000;\n\nexport async function postInteraction(event, canvasConfig) {\n const endpoint = getInteractionEndpoint(event, canvasConfig);\n const response = await fetchWithTimeout(endpoint, {\n method: 'POST',\n redirect: 'error',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n interaction_id: event.interaction_id,\n tool_call_id: event.tool_call_id,\n component: event.component,\n component_version: event.component_version,\n type: event.type,\n value: event.value,\n metadata: event.metadata,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Magic Canvas interaction POST failed with ${response.status}.`);\n }\n}\n\nexport async function closeBridge(bridge) {\n try {\n await bridge.teardownResource({}, { timeout: 1000 });\n } catch {\n // The app may not acknowledge teardown; the bridge transport is closed below either way.\n }\n try {\n await bridge.close();\n } catch {\n // Transport may already be torn down by the iframe unmount; ignore.\n }\n}\n\nfunction getInteractionEndpoint(event, canvasConfig) {\n if (canvasConfig.interaction_url) {\n return canvasConfig.interaction_url.replace(\n '{conversation_id}',\n encodeURIComponent(event.conversation_id)\n );\n }\n\n const apiBaseUrl = (canvasConfig.api_base_url ?? TAVUS_API_BASE_URL).replace(/\\/$/, '');\n return `${apiBaseUrl}/v2/conversations/${encodeURIComponent(event.conversation_id)}/canvas/interactions`;\n}\n\nasync function fetchWithTimeout(input, init) {\n const controller = new AbortController();\n const timeout = globalThis.setTimeout(() => controller.abort(), CANVAS_INTERACTION_TIMEOUT_MS);\n\n try {\n return await fetch(input, { ...init, signal: controller.signal });\n } finally {\n globalThis.clearTimeout(timeout);\n }\n}\n"
640
+ "content": "// Interaction POST + AppBridge teardown helpers. Network and SDK-shutdown\n// concerns live here so the React surface in `index.tsx` can stay focused on\n// lifecycle wiring.\n\n/**\n * The minimal AppBridge surface `closeBridge` needs, declared locally rather\n * than imported from `@modelcontextprotocol/ext-apps`. This keeps the package\n * free of any ext-apps type dependency, so it resolves under any consumer's\n * module resolution — a shipped global `declare module` augmentation does not\n * reliably merge from node_modules under a consumer's `nodenext`. Consumers\n * pass their real `AppBridge`, which structurally satisfies this. `close` is\n * optional because the published ext-apps types historically omit it even\n * though the transport implements it at runtime.\n */\n\nconst TAVUS_API_BASE_URL = 'https://tavusapi.com';\nconst CANVAS_INTERACTION_TIMEOUT_MS = 5000;\n\nexport async function postInteraction(event, canvasConfig) {\n const endpoint = getInteractionEndpoint(event, canvasConfig);\n const response = await fetchWithTimeout(endpoint, {\n method: 'POST',\n redirect: 'error',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n interaction_id: event.interaction_id,\n tool_call_id: event.tool_call_id,\n component: event.component,\n component_version: event.component_version,\n type: event.type,\n value: event.value,\n metadata: event.metadata,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Magic Canvas interaction POST failed with ${response.status}.`);\n }\n}\n\nexport async function closeBridge(bridge) {\n try {\n await bridge.teardownResource({}, { timeout: 1000 });\n } catch {\n // The app may not acknowledge teardown; the bridge transport is closed below either way.\n }\n try {\n await bridge.close?.();\n } catch {\n // Transport may already be torn down by the iframe unmount; ignore.\n }\n}\n\nfunction getInteractionEndpoint(event, canvasConfig) {\n if (canvasConfig.interaction_url) {\n return canvasConfig.interaction_url.replace(\n '{conversation_id}',\n encodeURIComponent(event.conversation_id)\n );\n }\n\n const apiBaseUrl = (canvasConfig.api_base_url ?? TAVUS_API_BASE_URL).replace(/\\/$/, '');\n return `${apiBaseUrl}/v2/conversations/${encodeURIComponent(event.conversation_id)}/canvas/interactions`;\n}\n\nasync function fetchWithTimeout(input, init) {\n const controller = new AbortController();\n const timeout = globalThis.setTimeout(() => controller.abort(), CANVAS_INTERACTION_TIMEOUT_MS);\n\n try {\n return await fetch(input, { ...init, signal: controller.signal });\n } finally {\n globalThis.clearTimeout(timeout);\n }\n}\n"
641
641
  },
642
642
  {
643
643
  "path": "native-host.jsx",
@@ -645,7 +645,7 @@ var magic_canvas_default = {
645
645
  },
646
646
  {
647
647
  "path": "runtime.js",
648
- "content": "// Magic Canvas runtime: pure parsing, validation, normalization, and the\n// constants the React surface and bridge code both need. Nothing in this file\n// touches React, fetch, or the AppBridge so it stays trivially testable and\n// portable across host runtimes.\n\nimport {\n isAllowedCanvasApiUrl,\n isAllowedCanvasMcpUrl,\n isAllowedCanvasSandboxUrl,\n} from './allowlists.js';\n\nexport const CANVAS_INTERACTION_META_KEY = 'tavus.canvas.interaction';\nexport const SUPPORTED_CANVAS_CONFIG_VERSION = 1;\nexport const MAGIC_CANVAS_MIN_HEIGHT_PX = 240;\nexport const MAGIC_CANVAS_MAX_HEIGHT_PX = 720;\nexport const MIN_CANVAS_DISPLAY_SCALE = 0.85;\nexport const MAX_CANVAS_INSTANCES = 3;\nexport const CANVAS_CLEAR_TOOL_NAME = 'canvas_clear';\nexport const CANVAS_UPDATE_TOOL_NAME = 'update_component';\n\nexport const CANVAS_LAYOUT_SLOTS = [\n 'safe-area-right',\n 'safe-area-left',\n 'safe-area-bottom',\n 'full',\n];\n\nexport const DEFAULT_CANVAS_LAYOUT_SLOT = 'safe-area-right';\nexport const DEFAULT_CANVAS_SAFE_AREA = {\n x: 275 / 1280,\n y: 111 / 720,\n width: 730 / 1280,\n height: 609 / 720,\n};\nexport const DEFAULT_CANVAS_BACKDROP = { type: 'snapshot_mirror' };\nexport const MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX = 448;\nexport const MAGIC_CANVAS_SIDE_PANEL_INSET_PX = 16;\nexport const MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX = 24;\nexport const MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX = 900;\n\nexport function isCanvasToolCallMessage(value) {\n if (!isRecord(value)) return false;\n\n return (\n value.message_type === 'conversation' &&\n value.event_type === 'conversation.tool_call' &&\n typeof value.conversation_id === 'string' &&\n isRecord(value.properties)\n );\n}\n\nexport function parseCanvasConfig(value) {\n if (!isRecord(value)) return null;\n\n const component = readString(value, 'component');\n const componentVersion = readString(value, 'component_version');\n const sandboxUrl = readString(value, 'sandbox_url');\n const mcpServerUrl = readOptionalAllowedUrl(value, 'mcp_server_url', isAllowedCanvasMcpUrl);\n const apiBaseUrl = readOptionalAllowedUrl(value, 'api_base_url', isAllowedCanvasApiUrl);\n const interactionUrl = readOptionalAllowedUrl(value, 'interaction_url', isAllowedCanvasApiUrl);\n const layout = parseCanvasLayoutConfig(value.layout);\n const version = value.version;\n\n if (version !== SUPPORTED_CANVAS_CONFIG_VERSION) return null;\n if (!component || !componentVersion || !sandboxUrl) return null;\n if (!isAllowedCanvasSandboxUrl(sandboxUrl)) return null;\n if (mcpServerUrl === null || apiBaseUrl === null || interactionUrl === null) return null;\n if (layout === null) return null;\n\n return {\n version,\n component,\n component_version: componentVersion,\n resource_uri: readString(value, 'resource_uri'),\n sandbox_url: sandboxUrl,\n mcp_server_url: mcpServerUrl,\n mcp_tool_name: readString(value, 'mcp_tool_name'),\n api_base_url: apiBaseUrl,\n interaction_url: interactionUrl,\n layout,\n host_context: isRecord(value.host_context) ? value.host_context : undefined,\n };\n}\n\nexport function parseToolArguments(value) {\n if (value === undefined) return { ok: true, value: {} };\n if (isRecord(value)) return { ok: true, value };\n\n if (typeof value !== 'string') {\n return {\n ok: false,\n error: new Error('Magic Canvas tool arguments must be an object or JSON string.'),\n };\n }\n\n try {\n const parsed = JSON.parse(value);\n\n if (!isRecord(parsed)) {\n return {\n ok: false,\n error: new Error('Magic Canvas tool arguments must decode to an object.'),\n };\n }\n\n return { ok: true, value: parsed };\n } catch (error) {\n return { ok: false, error: toError(error) };\n }\n}\n\nexport function parseCanvasControlCommand(message) {\n const toolName = message.properties.name;\n\n if (toolName !== CANVAS_CLEAR_TOOL_NAME && toolName !== CANVAS_UPDATE_TOOL_NAME) return null;\n\n const parsedArguments = parseToolArguments(message.properties.arguments);\n if (!parsedArguments.ok) throw parsedArguments.error;\n\n if (toolName === CANVAS_CLEAR_TOOL_NAME) {\n const toolCallId = parsedArguments.value.tool_call_id;\n const reason = parsedArguments.value.reason;\n\n if (toolCallId !== undefined && typeof toolCallId !== 'string') {\n throw new Error('Magic Canvas clear tool_call_id must be a string when provided.');\n }\n if (reason !== undefined && typeof reason !== 'string') {\n throw new Error('Magic Canvas clear reason must be a string when provided.');\n }\n\n return {\n kind: 'clear',\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n reason,\n };\n }\n\n const toolCallId = parsedArguments.value.tool_call_id;\n const updates = parsedArguments.value.updates;\n\n if (typeof toolCallId !== 'string' || toolCallId.length === 0) {\n throw new Error('Magic Canvas update_component requires a target tool_call_id.');\n }\n if (!isRecord(updates)) {\n throw new Error('Magic Canvas update_component requires an object updates payload.');\n }\n\n return {\n kind: 'update',\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n updates,\n };\n}\n\nfunction canvasEffectiveSlot(layout) {\n return layout.display_mode === 'fullscreen' ? 'full' : layout.preferred_slot;\n}\n\nfunction isCanvasTakeoverSlot(slot) {\n return slot === 'full' || slot === 'safe-area-bottom';\n}\n\n// Slot exclusivity: a takeover slot clears everything else; a side slot\n// replaces whatever occupied that side.\nfunction enforceCanvasSlotExclusivity(others, target) {\n const slot = canvasEffectiveSlot(target.layout);\n if (isCanvasTakeoverSlot(slot)) {\n return [target]; // fullscreen / underneath: the only component on screen\n }\n // side (left/right): keep only the opposite side; drop same side + any takeover\n const kept = others.filter((item) => {\n const itemSlot = canvasEffectiveSlot(item.layout);\n return !isCanvasTakeoverSlot(itemSlot) && itemSlot !== slot;\n });\n return [...kept, target];\n}\n\nexport function applyCanvasCommand(current, command) {\n switch (command.kind) {\n case 'show': {\n const existing = current.find((item) => item.id === command.instance.id);\n const nextInstance = existing\n ? { ...command.instance, revision: existing.revision + 1 }\n : command.instance;\n const others = current.filter((item) => item.id !== command.instance.id);\n return enforceCanvasSlotExclusivity(others, nextInstance);\n }\n case 'update': {\n // Commands are conversation-scoped: a stale or cross-conversation\n // command whose tool_call_id happens to collide must not mutate the\n // current canvas (tool_call_ids are LLM-generated and not unique\n // across conversations).\n const matchesTarget = (item) =>\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id;\n const update = splitCanvasRuntimeArguments(command.updates);\n let updated;\n const mapped = current.map((item) => {\n if (!matchesTarget(item)) return item;\n const next = {\n ...item,\n arguments: { ...item.arguments, ...update.componentArguments },\n layout: {\n preferred_slot: update.preferredSlot ?? item.layout.preferred_slot,\n display_mode: update.displayMode ?? item.layout.display_mode,\n avoid_safe_area: update.avoidSafeArea ?? item.layout.avoid_safe_area,\n safe_area: update.safeArea ?? item.layout.safe_area,\n backdrop: update.backdrop ?? item.layout.backdrop,\n },\n revision: item.revision + 1,\n };\n updated = next;\n return next;\n });\n if (updated === undefined) return mapped;\n const prev = current.find(matchesTarget);\n if (prev && canvasEffectiveSlot(prev.layout) === canvasEffectiveSlot(updated.layout)) {\n return mapped;\n }\n const rest = mapped.filter((item) => !matchesTarget(item));\n return enforceCanvasSlotExclusivity(rest, updated);\n }\n case 'clear':\n // Clear-all is scoped to the command's conversation; targeted clear\n // must match both conversation and tool_call_id.\n if (!command.tool_call_id)\n return current.filter((item) => item.conversation_id !== command.conversation_id);\n return current.filter(\n (item) =>\n !(\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id\n )\n );\n }\n}\n\nexport function extractCanvasInteraction(message) {\n if (!isRecord(message) || !Array.isArray(message.content)) return null;\n\n for (const block of message.content) {\n if (!isRecord(block)) continue;\n const meta = isRecord(block._meta) ? block._meta : null;\n const interaction = meta?.[CANVAS_INTERACTION_META_KEY];\n\n if (isRecord(interaction)) {\n return toPendingInteraction(interaction);\n }\n }\n\n return null;\n}\n\nexport function normalizeInteraction(instance, payload) {\n const interactionType = payload.type;\n const value = payload.value;\n\n if (!interactionType) {\n throw new Error('Magic Canvas interaction is missing type.');\n }\n\n if (value === undefined) {\n throw new Error('Magic Canvas interaction is missing value.');\n }\n\n return {\n interaction_id:\n payload.interaction_id ?? createInteractionId(instance.tool_call_id, interactionType),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: payload.component ?? instance.canvas_config.component,\n component_version: payload.component_version ?? instance.canvas_config.component_version,\n type: interactionType,\n value,\n metadata: isRecord(payload.metadata) ? payload.metadata : {},\n };\n}\n\nexport function shouldCompleteCanvasInteraction(event) {\n if (event.type === 'dismiss' || event.type === 'clear') return true;\n if (event.type !== 'submit' && event.type !== 'skip') return false;\n return (\n event.component === 'canvas.question' ||\n event.component === 'canvas.input' ||\n event.component === 'canvas.calendar'\n );\n}\n\nexport function extractTextContent(message) {\n if (!isRecord(message) || !Array.isArray(message.content)) return '';\n\n return message.content\n .map((block) =>\n isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : ''\n )\n .filter(Boolean)\n .join('\\n')\n .trim();\n}\n\nexport function buildCanvasModelContextAppend(instance, update) {\n const structuredContent = isRecord(update.structuredContent)\n ? update.structuredContent\n : undefined;\n const contentText = extractContentText(update.content);\n const state = typeof structuredContent?.state === 'string' ? structuredContent.state : 'updated';\n const summary =\n typeof structuredContent?.summary === 'string' ? structuredContent.summary : contentText;\n const sections = [\n `Magic Canvas state update for ${instance.canvas_config.component} (${instance.tool_call_id}).`,\n `State: ${state}.`,\n ];\n\n if (summary) sections.push(`Summary: ${summary}`);\n if (structuredContent) {\n sections.push(`Structured state: ${safeStringify(structuredContent)}`);\n }\n if (contentText && contentText !== summary) sections.push(contentText);\n\n return sections.join('\\n');\n}\n\nexport function buildHostContext(\n instance,\n layout = resolveCanvasLayout(instance, defaultCanvasViewport())\n) {\n return {\n ...instance.canvas_config.host_context,\n displayMode: layout.display_mode,\n availableDisplayModes: ['inline', 'fullscreen'],\n containerDimensions: canvasContainerDimensions(layout),\n layout: {\n preferred_slot: layout.preferred_slot,\n viable_slot: layout.viable_slot,\n avoid_safe_area: layout.avoid_safe_area,\n safe_area: layout.safe_area,\n backdrop: layout.backdrop,\n },\n userAgent: '@tavus/cvi-ui magic-canvas',\n platform: 'web',\n };\n}\n\nexport function createCanvasInstance({ conversationId, toolCallId, args, canvasConfig }) {\n const runtimeArgs = splitCanvasRuntimeArguments(args);\n\n return {\n id: toolCallId,\n conversation_id: conversationId,\n tool_call_id: toolCallId,\n arguments: runtimeArgs.componentArguments,\n canvas_config: canvasConfig,\n layout: {\n preferred_slot:\n runtimeArgs.preferredSlot ??\n canvasConfig.layout?.preferred_slot ??\n defaultLayoutSlotForComponent(canvasConfig.component),\n display_mode: runtimeArgs.displayMode ?? 'inline',\n avoid_safe_area: runtimeArgs.avoidSafeArea ?? canvasConfig.layout?.avoid_safe_area ?? true,\n safe_area: runtimeArgs.safeArea ?? canvasConfig.layout?.safe_area ?? DEFAULT_CANVAS_SAFE_AREA,\n backdrop: runtimeArgs.backdrop ?? canvasConfig.layout?.backdrop ?? DEFAULT_CANVAS_BACKDROP,\n },\n revision: 0,\n };\n}\n\nexport function resolveCanvasLayout(instance, viewport) {\n const displayMode = instance.layout.display_mode;\n\n return {\n ...instance.layout,\n display_mode: displayMode,\n viable_slot:\n displayMode === 'fullscreen'\n ? 'full'\n : resolveCanvasLayoutSlot(instance.layout.preferred_slot, viewport),\n };\n}\n\nexport function resolveCanvasSidecarLayout(layouts, viewport) {\n const sideLayouts = layouts.filter((layout) => {\n if (!layout.avoid_safe_area || layout.display_mode === 'fullscreen') return false;\n return layout.viable_slot === 'safe-area-left' || layout.viable_slot === 'safe-area-right';\n });\n const hasLeft = sideLayouts.some((layout) => layout.viable_slot === 'safe-area-left');\n const hasRight = sideLayouts.some((layout) => layout.viable_slot === 'safe-area-right');\n // When canvases occupy both sides, shifting the video toward either one\n // would just unbalance the persona. Keep it centered, let the cards sit on\n // top of the video edges, and skip the mirror backdrop since there is no\n // single gap to fill.\n if (hasLeft && hasRight) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n const activeLayout = [...sideLayouts].reverse()[0];\n\n if (!activeLayout || viewport.width < MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n\n const panelWidth = Math.min(\n MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX,\n Math.max(0, viewport.width - MAGIC_CANVAS_SIDE_PANEL_INSET_PX * 2)\n );\n const safeArea = activeLayout.safe_area;\n const safeAreaLeft = safeArea.x * viewport.width;\n const safeAreaRight = (safeArea.x + safeArea.width) * viewport.width;\n const maxShift = Math.min(360, viewport.width * 0.3);\n let videoShiftX = 0;\n\n if (activeLayout.viable_slot === 'safe-area-left') {\n const panelRight =\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX + panelWidth + MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = clamp(panelRight - safeAreaLeft, 0, maxShift);\n } else {\n const panelLeft =\n viewport.width -\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX -\n panelWidth -\n MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = -clamp(safeAreaRight - panelLeft, 0, maxShift);\n }\n\n return {\n active: true,\n side: activeLayout.viable_slot === 'safe-area-left' ? 'left' : 'right',\n video_shift_x: Math.round(videoShiftX),\n safe_area: safeArea,\n backdrop: activeLayout.backdrop,\n };\n}\n\nexport function resolveCanvasLayoutSlot(preferredSlot, viewport) {\n if (preferredSlot === 'full') return 'full';\n\n if (\n preferredSlot === 'safe-area-bottom' &&\n viewport.visualViewportHeight !== undefined &&\n viewport.visualViewportHeight < viewport.height * 0.75\n ) {\n return 'full';\n }\n\n if (\n (preferredSlot === 'safe-area-left' || preferredSlot === 'safe-area-right') &&\n viewport.width < 768 &&\n viewport.height >= viewport.width\n ) {\n return 'safe-area-bottom';\n }\n\n return preferredSlot;\n}\n\nexport function splitCanvasRuntimeArguments(args) {\n const componentArguments = {};\n\n for (const [key, value] of Object.entries(args)) {\n if (\n key === 'layout' ||\n key === 'preferred_slot' ||\n key === 'preferredSlot' ||\n key === 'display_mode' ||\n key === 'displayMode' ||\n key === 'presentation' ||\n key === 'fullscreen' ||\n key === 'avoid_safe_area' ||\n key === 'avoidSafeArea' ||\n key === 'safe_area' ||\n key === 'safeArea' ||\n key === 'backdrop'\n ) {\n continue;\n }\n\n componentArguments[key] = value;\n }\n\n return {\n componentArguments,\n preferredSlot:\n parseCanvasLayoutPreference(args.layout) ??\n parseCanvasLayoutPreference(args.preferred_slot) ??\n parseCanvasLayoutPreference(args.preferredSlot),\n displayMode: parseCanvasDisplayMode(args),\n avoidSafeArea: parseCanvasAvoidSafeArea(args),\n safeArea: parseCanvasSafeAreaFromArguments(args),\n backdrop: parseCanvasBackdropFromArguments(args),\n };\n}\n\nexport function defaultLayoutSlotForComponent(component) {\n switch (component) {\n case 'canvas.alert':\n return 'safe-area-bottom';\n case 'canvas.chart':\n case 'canvas.image':\n case 'canvas.video':\n return 'full';\n default:\n return DEFAULT_CANVAS_LAYOUT_SLOT;\n }\n}\n\nexport function canvasContainerDimensions(layout, options) {\n const displayScale = options?.displayScale ?? 1;\n\n if (layout.display_mode === 'fullscreen' || layout.viable_slot === 'full') {\n return {\n maxWidth: undefined,\n maxHeight: undefined,\n displayScale,\n };\n }\n\n const maxHeight = options?.maxHeight ?? MAGIC_CANVAS_MAX_HEIGHT_PX;\n\n if (layout.viable_slot === 'safe-area-bottom') {\n return {\n maxWidth: 720,\n maxHeight,\n displayScale,\n };\n }\n\n return {\n width: 384,\n maxHeight,\n displayScale,\n };\n}\n\nexport function createInteractionId(toolCallId, interactionType) {\n const safeToolCallId = toolCallId.replace(/[^a-zA-Z0-9_-]/g, '_');\n const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10);\n\n return `ci_${safeToolCallId}_${interactionType}_${random}`;\n}\n\nexport function isRecord(value) {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport function toError(error) {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction extractContentText(content) {\n if (!Array.isArray(content)) return '';\n\n return content\n .map((block) =>\n isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : ''\n )\n .filter(Boolean)\n .join('\\n')\n .trim();\n}\n\nfunction safeStringify(value) {\n const serialized = JSON.stringify(value);\n if (!serialized) return '{}';\n if (serialized.length <= 2000) return serialized;\n return `${serialized.slice(0, 2000)}...`;\n}\n\nfunction parseCanvasLayoutConfig(value) {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const preferredSlot = parseCanvasLayoutPreference(value);\n const safeArea = parseCanvasSafeArea(value.safe_area ?? value.safeArea);\n const backdrop = parseCanvasBackdrop(value.backdrop);\n\n if (safeArea === null || backdrop === null) return null;\n\n return {\n ...(preferredSlot ? { preferred_slot: preferredSlot } : {}),\n ...parseOptionalBooleanConfig(value.avoid_safe_area ?? value.avoidSafeArea, 'avoid_safe_area'),\n ...(safeArea ? { safe_area: safeArea } : {}),\n ...(backdrop ? { backdrop } : {}),\n };\n}\n\nfunction parseCanvasLayoutPreference(value) {\n if (typeof value === 'string' && isCanvasLayoutSlot(value)) return value;\n if (!isRecord(value)) return undefined;\n\n const preferredSlot = value.preferred_slot ?? value.preferredSlot ?? value.slot;\n return typeof preferredSlot === 'string' && isCanvasLayoutSlot(preferredSlot)\n ? preferredSlot\n : undefined;\n}\n\nfunction parseCanvasDisplayMode(args) {\n if (args.presentation === true || args.fullscreen === true) return 'fullscreen';\n if (args.presentation === false || args.fullscreen === false) return 'inline';\n\n const displayMode = args.display_mode ?? args.displayMode;\n return displayMode === 'fullscreen' || displayMode === 'inline' ? displayMode : undefined;\n}\n\nfunction parseCanvasAvoidSafeArea(args) {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const value =\n args.avoid_safe_area ?? args.avoidSafeArea ?? layout?.avoid_safe_area ?? layout?.avoidSafeArea;\n return typeof value === 'boolean' ? value : undefined;\n}\n\nfunction parseCanvasSafeAreaFromArguments(args) {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasSafeArea(\n args.safe_area ?? args.safeArea ?? layout?.safe_area ?? layout?.safeArea\n );\n return parsed ?? undefined;\n}\n\nfunction parseCanvasBackdropFromArguments(args) {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasBackdrop(args.backdrop ?? layout?.backdrop);\n return parsed ?? undefined;\n}\n\nfunction parseCanvasSafeArea(value) {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const x = readNumber(value, 'x');\n const y = readNumber(value, 'y');\n const width = readNumber(value, 'width');\n const height = readNumber(value, 'height');\n\n if (x === undefined || y === undefined || width === undefined || height === undefined) {\n return null;\n }\n if (x < 0 || y < 0 || width <= 0 || height <= 0) return null;\n if (x + width > 1.001 || y + height > 1.001) return null;\n\n return {\n x: clamp(x, 0, 1),\n y: clamp(y, 0, 1),\n width: clamp(width, 0, 1),\n height: clamp(height, 0, 1),\n };\n}\n\nfunction parseCanvasBackdrop(value) {\n if (value === undefined) return undefined;\n if (value === 'snapshot_mirror' || value === 'none') return { type: value };\n if (!isRecord(value)) return null;\n\n const type = value.type;\n if (type === 'snapshot_mirror' || type === 'none') return { type };\n return null;\n}\n\nfunction parseOptionalBooleanConfig(value, key) {\n return typeof value === 'boolean' ? { [key]: value } : {};\n}\n\nfunction isCanvasLayoutSlot(value) {\n return CANVAS_LAYOUT_SLOTS.includes(value);\n}\n\nfunction defaultCanvasViewport() {\n return {\n width: globalThis.innerWidth || 1024,\n height: globalThis.innerHeight || 768,\n visualViewportHeight: globalThis.visualViewport?.height,\n };\n}\n\nfunction readString(value, key) {\n const field = value[key];\n return typeof field === 'string' && field.length > 0 ? field : undefined;\n}\n\nfunction readNumber(value, key) {\n const field = value[key];\n return typeof field === 'number' && Number.isFinite(field) ? field : undefined;\n}\n\nfunction clamp(value, min, max) {\n return Math.min(Math.max(value, min), max);\n}\n\nfunction readOptionalAllowedUrl(value, key, isAllowed) {\n const url = readString(value, key);\n if (!url) return undefined;\n return isAllowed(url) ? url : null;\n}\n\nfunction toPendingInteraction(value) {\n if (value.interaction_id !== undefined && typeof value.interaction_id !== 'string') return null;\n if (value.component !== undefined && typeof value.component !== 'string') return null;\n if (value.component_version !== undefined && typeof value.component_version !== 'string')\n return null;\n if (value.type !== undefined && typeof value.type !== 'string') return null;\n if (value.metadata !== undefined && !isRecord(value.metadata)) return null;\n\n return value;\n}\n"
648
+ "content": "// Magic Canvas runtime: pure parsing, validation, normalization, and the\n// constants the React surface and bridge code both need. Nothing in this file\n// touches React, fetch, or the AppBridge so it stays trivially testable and\n// portable across host runtimes.\n\nimport {\n isAllowedCanvasApiUrl,\n isAllowedCanvasMcpUrl,\n isAllowedCanvasSandboxUrl,\n} from './allowlists.js';\n\nexport const CANVAS_INTERACTION_META_KEY = 'tavus.canvas.interaction';\nexport const SUPPORTED_CANVAS_CONFIG_VERSION = 1;\nexport const MAGIC_CANVAS_MIN_HEIGHT_PX = 240;\nexport const MAGIC_CANVAS_MAX_HEIGHT_PX = 720;\nexport const MIN_CANVAS_DISPLAY_SCALE = 0.85;\nexport const MAX_CANVAS_INSTANCES = 3;\nexport const CANVAS_CLEAR_TOOL_NAME = 'canvas_clear';\nexport const CANVAS_UPDATE_TOOL_NAME = 'update_component';\n\nexport const CANVAS_LAYOUT_SLOTS = [\n 'safe-area-right',\n 'safe-area-left',\n 'safe-area-bottom',\n 'full',\n];\n\nexport const DEFAULT_CANVAS_LAYOUT_SLOT = 'safe-area-right';\nexport const DEFAULT_CANVAS_SAFE_AREA = {\n x: 275 / 1280,\n y: 111 / 720,\n width: 730 / 1280,\n height: 609 / 720,\n};\nexport const DEFAULT_CANVAS_BACKDROP = {\n type: 'snapshot_mirror',\n};\nexport const MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX = 448;\nexport const MAGIC_CANVAS_SIDE_PANEL_INSET_PX = 16;\nexport const MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX = 24;\nexport const MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX = 900;\n\nexport function isCanvasToolCallMessage(value) {\n if (!isRecord(value)) return false;\n\n return (\n value.message_type === 'conversation' &&\n value.event_type === 'conversation.tool_call' &&\n typeof value.conversation_id === 'string' &&\n isRecord(value.properties)\n );\n}\n\nexport function parseCanvasConfig(value) {\n if (!isRecord(value)) return null;\n\n const component = readString(value, 'component');\n const componentVersion = readString(value, 'component_version');\n const sandboxUrl = readString(value, 'sandbox_url');\n const mcpServerUrl = readOptionalAllowedUrl(value, 'mcp_server_url', isAllowedCanvasMcpUrl);\n const apiBaseUrl = readOptionalAllowedUrl(value, 'api_base_url', isAllowedCanvasApiUrl);\n const interactionUrl = readOptionalAllowedUrl(value, 'interaction_url', isAllowedCanvasApiUrl);\n const layout = parseCanvasLayoutConfig(value.layout);\n const version = value.version;\n\n if (version !== SUPPORTED_CANVAS_CONFIG_VERSION) return null;\n if (!component || !componentVersion || !sandboxUrl) return null;\n if (!isAllowedCanvasSandboxUrl(sandboxUrl)) return null;\n if (mcpServerUrl === null || apiBaseUrl === null || interactionUrl === null) return null;\n if (layout === null) return null;\n\n return {\n version,\n component,\n component_version: componentVersion,\n resource_uri: readString(value, 'resource_uri'),\n sandbox_url: sandboxUrl,\n mcp_server_url: mcpServerUrl,\n mcp_tool_name: readString(value, 'mcp_tool_name'),\n api_base_url: apiBaseUrl,\n interaction_url: interactionUrl,\n layout,\n host_context: isRecord(value.host_context) ? value.host_context : undefined,\n };\n}\n\n/**\n * Returns the component id (e.g. \"canvas.alert\") of a Magic Canvas show\n * tool-call message, or null when the message is not a canvas show. Pure over\n * the tool-call message; host surfaces (e.g. the dev-portal post-test recap)\n * use it to record which on-screen element was displayed. Promoted into the\n * canonical runtime from the dev-portal-local copy (PROD-3563).\n */\nexport function getShownCanvasComponent(value) {\n if (!isCanvasToolCallMessage(value)) return null;\n if (value.canvas_config === undefined || value.canvas_config === null) return null;\n return parseCanvasConfig(value.canvas_config)?.component ?? null;\n}\n\nexport function parseToolArguments(value) {\n if (value === undefined) return { ok: true, value: {} };\n if (isRecord(value)) return { ok: true, value };\n\n if (typeof value !== 'string') {\n return {\n ok: false,\n error: new Error('Magic Canvas tool arguments must be an object or JSON string.'),\n };\n }\n\n try {\n const parsed = JSON.parse(value);\n\n if (!isRecord(parsed)) {\n return {\n ok: false,\n error: new Error('Magic Canvas tool arguments must decode to an object.'),\n };\n }\n\n return { ok: true, value: parsed };\n } catch (error) {\n return { ok: false, error: toError(error) };\n }\n}\n\nexport function parseCanvasControlCommand(message) {\n const toolName = message.properties.name;\n\n if (toolName !== CANVAS_CLEAR_TOOL_NAME && toolName !== CANVAS_UPDATE_TOOL_NAME) return null;\n\n const parsedArguments = parseToolArguments(message.properties.arguments);\n if (!parsedArguments.ok) throw parsedArguments.error;\n\n if (toolName === CANVAS_CLEAR_TOOL_NAME) {\n const toolCallId = parsedArguments.value.tool_call_id;\n const reason = parsedArguments.value.reason;\n\n if (toolCallId !== undefined && typeof toolCallId !== 'string') {\n throw new Error('Magic Canvas clear tool_call_id must be a string when provided.');\n }\n if (reason !== undefined && typeof reason !== 'string') {\n throw new Error('Magic Canvas clear reason must be a string when provided.');\n }\n\n return {\n kind: 'clear',\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n reason,\n };\n }\n\n const toolCallId = parsedArguments.value.tool_call_id;\n const updates = parsedArguments.value.updates;\n\n if (typeof toolCallId !== 'string' || toolCallId.length === 0) {\n throw new Error('Magic Canvas update_component requires a target tool_call_id.');\n }\n if (!isRecord(updates)) {\n throw new Error('Magic Canvas update_component requires an object updates payload.');\n }\n\n return {\n kind: 'update',\n conversation_id: message.conversation_id,\n tool_call_id: toolCallId,\n updates,\n };\n}\n\nconst CANVAS_MODEL_SELECTABLE_SLOTS = ['safe-area-left', 'safe-area-right'];\n\nfunction clampCanvasLayout(layout) {\n const preferred_slot = CANVAS_MODEL_SELECTABLE_SLOTS.includes(layout.preferred_slot)\n ? layout.preferred_slot\n : DEFAULT_CANVAS_LAYOUT_SLOT;\n if (preferred_slot === layout.preferred_slot && layout.display_mode === 'inline') {\n return layout;\n }\n return { ...layout, preferred_slot, display_mode: 'inline' };\n}\n\nfunction canvasEffectiveSlot(layout) {\n return layout.display_mode === 'fullscreen' ? 'full' : layout.preferred_slot;\n}\n\n// Single-card policy: a newly shown or updated card replaces what was on screen.\nfunction enforceCanvasSlotExclusivity(_others, target) {\n return [target];\n}\n\nexport function applyCanvasCommand(current, command) {\n switch (command.kind) {\n case 'show': {\n const existing = current.find((item) => item.id === command.instance.id);\n const nextInstance = existing\n ? { ...command.instance, revision: existing.revision + 1 }\n : command.instance;\n const others = current.filter((item) => item.id !== command.instance.id);\n return enforceCanvasSlotExclusivity(others, nextInstance);\n }\n case 'update': {\n // Commands are conversation-scoped: a stale or cross-conversation\n // command whose tool_call_id happens to collide must not mutate the\n // current canvas (tool_call_ids are LLM-generated and not unique\n // across conversations).\n const matchesTarget = (item) =>\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id;\n const update = splitCanvasRuntimeArguments(command.updates);\n let updated;\n const mapped = current.map((item) => {\n if (!matchesTarget(item)) return item;\n const next = {\n ...item,\n arguments: { ...item.arguments, ...update.componentArguments },\n layout: clampCanvasLayout({\n preferred_slot: update.preferredSlot ?? item.layout.preferred_slot,\n display_mode: update.displayMode ?? item.layout.display_mode,\n avoid_safe_area: update.avoidSafeArea ?? item.layout.avoid_safe_area,\n safe_area: update.safeArea ?? item.layout.safe_area,\n backdrop: update.backdrop ?? item.layout.backdrop,\n }),\n revision: item.revision + 1,\n };\n updated = next;\n return next;\n });\n if (updated === undefined) return mapped;\n const prev = current.find(matchesTarget);\n if (prev && canvasEffectiveSlot(prev.layout) === canvasEffectiveSlot(updated.layout)) {\n return mapped;\n }\n const rest = mapped.filter((item) => !matchesTarget(item));\n return enforceCanvasSlotExclusivity(rest, updated);\n }\n case 'clear':\n // Clear-all is scoped to the command's conversation; targeted clear\n // must match both conversation and tool_call_id.\n if (!command.tool_call_id)\n return current.filter((item) => item.conversation_id !== command.conversation_id);\n return current.filter(\n (item) =>\n !(\n item.conversation_id === command.conversation_id &&\n item.tool_call_id === command.tool_call_id\n )\n );\n }\n}\n\nexport function extractCanvasInteraction(message) {\n if (!isRecord(message) || !Array.isArray(message.content)) return null;\n\n for (const block of message.content) {\n if (!isRecord(block)) continue;\n const meta = isRecord(block._meta) ? block._meta : null;\n const interaction = meta?.[CANVAS_INTERACTION_META_KEY];\n\n if (isRecord(interaction)) {\n return toPendingInteraction(interaction);\n }\n }\n\n return null;\n}\n\nexport function normalizeInteraction(instance, payload) {\n const interactionType = payload.type;\n const value = payload.value;\n\n if (!interactionType) {\n throw new Error('Magic Canvas interaction is missing type.');\n }\n\n if (value === undefined) {\n throw new Error('Magic Canvas interaction is missing value.');\n }\n\n return {\n interaction_id:\n payload.interaction_id ?? createInteractionId(instance.tool_call_id, interactionType),\n conversation_id: instance.conversation_id,\n tool_call_id: instance.tool_call_id,\n component: payload.component ?? instance.canvas_config.component,\n component_version: payload.component_version ?? instance.canvas_config.component_version,\n type: interactionType,\n value,\n metadata: isRecord(payload.metadata) ? payload.metadata : {},\n };\n}\n\nexport function shouldCompleteCanvasInteraction(event) {\n if (event.type === 'dismiss' || event.type === 'clear') return true;\n if (event.type !== 'submit' && event.type !== 'skip') return false;\n return (\n event.component === 'canvas.question' ||\n event.component === 'canvas.input' ||\n event.component === 'canvas.calendar'\n );\n}\n\nexport function extractTextContent(message) {\n if (!isRecord(message) || !Array.isArray(message.content)) return '';\n\n return message.content\n .map((block) =>\n isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : ''\n )\n .filter(Boolean)\n .join('\\n')\n .trim();\n}\n\nexport function buildCanvasModelContextAppend(instance, update) {\n const structuredContent = isRecord(update.structuredContent)\n ? update.structuredContent\n : undefined;\n const contentText = extractContentText(update.content);\n const state = typeof structuredContent?.state === 'string' ? structuredContent.state : 'updated';\n const summary =\n typeof structuredContent?.summary === 'string' ? structuredContent.summary : contentText;\n const sections = [\n `Magic Canvas state update for ${instance.canvas_config.component} (${instance.tool_call_id}).`,\n `State: ${state}.`,\n ];\n\n if (summary) sections.push(`Summary: ${summary}`);\n if (structuredContent) {\n sections.push(`Structured state: ${safeStringify(structuredContent)}`);\n }\n if (contentText && contentText !== summary) sections.push(contentText);\n\n return sections.join('\\n');\n}\n\nexport function buildHostContext(\n instance,\n layout = resolveCanvasLayout(instance, defaultCanvasViewport())\n) {\n return {\n ...instance.canvas_config.host_context,\n displayMode: layout.display_mode,\n availableDisplayModes: ['inline', 'fullscreen'],\n containerDimensions: canvasContainerDimensions(layout),\n layout: {\n preferred_slot: layout.preferred_slot,\n viable_slot: layout.viable_slot,\n avoid_safe_area: layout.avoid_safe_area,\n safe_area: layout.safe_area,\n backdrop: layout.backdrop,\n },\n userAgent: '@tavus/cvi-ui magic-canvas',\n platform: 'web',\n };\n}\n\nexport function createCanvasInstance({ conversationId, toolCallId, args, canvasConfig }) {\n const runtimeArgs = splitCanvasRuntimeArguments(args);\n\n return {\n id: toolCallId,\n conversation_id: conversationId,\n tool_call_id: toolCallId,\n arguments: runtimeArgs.componentArguments,\n canvas_config: canvasConfig,\n layout: clampCanvasLayout({\n preferred_slot:\n runtimeArgs.preferredSlot ??\n canvasConfig.layout?.preferred_slot ??\n defaultLayoutSlotForComponent(canvasConfig.component),\n display_mode: runtimeArgs.displayMode ?? 'inline',\n avoid_safe_area: runtimeArgs.avoidSafeArea ?? canvasConfig.layout?.avoid_safe_area ?? true,\n safe_area: runtimeArgs.safeArea ?? canvasConfig.layout?.safe_area ?? DEFAULT_CANVAS_SAFE_AREA,\n backdrop: runtimeArgs.backdrop ?? canvasConfig.layout?.backdrop ?? DEFAULT_CANVAS_BACKDROP,\n }),\n revision: 0,\n };\n}\n\nexport function resolveCanvasLayout(instance, viewport) {\n const displayMode = instance.layout.display_mode;\n\n return {\n ...instance.layout,\n display_mode: displayMode,\n viable_slot:\n displayMode === 'fullscreen'\n ? 'full'\n : resolveCanvasLayoutSlot(instance.layout.preferred_slot, viewport),\n };\n}\n\nexport function resolveCanvasSidecarLayout(layouts, viewport) {\n const sideLayouts = layouts.filter((layout) => {\n if (!layout.avoid_safe_area || layout.display_mode === 'fullscreen') return false;\n return layout.viable_slot === 'safe-area-left' || layout.viable_slot === 'safe-area-right';\n });\n const hasLeft = sideLayouts.some((layout) => layout.viable_slot === 'safe-area-left');\n const hasRight = sideLayouts.some((layout) => layout.viable_slot === 'safe-area-right');\n // When canvases occupy both sides, shifting the video toward either one\n // would just unbalance the persona. Keep it centered, let the cards sit on\n // top of the video edges, and skip the mirror backdrop since there is no\n // single gap to fill.\n if (hasLeft && hasRight) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n const activeLayout = [...sideLayouts].reverse()[0];\n\n if (!activeLayout || viewport.width < MAGIC_CANVAS_SAFE_AREA_MIN_VIEWPORT_WIDTH_PX) {\n return {\n active: false,\n video_shift_x: 0,\n backdrop: DEFAULT_CANVAS_BACKDROP,\n };\n }\n\n const panelWidth = Math.min(\n MAGIC_CANVAS_SIDE_PANEL_WIDTH_PX,\n Math.max(0, viewport.width - MAGIC_CANVAS_SIDE_PANEL_INSET_PX * 2)\n );\n const safeArea = activeLayout.safe_area;\n const safeAreaLeft = safeArea.x * viewport.width;\n const safeAreaRight = (safeArea.x + safeArea.width) * viewport.width;\n const maxShift = Math.min(360, viewport.width * 0.3);\n let videoShiftX = 0;\n\n if (activeLayout.viable_slot === 'safe-area-left') {\n const panelRight =\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX + panelWidth + MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = clamp(panelRight - safeAreaLeft, 0, maxShift);\n } else {\n const panelLeft =\n viewport.width -\n MAGIC_CANVAS_SIDE_PANEL_INSET_PX -\n panelWidth -\n MAGIC_CANVAS_SIDE_PANEL_GUTTER_PX;\n videoShiftX = -clamp(safeAreaRight - panelLeft, 0, maxShift);\n }\n\n return {\n active: true,\n side: activeLayout.viable_slot === 'safe-area-left' ? 'left' : 'right',\n video_shift_x: Math.round(videoShiftX),\n safe_area: safeArea,\n backdrop: activeLayout.backdrop,\n };\n}\n\nexport function resolveCanvasLayoutSlot(preferredSlot, viewport) {\n if (preferredSlot === 'full') return 'full';\n\n if (\n preferredSlot === 'safe-area-bottom' &&\n viewport.visualViewportHeight !== undefined &&\n viewport.visualViewportHeight < viewport.height * 0.75\n ) {\n return 'full';\n }\n\n if (\n (preferredSlot === 'safe-area-left' || preferredSlot === 'safe-area-right') &&\n viewport.width < 768 &&\n viewport.height >= viewport.width\n ) {\n return 'safe-area-bottom';\n }\n\n return preferredSlot;\n}\n\nexport function splitCanvasRuntimeArguments(args) {\n const componentArguments = {};\n\n for (const [key, value] of Object.entries(args)) {\n if (\n key === 'layout' ||\n key === 'preferred_slot' ||\n key === 'preferredSlot' ||\n key === 'display_mode' ||\n key === 'displayMode' ||\n key === 'presentation' ||\n key === 'fullscreen' ||\n key === 'avoid_safe_area' ||\n key === 'avoidSafeArea' ||\n key === 'safe_area' ||\n key === 'safeArea' ||\n key === 'backdrop'\n ) {\n continue;\n }\n\n componentArguments[key] = value;\n }\n\n return {\n componentArguments,\n preferredSlot:\n parseCanvasLayoutPreference(args.layout) ??\n parseCanvasLayoutPreference(args.preferred_slot) ??\n parseCanvasLayoutPreference(args.preferredSlot),\n displayMode: parseCanvasDisplayMode(args),\n avoidSafeArea: parseCanvasAvoidSafeArea(args),\n safeArea: parseCanvasSafeAreaFromArguments(args),\n backdrop: parseCanvasBackdropFromArguments(args),\n };\n}\n\nexport function defaultLayoutSlotForComponent(component) {\n switch (component) {\n case 'canvas.alert':\n return 'safe-area-bottom';\n case 'canvas.chart':\n case 'canvas.image':\n case 'canvas.video':\n return 'full';\n default:\n return DEFAULT_CANVAS_LAYOUT_SLOT;\n }\n}\n\nexport function canvasContainerDimensions(layout, options) {\n const displayScale = options?.displayScale ?? 1;\n\n if (layout.display_mode === 'fullscreen' || layout.viable_slot === 'full') {\n return {\n maxWidth: undefined,\n maxHeight: undefined,\n displayScale,\n };\n }\n\n const maxHeight = options?.maxHeight ?? MAGIC_CANVAS_MAX_HEIGHT_PX;\n\n if (layout.viable_slot === 'safe-area-bottom') {\n return {\n maxWidth: 720,\n maxHeight,\n displayScale,\n };\n }\n\n return {\n width: 384,\n maxHeight,\n displayScale,\n };\n}\n\nexport function createInteractionId(toolCallId, interactionType) {\n const safeToolCallId = toolCallId.replace(/[^a-zA-Z0-9_-]/g, '_');\n const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10);\n\n return `ci_${safeToolCallId}_${interactionType}_${random}`;\n}\n\nexport function isRecord(value) {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nexport function toError(error) {\n return error instanceof Error ? error : new Error(String(error));\n}\n\nfunction extractContentText(content) {\n if (!Array.isArray(content)) return '';\n\n return content\n .map((block) =>\n isRecord(block) && block.type === 'text' && typeof block.text === 'string' ? block.text : ''\n )\n .filter(Boolean)\n .join('\\n')\n .trim();\n}\n\nfunction safeStringify(value) {\n const serialized = JSON.stringify(value);\n if (!serialized) return '{}';\n if (serialized.length <= 2000) return serialized;\n return `${serialized.slice(0, 2000)}...`;\n}\n\nfunction parseCanvasLayoutConfig(value) {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const preferredSlot = parseCanvasLayoutPreference(value);\n const safeArea = parseCanvasSafeArea(value.safe_area ?? value.safeArea);\n const backdrop = parseCanvasBackdrop(value.backdrop);\n\n if (safeArea === null || backdrop === null) return null;\n\n return {\n ...(preferredSlot ? { preferred_slot: preferredSlot } : {}),\n ...parseOptionalBooleanConfig(value.avoid_safe_area ?? value.avoidSafeArea, 'avoid_safe_area'),\n ...(safeArea ? { safe_area: safeArea } : {}),\n ...(backdrop ? { backdrop } : {}),\n };\n}\n\nfunction parseCanvasLayoutPreference(value) {\n if (typeof value === 'string' && isCanvasLayoutSlot(value)) return value;\n if (!isRecord(value)) return undefined;\n\n const preferredSlot = value.preferred_slot ?? value.preferredSlot ?? value.slot;\n return typeof preferredSlot === 'string' && isCanvasLayoutSlot(preferredSlot)\n ? preferredSlot\n : undefined;\n}\n\nfunction parseCanvasDisplayMode(args) {\n if (args.presentation === true || args.fullscreen === true) return 'fullscreen';\n if (args.presentation === false || args.fullscreen === false) return 'inline';\n\n const displayMode = args.display_mode ?? args.displayMode;\n return displayMode === 'fullscreen' || displayMode === 'inline' ? displayMode : undefined;\n}\n\nfunction parseCanvasAvoidSafeArea(args) {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const value =\n args.avoid_safe_area ?? args.avoidSafeArea ?? layout?.avoid_safe_area ?? layout?.avoidSafeArea;\n return typeof value === 'boolean' ? value : undefined;\n}\n\nfunction parseCanvasSafeAreaFromArguments(args) {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasSafeArea(\n args.safe_area ?? args.safeArea ?? layout?.safe_area ?? layout?.safeArea\n );\n return parsed ?? undefined;\n}\n\nfunction parseCanvasBackdropFromArguments(args) {\n const layout = isRecord(args.layout) ? args.layout : undefined;\n const parsed = parseCanvasBackdrop(args.backdrop ?? layout?.backdrop);\n return parsed ?? undefined;\n}\n\nfunction parseCanvasSafeArea(value) {\n if (value === undefined) return undefined;\n if (!isRecord(value)) return null;\n\n const x = readNumber(value, 'x');\n const y = readNumber(value, 'y');\n const width = readNumber(value, 'width');\n const height = readNumber(value, 'height');\n\n if (x === undefined || y === undefined || width === undefined || height === undefined) {\n return null;\n }\n if (x < 0 || y < 0 || width <= 0 || height <= 0) return null;\n if (x + width > 1.001 || y + height > 1.001) return null;\n\n return {\n x: clamp(x, 0, 1),\n y: clamp(y, 0, 1),\n width: clamp(width, 0, 1),\n height: clamp(height, 0, 1),\n };\n}\n\nfunction parseCanvasBackdrop(value) {\n if (value === undefined) return undefined;\n if (value === 'snapshot_mirror' || value === 'none') return { type: value };\n if (!isRecord(value)) return null;\n\n const type = value.type;\n if (type === 'snapshot_mirror' || type === 'none') return { type };\n return null;\n}\n\nfunction parseOptionalBooleanConfig(value, key) {\n return typeof value === 'boolean' ? { [key]: value } : {};\n}\n\nfunction isCanvasLayoutSlot(value) {\n return CANVAS_LAYOUT_SLOTS.includes(value);\n}\n\nfunction defaultCanvasViewport() {\n return {\n width: globalThis.innerWidth || 1024,\n height: globalThis.innerHeight || 768,\n visualViewportHeight: globalThis.visualViewport?.height,\n };\n}\n\nfunction readString(value, key) {\n const field = value[key];\n return typeof field === 'string' && field.length > 0 ? field : undefined;\n}\n\nfunction readNumber(value, key) {\n const field = value[key];\n return typeof field === 'number' && Number.isFinite(field) ? field : undefined;\n}\n\nfunction clamp(value, min, max) {\n return Math.min(Math.max(value, min), max);\n}\n\nfunction readOptionalAllowedUrl(value, key, isAllowed) {\n const url = readString(value, key);\n if (!url) return undefined;\n return isAllowed(url) ? url : null;\n}\n\nfunction toPendingInteraction(value) {\n if (value.interaction_id !== undefined && typeof value.interaction_id !== 'string') return null;\n if (value.component !== undefined && typeof value.component !== 'string') return null;\n if (value.component_version !== undefined && typeof value.component_version !== 'string')\n return null;\n if (value.type !== undefined && typeof value.type !== 'string') return null;\n if (value.metadata !== undefined && !isRecord(value.metadata)) return null;\n\n return value;\n}\n"
649
649
  }
650
650
  ],
651
651
  componentsDependencies: ["cvi-events-hooks"],
@@ -1439,7 +1439,7 @@ const info = new Command().name("info").description("get information about your
1439
1439
  });
1440
1440
  //#endregion
1441
1441
  //#region package.json
1442
- var version = "0.0.4-beta.1";
1442
+ var version = "0.1.0";
1443
1443
  //#endregion
1444
1444
  //#region src/index.ts
1445
1445
  process.on("SIGINT", () => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tavus/cvi-ui",
3
- "version": "0.0.4-beta.1",
3
+ "version": "0.1.0",
4
4
  "description": "A CLI tool for installing and managing CVI components",
5
5
  "keywords": [
6
6
  "ai-replica",
@@ -34,17 +34,16 @@
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit",
37
- "build": "npm run typecheck && npm run check:canvas-runtime-parity && vp pack",
37
+ "build": "npm run typecheck && vp pack",
38
38
  "start": "node dist/index.js",
39
39
  "create-templates": "node prepare-scripts/create-templates.js",
40
40
  "convert-to-js": "node prepare-scripts/convert-to-js.js",
41
- "build:templates": "npm run convert-to-js && npm run create-templates",
42
- "check:canvas-runtime-parity": "node scripts/check-magic-canvas-runtime-parity.mjs",
43
- "update:canvas-runtime-parity": "node scripts/check-magic-canvas-runtime-parity.mjs --update",
41
+ "stage:canvas-core": "node prepare-scripts/stage-canvas-core.mjs",
42
+ "build:templates": "npm run stage:canvas-core && npm run convert-to-js && npm run create-templates",
44
43
  "test:contract": "node --test \"tests/**/*.test.mjs\"",
45
44
  "clean": "rm -rf jsx-templates dist src/templates",
46
- "build-all": "npm run clean && npm run build:templates && npm run check:canvas-runtime-parity && npm run build && vp check --fix",
47
- "test": "npm run build:templates && npm run check:canvas-runtime-parity && npm run test:contract && vp test run test/unit test/integration",
45
+ "build-all": "npm run clean && npm run build:templates && npm run build && vp check --fix",
46
+ "test": "npm run build:templates && npm run test:contract && vp test run test/unit test/integration",
48
47
  "test:watch": "vp test watch",
49
48
  "test:unit": "npm run build:templates && vp test run test/unit",
50
49
  "test:integration": "npm run build-all && vp test run test/integration",
@@ -83,6 +82,7 @@
83
82
  "@babel/generator": "^7.29.1",
84
83
  "@babel/parser": "^7.29.3",
85
84
  "@babel/traverse": "^7.29.0",
85
+ "@tavus-engineering/magic-canvas-core": "0.1.0-beta.1",
86
86
  "@types/fs-extra": "^11.0.4",
87
87
  "@types/node": "^25.6.0",
88
88
  "@types/prompts": "^2.4.9",