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

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 (3) hide show
  1. package/README.md +15 -16
  2. package/dist/index.js +71 -5
  3. package/package.json +8 -4
package/README.md CHANGED
@@ -28,7 +28,7 @@ npx @tavus/cvi-ui@latest add conversation
28
28
  import { CVIProvider } from './components/cvi/components/cvi-provider';
29
29
 
30
30
  function App() {
31
- return <CVIProvider>{/* Your app content */}</CVIProvider>;
31
+ return <CVIProvider>{/* Your app content */}</CVIProvider>;
32
32
  }
33
33
  ```
34
34
 
@@ -38,18 +38,18 @@ function App() {
38
38
  import { Conversation } from './components/cvi/components/conversation';
39
39
 
40
40
  function CVI() {
41
- return (
42
- <div
43
- style={{
44
- width: '100%',
45
- height: '100%',
46
- maxWidth: '1200px',
47
- margin: '0 auto',
48
- }}
49
- >
50
- <Conversation conversationUrl="YOUR_TAVUS_MEETING_URL" onLeave={() => {}} />
51
- </div>
52
- );
41
+ return (
42
+ <div
43
+ style={{
44
+ width: '100%',
45
+ height: '100%',
46
+ maxWidth: '1200px',
47
+ margin: '0 auto',
48
+ }}
49
+ >
50
+ <Conversation conversationUrl="YOUR_TAVUS_MEETING_URL" onLeave={() => {}} />
51
+ </div>
52
+ );
53
53
  }
54
54
  ```
55
55
 
@@ -77,7 +77,7 @@ npx @tavus/cvi-ui@latest add tavus-api
77
77
  | TanStack Start | `app/routes/api/tavus.ts` + `lib/tavus-client.ts` |
78
78
  | Anything else (plain Vite, Expo, …) | **Exits with an error.** See the Vite-with-server opt-in below. |
79
79
 
80
- Set the server-side env var (no client prefix). [Create your API key in the Tavus Developer Portal →](https://platform.tavus.io/api-keys)
80
+ Set the server-side env var (no client prefix). [Create your API key in the Tavus Portal →](https://platform.tavus.io/api-keys)
81
81
 
82
82
  ```env
83
83
  TAVUS_API_KEY=sk_...
@@ -89,8 +89,7 @@ Then call from your UI — your key is never touched on the client:
89
89
  import { createTavusConversation, endTavusConversation } from './components/cvi/lib/tavus-client';
90
90
 
91
91
  const { conversation_id, conversation_url } = await createTavusConversation({
92
- replica_id: 'rf4e9d9790f0',
93
- persona_id: 'pcb7a34da5fe',
92
+ persona_id: 'pcb7a34da5fe',
94
93
  });
95
94
  // …pass conversation_url into <Conversation />, then later:
96
95
  await endTavusConversation(conversation_id);
package/dist/index.js CHANGED
@@ -312,7 +312,7 @@ var closed_captions_default$1 = {
312
312
  //#region src/templates/tsx/components/conversation-01.json
313
313
  var conversation_01_default$1 = {
314
314
  type: "components",
315
- content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ClosedCaptions, ClosedCaptionsButton, ClosedCaptionsProvider } from '../closed-captions';\nimport { ChatButton, ChatPanel, ChatProvider } from '../chat';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\ninterface ConversationProps {\n onLeave: () => void;\n conversationUrl: string;\n}\n\nconst VideoPreview = React.memo(({ id }: { id: string }) => {\n const videoState = useVideoTrack(id);\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n fit=\"cover\"\n className={`${styles.previewVideo} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst SelfView = React.memo(() => (\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n));\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nconst MoreMenu = memo(() => {\n const [isOpen, setIsOpen] = useState(false);\n const ref = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!isOpen) {\n return;\n }\n const handlePointerDown = (e: PointerEvent) => {\n if (ref.current && !ref.current.contains(e.target as Node)) {\n setIsOpen(false);\n }\n };\n const handleKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n setIsOpen(false);\n }\n };\n document.addEventListener('pointerdown', handlePointerDown);\n document.addEventListener('keydown', handleKey);\n return () => {\n document.removeEventListener('pointerdown', handlePointerDown);\n document.removeEventListener('keydown', handleKey);\n };\n }, [isOpen]);\n\n return (\n <div ref={ref} className={styles.moreMenu}>\n <button\n type=\"button\"\n onClick={() => setIsOpen((v) => !v)}\n aria-pressed={isOpen}\n aria-label={isOpen ? 'Close more controls' : 'More controls'}\n aria-haspopup=\"true\"\n aria-expanded={isOpen}\n className={`${styles.moreButton} ${isOpen ? styles.moreButtonActive : ''}`}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <circle cx=\"5\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"12\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"19\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n </svg>\n </button>\n {isOpen && (\n <div className={styles.morePopover} role=\"menu\">\n <ScreenShareButton />\n <ClosedCaptionsButton />\n </div>\n )}\n </div>\n );\n});\n\nMoreMenu.displayName = 'MoreMenu';\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }: ConversationProps) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n useEffect(() => {\n joinCall({ url: conversationUrl });\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <ClosedCaptionsProvider>\n <ChatProvider>\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>\n Camera or microphone access denied. Please check your settings and try again.\n </p>\n </div>\n )}\n\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n <SelfView />\n\n <ClosedCaptions />\n </div>\n\n <ChatPanel />\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <MoreMenu />\n <ChatButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n </ChatProvider>\n </ClosedCaptionsProvider>\n );\n});\n",
315
+ content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ClosedCaptions, ClosedCaptionsButton, ClosedCaptionsProvider } from '../closed-captions';\nimport { ChatButton, ChatPanel, ChatProvider } from '../chat';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\ninterface ConversationProps {\n onLeave: () => void;\n conversationUrl: string;\n}\n\nconst VideoPreview = React.memo(({ id }: { id: string }) => {\n const videoState = useVideoTrack(id);\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n fit=\"cover\"\n className={`${styles.previewVideo} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst SelfView = React.memo(() => (\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n));\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nconst MoreMenu = memo(() => {\n const [isOpen, setIsOpen] = useState(false);\n const ref = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!isOpen) {\n return;\n }\n const handlePointerDown = (e: PointerEvent) => {\n if (ref.current && !ref.current.contains(e.target as Node)) {\n setIsOpen(false);\n }\n };\n const handleKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n setIsOpen(false);\n }\n };\n document.addEventListener('pointerdown', handlePointerDown);\n document.addEventListener('keydown', handleKey);\n return () => {\n document.removeEventListener('pointerdown', handlePointerDown);\n document.removeEventListener('keydown', handleKey);\n };\n }, [isOpen]);\n\n return (\n <div ref={ref} className={styles.moreMenu}>\n <button\n type=\"button\"\n onClick={() => setIsOpen((v) => !v)}\n aria-pressed={isOpen}\n aria-label={isOpen ? 'Close more controls' : 'More controls'}\n aria-haspopup=\"true\"\n aria-expanded={isOpen}\n className={`${styles.moreButton} ${isOpen ? styles.moreButtonActive : ''}`}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <circle cx=\"5\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"12\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"19\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n </svg>\n </button>\n {isOpen && (\n <div className={styles.morePopover} role=\"menu\">\n <ScreenShareButton />\n <ClosedCaptionsButton />\n </div>\n )}\n </div>\n );\n});\n\nMoreMenu.displayName = 'MoreMenu';\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }: ConversationProps) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n useEffect(() => {\n joinCall({ url: conversationUrl });\n // Release the singleton call on unmount: otherwise the next mount's join()\n // is rejected (\"already joined meeting\") and the stale room's death ends\n // the new conversation via onLeave above. Also StrictMode-safe.\n return () => {\n leaveCall();\n };\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <ClosedCaptionsProvider>\n <ChatProvider>\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>\n Camera or microphone access denied. Please check your settings and try again.\n </p>\n </div>\n )}\n\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n <SelfView />\n\n <ClosedCaptions />\n </div>\n\n <ChatPanel />\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <MoreMenu />\n <ChatButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n </ChatProvider>\n </ClosedCaptionsProvider>\n );\n});\n",
316
316
  styles: ".containerWrapper {\n width: 100%;\n container-type: inline-size;\n container-name: conversation;\n}\n\n.container {\n position: relative;\n width: 100%;\n text-align: center;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n aspect-ratio: 9/16;\n overflow: hidden;\n border-radius: 0.5rem;\n max-height: 90vh;\n background: linear-gradient(135deg, #4b5563 0%, #1f2937 100%);\n background-size: 400% 400%;\n animation: gradient 15s ease infinite;\n}\n\n/* Aspect ratio tracks the wrapper's actual width: portrait when narrow\n (e.g. embedded in a 400px modal), landscape when wide. */\n@container conversation (min-width: 768px) {\n .container {\n aspect-ratio: 16/9;\n }\n}\n\n.errorContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(248, 250, 252, 0.08);\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n text-align: center;\n}\n\n.videoContainer {\n position: relative;\n z-index: 5;\n width: 100%;\n height: 100%;\n}\n\n.footer {\n position: absolute;\n bottom: 1.5rem;\n left: 0;\n right: 0;\n z-index: 20;\n transition:\n opacity 0.3s ease,\n transform 0.3s ease;\n}\n\n.footerLeaving {\n opacity: 0;\n transform: translateY(20px);\n pointer-events: none;\n}\n\n.footerControls {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 16px;\n}\n\n.moreMenu {\n display: block;\n position: relative;\n}\n\n.moreButton {\n position: relative;\n height: 3rem;\n width: 3rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 9999px;\n backdrop-filter: blur(4px);\n background-color: rgba(255, 255, 255, 0.2);\n border: 1px solid rgba(255, 255, 255, 0.2);\n color: #000;\n cursor: pointer;\n}\n\n.moreButtonActive {\n background-color: white;\n color: #020617;\n}\n\n.morePopover {\n position: absolute;\n bottom: calc(100% + 0.5rem);\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding: 0.5rem;\n border-radius: 1rem;\n backdrop-filter: blur(16px);\n background-color: rgba(162, 162, 162, 0.2);\n z-index: 30;\n}\n\n.leaveButton {\n background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);\n color: white;\n border: none;\n font-size: 16px;\n cursor: pointer;\n transition: all 0.3s ease;\n height: 3rem;\n width: 3rem;\n border-radius: 9999px;\n background-color: #ef4444;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.leaveButton:hover {\n opacity: 0.8;\n}\n\n.leaveButtonIcon {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n@container conversation (max-width: 400px) {\n .footerControls {\n gap: 8px;\n }\n .moreButton,\n .leaveButton {\n height: 2.5rem;\n width: 2.5rem;\n }\n}\n\n/* ReplicaVideo styles */\n.mainVideoContainer {\n background: transparent;\n width: 100%;\n height: 100%;\n position: relative;\n}\n\n.mainVideoContainerScreenSharing {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.mainVideo {\n position: absolute;\n inset: 0;\n object-position: center;\n object-fit: cover !important;\n height: 100%;\n width: 100%;\n transition: all 0.3s ease;\n}\n\n.mainVideoScreenSharing {\n object-fit: contain !important;\n}\n\n.mainVideoHidden {\n display: none;\n}\n\n/* PreviewVideo styles */\n.previewVideoContainer {\n position: relative;\n background: rgba(2, 6, 23, 0.3);\n aspect-ratio: 1/1;\n width: 6rem;\n border-radius: 0.75rem;\n overflow: hidden;\n z-index: 10;\n}\n\n@container conversation (min-width: 768px) {\n .previewVideoContainer {\n width: 7rem;\n }\n}\n\n@container conversation (min-width: 1024px) {\n .previewVideoContainer {\n width: 8rem;\n }\n}\n\n.previewVideoContainerHidden {\n background: transparent;\n display: none;\n}\n\n.previewVideo {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.previewVideoHidden {\n display: none;\n}\n\n/* Main video container */\n.mainVideoContainer {\n width: 100%;\n height: 100%;\n}\n\n/* Self view container — pinned to top-right so captions own the bottom band. */\n.selfViewContainer {\n position: absolute;\n top: 1rem;\n left: 1rem;\n display: flex;\n align-items: flex-end;\n flex-direction: column;\n gap: 0.5rem;\n z-index: 15;\n}\n\n.selfViewContainer video {\n object-position: center center;\n object-fit: cover;\n}\n\n/* Waiting message container */\n/* Start of Selection */\n.waitingContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n@keyframes gradient {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n}\n/* End of Selection */\n\n.audioWaveContainer {\n position: absolute;\n bottom: 0.5rem;\n right: 0.5rem;\n}\n",
317
317
  componentsDependencies: [
318
318
  "device-select",
@@ -330,7 +330,7 @@ var conversation_01_default$1 = {
330
330
  //#region src/templates/tsx/components/conversation-02.json
331
331
  var conversation_02_default$1 = {
332
332
  type: "components",
333
- content: "import React, { useCallback, useEffect, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\ninterface ConversationProps {\n onLeave: () => void;\n conversationUrl: string;\n}\n\nconst VideoPreview = React.memo(({ id }: { id: string }) => {\n const videoState = useVideoTrack(id);\n const widthVideo = videoState.track?.getSettings()?.width;\n const heightVideo = videoState.track?.getSettings()?.height;\n const isVertical = widthVideo && heightVideo ? widthVideo < heightVideo : false;\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${isVertical ? styles.previewVideoContainerVertical : ''} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n className={`${styles.previewVideo} ${isVertical ? styles.previewVideoVertical : ''} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n // This is one-to-one call, so we can use the first replica id\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n // Switching between replica video and screen sharing video\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }: ConversationProps) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n // Initialize call when conversation is available\n useEffect(() => {\n joinCall({ url: conversationUrl });\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>Camera or microphone access denied. Please check your settings and try again.</p>\n </div>\n )}\n\n {/* Main video */}\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n {/* Self view */}\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n </div>\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <ScreenShareButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n});\n",
333
+ content: "import React, { useCallback, useEffect, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\ninterface ConversationProps {\n onLeave: () => void;\n conversationUrl: string;\n}\n\nconst VideoPreview = React.memo(({ id }: { id: string }) => {\n const videoState = useVideoTrack(id);\n const widthVideo = videoState.track?.getSettings()?.width;\n const heightVideo = videoState.track?.getSettings()?.height;\n const isVertical = widthVideo && heightVideo ? widthVideo < heightVideo : false;\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${isVertical ? styles.previewVideoContainerVertical : ''} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n className={`${styles.previewVideo} ${isVertical ? styles.previewVideoVertical : ''} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n // This is one-to-one call, so we can use the first replica id\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n // Switching between replica video and screen sharing video\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }: ConversationProps) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n // Initialize call when conversation is available\n useEffect(() => {\n joinCall({ url: conversationUrl });\n // Release the singleton call on unmount (see conversation-01): prevents\n // stale-room error cascades on the next conversation; StrictMode-safe.\n return () => {\n leaveCall();\n };\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>Camera or microphone access denied. Please check your settings and try again.</p>\n </div>\n )}\n\n {/* Main video */}\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n {/* Self view */}\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n </div>\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <ScreenShareButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n});\n",
334
334
  styles: ".containerWrapper {\n width: 100%;\n container-type: inline-size;\n container-name: conversation;\n}\n\n.container {\n position: relative;\n width: 100%;\n text-align: center;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n aspect-ratio: 9/16;\n overflow: hidden;\n border-radius: 0.5rem;\n max-height: 90vh;\n background: linear-gradient(135deg, #4b5563 0%, #1f2937 100%);\n background-size: 400% 400%;\n animation: gradient 15s ease infinite;\n}\n\n/* Aspect ratio tracks the wrapper's actual width: portrait when narrow,\n landscape when wide. */\n@container conversation (min-width: 768px) {\n .container {\n aspect-ratio: 16/9;\n }\n}\n\n.errorContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(248, 250, 252, 0.08);\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n text-align: center;\n}\n\n.videoContainer {\n position: relative;\n z-index: 5;\n width: 100%;\n height: 100%;\n}\n\n.footer {\n position: absolute;\n bottom: 1.5rem;\n left: 0;\n right: 0;\n z-index: 20;\n transition:\n opacity 0.3s ease,\n transform 0.3s ease;\n}\n\n.footerLeaving {\n opacity: 0;\n transform: translateY(20px);\n pointer-events: none;\n}\n\n.footerControls {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 16px;\n}\n\n.leaveButton {\n background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);\n color: white;\n border: none;\n font-size: 16px;\n cursor: pointer;\n transition: all 0.3s ease;\n height: 3rem;\n width: 3rem;\n border-radius: 9999px;\n background-color: #ef4444;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.leaveButton:hover {\n opacity: 0.8;\n}\n\n.leaveButtonIcon {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n/* ReplicaVideo styles */\n.mainVideoContainer {\n background: transparent;\n width: 100%;\n height: 100%;\n position: relative;\n}\n\n.mainVideoContainerScreenSharing {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.mainVideo {\n position: absolute;\n inset: 0;\n object-position: center;\n object-fit: cover !important;\n height: 100%;\n width: 100%;\n transition: all 0.3s ease;\n}\n\n.mainVideoScreenSharing {\n object-fit: contain !important;\n}\n\n.mainVideoHidden {\n display: none;\n}\n\n/* PreviewVideo styles */\n.previewVideoContainer {\n position: relative;\n background: rgba(2, 6, 23, 0.3);\n aspect-ratio: 16/9;\n width: 13rem;\n border-radius: 1rem;\n overflow: hidden;\n max-height: 140px;\n z-index: 10;\n}\n\n@container conversation (min-width: 768px) {\n .previewVideoContainer {\n width: 15rem;\n max-height: 160px;\n }\n}\n\n@container conversation (min-width: 1024px) {\n .previewVideoContainer {\n width: 18rem;\n max-height: 200px;\n }\n}\n\n.previewVideoContainerVertical {\n height: 40.5rem;\n width: 6rem;\n}\n\n.previewVideoContainerHidden {\n background: transparent;\n display: none;\n}\n\n.previewVideo {\n width: 100%;\n height: auto;\n max-height: 120px;\n}\n\n@container conversation (min-width: 768px) {\n .previewVideo {\n max-height: 100%;\n }\n}\n\n.previewVideoVertical {\n height: 40.5rem;\n width: 6rem;\n object-fit: cover;\n}\n\n.previewVideoHidden {\n display: none;\n}\n\n/* Main video container */\n.mainVideoContainer {\n width: 100%;\n height: 100%;\n}\n\n/* Self view container */\n.selfViewContainer {\n position: absolute;\n bottom: 5.5rem;\n left: 1rem;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n@container conversation (min-width: 1024px) {\n .selfViewContainer {\n bottom: 1rem;\n }\n}\n\n/* Waiting message container */\n/* Start of Selection */\n.waitingContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n@keyframes gradient {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n}\n/* End of Selection */\n\n.audioWaveContainer {\n position: absolute;\n bottom: 0.5rem;\n right: 0.5rem;\n}\n",
335
335
  componentsDependencies: [
336
336
  "device-select",
@@ -383,6 +383,33 @@ var hair_check_01_default$1 = {
383
383
  ]
384
384
  };
385
385
  //#endregion
386
+ //#region src/templates/tsx/components/magic-canvas.json
387
+ var magic_canvas_default$1 = {
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",
391
+ extraFiles: [
392
+ {
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"
395
+ },
396
+ {
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"
399
+ },
400
+ {
401
+ "path": "native-host.tsx",
402
+ "content": "// Data-only \"bring-your-own-renderer\" host for Magic Canvas. Not part of the\n// SHA-pinned parity set; it reuses the pinned runtime helpers as pure\n// functions so native and iframe paths emit identical downstream behavior.\n\nimport React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';\n\nimport styles from './magic-canvas.module.css';\nimport { postInteraction } from './bridge';\nimport {\n buildCanvasModelContextAppend,\n normalizeInteraction,\n shouldCompleteCanvasInteraction,\n} from './runtime';\nimport type {\n CanvasConfig,\n CanvasErrorCode,\n CanvasErrorEvent,\n CanvasInstance,\n CanvasInteractionEvent,\n CanvasResolvedLayout,\n JsonRecord,\n PendingInteraction,\n} from './runtime';\nimport { useSendAppMessage } from '../../hooks/cvi-events-hooks';\n\n// Mirrors MODEL_CONTEXT_RELAY_INTERVAL_MS in index.tsx (the iframe relay);\n// duplicated here because index.tsx imports this module (no circular import).\nconst MODEL_CONTEXT_RELAY_INTERVAL_MS = 1000;\n\n// SoT decision 17 (PROD-3246): after a successful `submit` interaction, hold\n// the card for this long before completing/removing it, so the component's\n// submitted confirmation (\"Sent.\") is actually visible instead of lasting only\n// for the POST round-trip. Mirrors SUBMIT_CONFIRMATION_CLEAR_DELAY_MS in\n// magic-canvas-apps src/components/confirmation.ts; the two must move\n// together, but they cannot share an import across repos today (PROD-3238).\n// Skip/dismiss/clear stay instant, and failed POSTs never complete at all.\nexport const CANVAS_SUBMIT_TEARDOWN_DELAY_MS = 1200;\n\nexport type CanvasCompletionScheduler = {\n /**\n * Complete an interaction per decision 17: no-op when the interaction does\n * not complete at all (shouldCompleteCanvasInteraction), instant for\n * skip/dismiss/clear, deferred by CANVAS_SUBMIT_TEARDOWN_DELAY_MS for\n * submit. A newer submit re-arms the window; a deferred completion is\n * dropped when `isClosed()` reports the host tore down in the meantime.\n */\n complete: (interaction: CanvasInteractionEvent, onComplete: () => void) => void;\n /** Cancel any pending deferred completion (host teardown/unmount). */\n cancel: () => void;\n};\n\n// Single implementation shared by BOTH cvi paths (iframe CanvasFrame and\n// NativeCanvasHost) so the deferred-teardown semantics cannot drift between\n// them. Failure paths never reach this: callers stop on a failed delivery\n// (the F16 contract), so a failed POST neither defers nor completes.\nexport function createCanvasCompletionScheduler(\n isClosed: () => boolean\n): CanvasCompletionScheduler {\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n return {\n complete(interaction, onComplete) {\n if (!shouldCompleteCanvasInteraction(interaction)) return;\n\n if (interaction.type !== 'submit') {\n onComplete();\n return;\n }\n\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null;\n if (!isClosed()) onComplete();\n }, CANVAS_SUBMIT_TEARDOWN_DELAY_MS);\n },\n cancel() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n },\n };\n}\n\n/**\n * Data-only props handed to a consumer-supplied renderer: the instance's\n * validated `{ component, version, args }` plus callbacks that drive the same\n * downstream behavior as the iframe path.\n */\nexport type CanvasRendererProps = {\n /** Validated component id, e.g. `\"canvas.question\"`. */\n component: string;\n /** Validated component version, e.g. `\"v1\"`. */\n version: string;\n /** Component arguments (runtime/layout keys already stripped out). */\n args: JsonRecord;\n /**\n * Report a user interaction (`type` defaults to `'submit'`). Runs the same\n * pure helpers as the iframe path, so payload + completion are identical.\n */\n submit: (interaction: PendingInteraction) => void;\n /** Append model context — the `conversation.append_llm_context` path. */\n sendContext: (update: { content?: unknown; structuredContent?: unknown }) => void;\n /** Free-text response — the `conversation.respond` path. */\n respond: (text: string) => void;\n /** Report a renderer-side error through the same error channel. */\n onError: (error: unknown) => void;\n};\n\n/** A consumer-supplied native renderer for a single `component@version`. */\nexport type CanvasComponentRenderer = (props: CanvasRendererProps) => React.ReactNode;\n\n/**\n * Registry of native renderers keyed by `\"<component>@<version>\"`. When\n * undefined or missing an entry, the iframe (`CanvasFrame`) path is used\n * unchanged — the default-off guarantee.\n */\nexport type CanvasRenderRegistry = Record<string, CanvasComponentRenderer>;\n\n/** Build the registry key for an instance's component + version. */\nexport function canvasRendererKey(component: string, version: string): string {\n return `${component}@${version}`;\n}\n\nexport type CanvasInteractionDelivery = { ok: true } | { ok: false; error: CanvasErrorEvent };\n\n// Shared interaction delivery for the iframe and native paths: run the\n// consumer onInteraction callback (a throw is reported but does not block the\n// POST), then POST the interaction row. A failed POST is reported through the\n// onError channel AND returned as `ok: false` so callers stop before\n// respond/complete instead of signaling success while the canvas_interaction\n// row is silently missing. Mirrors the tavus-deployment host ordering.\nexport async function deliverCanvasInteraction(options: {\n interaction: CanvasInteractionEvent;\n canvasConfig: CanvasConfig;\n onInteraction?: (event: CanvasInteractionEvent) => void | Promise<void>;\n reportError: (event: CanvasErrorEvent) => void;\n buildError: (code: CanvasErrorCode, defaultMessage: string, cause: unknown) => CanvasErrorEvent;\n}): Promise<CanvasInteractionDelivery> {\n const { interaction, canvasConfig, onInteraction, reportError, buildError } = options;\n\n if (onInteraction) {\n try {\n await onInteraction(interaction);\n } catch (error) {\n reportError(\n buildError(\n 'on_interaction_callback_failed',\n 'Magic Canvas onInteraction callback threw.',\n error\n )\n );\n }\n }\n\n try {\n await postInteraction(interaction, canvasConfig);\n } catch (error) {\n const postError = buildError(\n 'interaction_post_failed',\n 'Magic Canvas interaction POST failed.',\n error\n );\n reportError(postError);\n return { ok: false, error: postError };\n }\n\n return { ok: true };\n}\n\n/**\n * Resolve the native renderer for an instance, or `null` to fall back to the\n * iframe path. Single chokepoint for default-off: no registry or no matching\n * entry → `null`.\n */\nexport function resolveCanvasRenderer(\n registry: CanvasRenderRegistry | undefined,\n instance: CanvasInstance\n): CanvasComponentRenderer | null {\n if (!registry) return null;\n const key = canvasRendererKey(\n instance.canvas_config.component,\n instance.canvas_config.component_version\n );\n return registry[key] ?? null;\n}\n\ntype NativeCanvasHostProps = {\n instance: CanvasInstance;\n layout: CanvasResolvedLayout;\n render: CanvasComponentRenderer;\n dimmed?: boolean;\n onComplete?: () => void;\n onInteraction?: (event: CanvasInteractionEvent) => void | Promise<void>;\n onError?: (event: CanvasErrorEvent) => void;\n};\n\nexport const NativeCanvasHost = memo(\n ({\n instance,\n layout,\n render,\n dimmed,\n onComplete,\n onInteraction,\n onError,\n }: NativeCanvasHostProps) => {\n const instanceRef = useRef(instance);\n const sendAppMessage = useSendAppMessage();\n const sendAppMessageRef = useRef(sendAppMessage);\n const onCompleteRef = useRef(onComplete);\n const onInteractionRef = useRef(onInteraction);\n const onErrorRef = useRef(onError);\n // Mirror CanvasFrame's `closed` flag: in-flight submit continuations must\n // not fire callbacks after unmount (a stale onComplete could clear an\n // unrelated instance that reused the same id).\n const closedRef = useRef(false);\n\n // Decision 17 deferred-submit completion, shared with CanvasFrame. One\n // scheduler per host; the unmount cleanup cancels any pending defer so a\n // late completion can never fire on behalf of a dead host.\n const completionSchedulerRef = useRef<CanvasCompletionScheduler | null>(null);\n if (!completionSchedulerRef.current) {\n completionSchedulerRef.current = createCanvasCompletionScheduler(() => closedRef.current);\n }\n const completionScheduler = completionSchedulerRef.current;\n\n useEffect(() => {\n closedRef.current = false;\n return () => {\n closedRef.current = true;\n completionScheduler.cancel();\n };\n }, [completionScheduler]);\n\n useEffect(() => {\n instanceRef.current = instance;\n }, [instance]);\n\n useEffect(() => {\n sendAppMessageRef.current = sendAppMessage;\n onCompleteRef.current = onComplete;\n onInteractionRef.current = onInteraction;\n onErrorRef.current = onError;\n }, [sendAppMessage, onComplete, onInteraction, onError]);\n\n const buildHostError = useCallback(\n (code: CanvasErrorCode, defaultMessage: string, cause: unknown): CanvasErrorEvent => {\n const current = instanceRef.current;\n return {\n code,\n message: cause instanceof Error && cause.message ? cause.message : defaultMessage,\n conversation_id: current.conversation_id,\n tool_call_id: current.tool_call_id,\n component: current.canvas_config.component,\n cause,\n };\n },\n []\n );\n\n const reportError = useCallback((event: CanvasErrorEvent) => {\n if (!closedRef.current) onErrorRef.current?.(event);\n }, []);\n\n // submit(value) → the SAME pure helpers + host path CanvasFrame uses:\n // normalizeInteraction → onInteraction → postInteraction →\n // shouldCompleteCanvasInteraction drives completion/clear.\n const submit = useCallback(\n (interactionPayload: PendingInteraction) => {\n const current = instanceRef.current;\n const payload: PendingInteraction = {\n type: 'submit',\n ...interactionPayload,\n };\n\n let interaction: CanvasInteractionEvent;\n try {\n interaction = normalizeInteraction(current, payload);\n } catch (error) {\n reportError(\n buildHostError(\n 'interaction_normalization_failed',\n 'Magic Canvas failed to normalize an interaction.',\n error\n )\n );\n return;\n }\n\n void (async () => {\n const delivery = await deliverCanvasInteraction({\n interaction,\n canvasConfig: current.canvas_config,\n onInteraction: onInteractionRef.current,\n reportError,\n buildError: buildHostError,\n });\n\n // A teardown can race the awaited delivery: don't complete an\n // unmounted host. A failed POST must not complete (and clear)\n // the card either: the interaction row was never recorded, so\n // the renderer stays up and the consumer keeps a retry signal\n // via onError.\n if (closedRef.current || !delivery.ok) return;\n\n // Decision 17: submit defers by CANVAS_SUBMIT_TEARDOWN_DELAY_MS\n // so the renderer's confirmation stays visible; everything else\n // completes instantly.\n completionScheduler.complete(interaction, () => onCompleteRef.current?.());\n })();\n },\n [buildHostError, completionScheduler, reportError]\n );\n\n // sendContext(message) → the append_llm_context path, throttled like\n // CanvasFrame's onupdatemodelcontext relay (latest update wins,\n // trailing-edge flush) so a chatty renderer can't flood the channel.\n const pendingContextUpdateRef = useRef<{\n content?: unknown;\n structuredContent?: unknown;\n } | null>(null);\n const contextRelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const flushContextUpdate = useCallback(() => {\n const update = pendingContextUpdateRef.current;\n pendingContextUpdateRef.current = null;\n if (!update) return;\n const current = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.append_llm_context',\n conversation_id: current.conversation_id,\n properties: {\n context: buildCanvasModelContextAppend(current, update),\n },\n });\n }, []);\n\n const sendContext = useCallback(\n (update: { content?: unknown; structuredContent?: unknown }) => {\n pendingContextUpdateRef.current = update;\n if (!contextRelayTimerRef.current) {\n contextRelayTimerRef.current = setTimeout(() => {\n contextRelayTimerRef.current = null;\n flushContextUpdate();\n }, MODEL_CONTEXT_RELAY_INTERVAL_MS);\n }\n },\n [flushContextUpdate]\n );\n\n // Mirror the iframe teardown: clear the timer and flush the last buffered\n // update instead of dropping it.\n useEffect(() => {\n return () => {\n if (contextRelayTimerRef.current) {\n clearTimeout(contextRelayTimerRef.current);\n contextRelayTimerRef.current = null;\n }\n flushContextUpdate();\n };\n }, [flushContextUpdate]);\n\n // respond(text) → the conversation.respond path CanvasFrame uses.\n const respond = useCallback((text: string) => {\n const current = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.respond',\n conversation_id: current.conversation_id,\n properties: { text },\n });\n }, []);\n\n const onRendererError = useCallback(\n (error: unknown) => {\n reportError(\n buildHostError(\n 'on_interaction_callback_failed',\n 'Magic Canvas native renderer reported an error.',\n error\n )\n );\n },\n [buildHostError, reportError]\n );\n\n const rendererProps = useMemo<CanvasRendererProps>(\n () => ({\n component: instance.canvas_config.component,\n version: instance.canvas_config.component_version,\n args: instance.arguments,\n submit,\n sendContext,\n respond,\n onError: onRendererError,\n }),\n [\n instance.canvas_config.component,\n instance.canvas_config.component_version,\n instance.arguments,\n submit,\n sendContext,\n respond,\n onRendererError,\n ]\n );\n\n const isFull = layout.viable_slot === 'full' || layout.display_mode === 'fullscreen';\n\n return (\n <div\n className={[\n styles.frameShell,\n styles.frameShellReady,\n isFull ? styles.frameShellFull : '',\n dimmed ? styles.frameShellDimmed : '',\n ]\n .filter(Boolean)\n .join(' ')}\n data-magic-canvas-native=\"\"\n data-component={instance.canvas_config.component}\n data-component-version={instance.canvas_config.component_version}\n data-dimmed={dimmed ? '' : undefined}\n style={{ height: isFull ? '100%' : undefined }}\n >\n {render(rendererProps)}\n </div>\n );\n }\n);\n\nNativeCanvasHost.displayName = 'NativeCanvasHost';\n"
403
+ },
404
+ {
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"
407
+ }
408
+ ],
409
+ componentsDependencies: ["cvi-events-hooks"],
410
+ dependencies: ["@modelcontextprotocol/ext-apps@^1.7.1"]
411
+ };
412
+ //#endregion
386
413
  //#region src/templates/tsx/components/media-controls.json
387
414
  var media_controls_default$1 = {
388
415
  type: "components",
@@ -487,6 +514,7 @@ var tsx_exports = /* @__PURE__ */ __exportAll({
487
514
  "cvi-provider": () => cvi_provider_default$1,
488
515
  "device-select": () => device_select_default$1,
489
516
  "hair-check-01": () => hair_check_01_default$1,
517
+ "magic-canvas": () => magic_canvas_default$1,
490
518
  "media-controls": () => media_controls_default$1,
491
519
  "use-chat": () => use_chat_default$1,
492
520
  "use-closed-caption": () => use_closed_caption_default$1,
@@ -526,7 +554,7 @@ var closed_captions_default = {
526
554
  //#region src/templates/jsx/components/conversation-01.json
527
555
  var conversation_01_default = {
528
556
  type: "components",
529
- content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ClosedCaptions, ClosedCaptionsButton, ClosedCaptionsProvider } from '../closed-captions';\nimport { ChatButton, ChatPanel, ChatProvider } from '../chat';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\nconst VideoPreview = React.memo(({ id }) => {\n const videoState = useVideoTrack(id);\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n fit=\"cover\"\n className={`${styles.previewVideo} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst SelfView = React.memo(() => (\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n));\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nconst MoreMenu = memo(() => {\n const [isOpen, setIsOpen] = useState(false);\n const ref = useRef(null);\n\n useEffect(() => {\n if (!isOpen) {\n return;\n }\n const handlePointerDown = (e) => {\n if (ref.current && !ref.current.contains(e.target)) {\n setIsOpen(false);\n }\n };\n const handleKey = (e) => {\n if (e.key === 'Escape') {\n setIsOpen(false);\n }\n };\n document.addEventListener('pointerdown', handlePointerDown);\n document.addEventListener('keydown', handleKey);\n return () => {\n document.removeEventListener('pointerdown', handlePointerDown);\n document.removeEventListener('keydown', handleKey);\n };\n }, [isOpen]);\n\n return (\n <div ref={ref} className={styles.moreMenu}>\n <button\n type=\"button\"\n onClick={() => setIsOpen((v) => !v)}\n aria-pressed={isOpen}\n aria-label={isOpen ? 'Close more controls' : 'More controls'}\n aria-haspopup=\"true\"\n aria-expanded={isOpen}\n className={`${styles.moreButton} ${isOpen ? styles.moreButtonActive : ''}`}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <circle cx=\"5\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"12\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"19\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n </svg>\n </button>\n {isOpen && (\n <div className={styles.morePopover} role=\"menu\">\n <ScreenShareButton />\n <ClosedCaptionsButton />\n </div>\n )}\n </div>\n );\n});\n\nMoreMenu.displayName = 'MoreMenu';\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n useEffect(() => {\n joinCall({ url: conversationUrl });\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <ClosedCaptionsProvider>\n <ChatProvider>\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>\n Camera or microphone access denied. Please check your settings and try again.\n </p>\n </div>\n )}\n\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n <SelfView />\n\n <ClosedCaptions />\n </div>\n\n <ChatPanel />\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <MoreMenu />\n <ChatButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n </ChatProvider>\n </ClosedCaptionsProvider>\n );\n});\n",
557
+ content: "import React, { memo, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ClosedCaptions, ClosedCaptionsButton, ClosedCaptionsProvider } from '../closed-captions';\nimport { ChatButton, ChatPanel, ChatProvider } from '../chat';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\nconst VideoPreview = React.memo(({ id }) => {\n const videoState = useVideoTrack(id);\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n fit=\"cover\"\n className={`${styles.previewVideo} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst SelfView = React.memo(() => (\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n));\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nconst MoreMenu = memo(() => {\n const [isOpen, setIsOpen] = useState(false);\n const ref = useRef(null);\n\n useEffect(() => {\n if (!isOpen) {\n return;\n }\n const handlePointerDown = (e) => {\n if (ref.current && !ref.current.contains(e.target)) {\n setIsOpen(false);\n }\n };\n const handleKey = (e) => {\n if (e.key === 'Escape') {\n setIsOpen(false);\n }\n };\n document.addEventListener('pointerdown', handlePointerDown);\n document.addEventListener('keydown', handleKey);\n return () => {\n document.removeEventListener('pointerdown', handlePointerDown);\n document.removeEventListener('keydown', handleKey);\n };\n }, [isOpen]);\n\n return (\n <div ref={ref} className={styles.moreMenu}>\n <button\n type=\"button\"\n onClick={() => setIsOpen((v) => !v)}\n aria-pressed={isOpen}\n aria-label={isOpen ? 'Close more controls' : 'More controls'}\n aria-haspopup=\"true\"\n aria-expanded={isOpen}\n className={`${styles.moreButton} ${isOpen ? styles.moreButtonActive : ''}`}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <circle cx=\"5\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"12\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n <circle cx=\"19\" cy=\"12\" r=\"1.75\" fill=\"currentColor\" />\n </svg>\n </button>\n {isOpen && (\n <div className={styles.morePopover} role=\"menu\">\n <ScreenShareButton />\n <ClosedCaptionsButton />\n </div>\n )}\n </div>\n );\n});\n\nMoreMenu.displayName = 'MoreMenu';\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n useEffect(() => {\n joinCall({ url: conversationUrl });\n // Release the singleton call on unmount: otherwise the next mount's join()\n // is rejected (\"already joined meeting\") and the stale room's death ends\n // the new conversation via onLeave above. Also StrictMode-safe.\n return () => {\n leaveCall();\n };\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <ClosedCaptionsProvider>\n <ChatProvider>\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>\n Camera or microphone access denied. Please check your settings and try again.\n </p>\n </div>\n )}\n\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n <SelfView />\n\n <ClosedCaptions />\n </div>\n\n <ChatPanel />\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <MoreMenu />\n <ChatButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n </ChatProvider>\n </ClosedCaptionsProvider>\n );\n});\n",
530
558
  styles: ".containerWrapper {\n width: 100%;\n container-type: inline-size;\n container-name: conversation;\n}\n\n.container {\n position: relative;\n width: 100%;\n text-align: center;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n aspect-ratio: 9/16;\n overflow: hidden;\n border-radius: 0.5rem;\n max-height: 90vh;\n background: linear-gradient(135deg, #4b5563 0%, #1f2937 100%);\n background-size: 400% 400%;\n animation: gradient 15s ease infinite;\n}\n\n/* Aspect ratio tracks the wrapper's actual width: portrait when narrow\n (e.g. embedded in a 400px modal), landscape when wide. */\n@container conversation (min-width: 768px) {\n .container {\n aspect-ratio: 16/9;\n }\n}\n\n.errorContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(248, 250, 252, 0.08);\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n text-align: center;\n}\n\n.videoContainer {\n position: relative;\n z-index: 5;\n width: 100%;\n height: 100%;\n}\n\n.footer {\n position: absolute;\n bottom: 1.5rem;\n left: 0;\n right: 0;\n z-index: 20;\n transition:\n opacity 0.3s ease,\n transform 0.3s ease;\n}\n\n.footerLeaving {\n opacity: 0;\n transform: translateY(20px);\n pointer-events: none;\n}\n\n.footerControls {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 16px;\n}\n\n.moreMenu {\n display: block;\n position: relative;\n}\n\n.moreButton {\n position: relative;\n height: 3rem;\n width: 3rem;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 9999px;\n backdrop-filter: blur(4px);\n background-color: rgba(255, 255, 255, 0.2);\n border: 1px solid rgba(255, 255, 255, 0.2);\n color: #000;\n cursor: pointer;\n}\n\n.moreButtonActive {\n background-color: white;\n color: #020617;\n}\n\n.morePopover {\n position: absolute;\n bottom: calc(100% + 0.5rem);\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding: 0.5rem;\n border-radius: 1rem;\n backdrop-filter: blur(16px);\n background-color: rgba(162, 162, 162, 0.2);\n z-index: 30;\n}\n\n.leaveButton {\n background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);\n color: white;\n border: none;\n font-size: 16px;\n cursor: pointer;\n transition: all 0.3s ease;\n height: 3rem;\n width: 3rem;\n border-radius: 9999px;\n background-color: #ef4444;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.leaveButton:hover {\n opacity: 0.8;\n}\n\n.leaveButtonIcon {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n@container conversation (max-width: 400px) {\n .footerControls {\n gap: 8px;\n }\n .moreButton,\n .leaveButton {\n height: 2.5rem;\n width: 2.5rem;\n }\n}\n\n/* ReplicaVideo styles */\n.mainVideoContainer {\n background: transparent;\n width: 100%;\n height: 100%;\n position: relative;\n}\n\n.mainVideoContainerScreenSharing {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.mainVideo {\n position: absolute;\n inset: 0;\n object-position: center;\n object-fit: cover !important;\n height: 100%;\n width: 100%;\n transition: all 0.3s ease;\n}\n\n.mainVideoScreenSharing {\n object-fit: contain !important;\n}\n\n.mainVideoHidden {\n display: none;\n}\n\n/* PreviewVideo styles */\n.previewVideoContainer {\n position: relative;\n background: rgba(2, 6, 23, 0.3);\n aspect-ratio: 1/1;\n width: 6rem;\n border-radius: 0.75rem;\n overflow: hidden;\n z-index: 10;\n}\n\n@container conversation (min-width: 768px) {\n .previewVideoContainer {\n width: 7rem;\n }\n}\n\n@container conversation (min-width: 1024px) {\n .previewVideoContainer {\n width: 8rem;\n }\n}\n\n.previewVideoContainerHidden {\n background: transparent;\n display: none;\n}\n\n.previewVideo {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.previewVideoHidden {\n display: none;\n}\n\n/* Main video container */\n.mainVideoContainer {\n width: 100%;\n height: 100%;\n}\n\n/* Self view container — pinned to top-right so captions own the bottom band. */\n.selfViewContainer {\n position: absolute;\n top: 1rem;\n left: 1rem;\n display: flex;\n align-items: flex-end;\n flex-direction: column;\n gap: 0.5rem;\n z-index: 15;\n}\n\n.selfViewContainer video {\n object-position: center center;\n object-fit: cover;\n}\n\n/* Waiting message container */\n/* Start of Selection */\n.waitingContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n@keyframes gradient {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n}\n/* End of Selection */\n\n.audioWaveContainer {\n position: absolute;\n bottom: 0.5rem;\n right: 0.5rem;\n}\n",
531
559
  componentsDependencies: [
532
560
  "device-select",
@@ -544,7 +572,7 @@ var conversation_01_default = {
544
572
  //#region src/templates/jsx/components/conversation-02.json
545
573
  var conversation_02_default = {
546
574
  type: "components",
547
- content: "import React, { useCallback, useEffect, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\nconst VideoPreview = React.memo(({ id }) => {\n const videoState = useVideoTrack(id);\n const widthVideo = videoState.track?.getSettings()?.width;\n const heightVideo = videoState.track?.getSettings()?.height;\n const isVertical = widthVideo && heightVideo ? widthVideo < heightVideo : false;\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${isVertical ? styles.previewVideoContainerVertical : ''} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n className={`${styles.previewVideo} ${isVertical ? styles.previewVideoVertical : ''} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n // This is one-to-one call, so we can use the first replica id\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n // Switching between replica video and screen sharing video\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n // Initialize call when conversation is available\n useEffect(() => {\n joinCall({ url: conversationUrl });\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>Camera or microphone access denied. Please check your settings and try again.</p>\n </div>\n )}\n\n {/* Main video */}\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n {/* Self view */}\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n </div>\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <ScreenShareButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n});\n",
575
+ content: "import React, { useCallback, useEffect, useState } from 'react';\nimport {\n DailyAudioTrack,\n DailyVideo,\n useDevices,\n useLocalSessionId,\n useMeetingState,\n useScreenVideoTrack,\n useVideoTrack,\n} from '@daily-co/daily-react';\nimport { MicSelectBtn, CameraSelectBtn, ScreenShareButton } from '../device-select';\nimport { ConnectingState, LeavingState } from '../conversation-status';\nimport { useLocalScreenshare } from '../../hooks/use-local-screenshare';\nimport { useReplicaIDs } from '../../hooks/use-replica-ids';\nimport { useCVICall } from '../../hooks/use-cvi-call';\nimport { AudioWave } from '../audio-wave';\n\nimport styles from './conversation.module.css';\n\nconst VideoPreview = React.memo(({ id }) => {\n const videoState = useVideoTrack(id);\n const widthVideo = videoState.track?.getSettings()?.width;\n const heightVideo = videoState.track?.getSettings()?.height;\n const isVertical = widthVideo && heightVideo ? widthVideo < heightVideo : false;\n\n return (\n <div\n className={`${styles.previewVideoContainer} ${isVertical ? styles.previewVideoContainerVertical : ''} ${videoState.isOff ? styles.previewVideoContainerHidden : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={id}\n type=\"video\"\n className={`${styles.previewVideo} ${isVertical ? styles.previewVideoVertical : ''} ${videoState.isOff ? styles.previewVideoHidden : ''}`}\n />\n\n <div className={styles.audioWaveContainer}>\n <AudioWave id={id} />\n </div>\n </div>\n );\n});\n\nconst PreviewVideos = React.memo(() => {\n const localId = useLocalSessionId();\n const { isScreenSharing } = useLocalScreenshare();\n const replicaIds = useReplicaIDs();\n const replicaId = replicaIds[0];\n\n return (\n <>\n {isScreenSharing && <VideoPreview id={replicaId} />}\n <VideoPreview id={localId} />\n </>\n );\n});\n\nconst MainVideo = React.memo(() => {\n const replicaIds = useReplicaIDs();\n const localId = useLocalSessionId();\n const videoState = useVideoTrack(replicaIds[0]);\n const screenVideoState = useScreenVideoTrack(localId);\n const meetingState = useMeetingState();\n const isScreenSharing = !screenVideoState.isOff;\n // This is one-to-one call, so we can use the first replica id\n const replicaId = replicaIds[0];\n const [hasReplicaConnected, setHasReplicaConnected] = useState(false);\n\n useEffect(() => {\n if (replicaId && videoState.state === 'playable') {\n setHasReplicaConnected(true);\n }\n }, [replicaId, videoState.state]);\n\n if (meetingState === 'left-meeting' || meetingState === 'error') {\n return <LeavingState />;\n }\n\n if (!hasReplicaConnected) {\n return <ConnectingState />;\n }\n\n if (!replicaId) {\n return <ConnectingState />;\n }\n\n // Switching between replica video and screen sharing video\n return (\n <div\n className={`${styles.mainVideoContainer} ${isScreenSharing ? styles.mainVideoContainerScreenSharing : ''}`}\n >\n <DailyVideo\n automirror\n sessionId={isScreenSharing ? localId : replicaId}\n type={isScreenSharing ? 'screenVideo' : 'video'}\n className={`${styles.mainVideo}\n ${isScreenSharing ? styles.mainVideoScreenSharing : ''}\n ${videoState.isOff ? styles.mainVideoHidden : ''}`}\n />\n\n <DailyAudioTrack sessionId={replicaId} />\n </div>\n );\n});\n\nexport const Conversation = React.memo(({ onLeave, conversationUrl }) => {\n const { joinCall, leaveCall } = useCVICall();\n const meetingState = useMeetingState();\n const { hasMicError } = useDevices();\n\n useEffect(() => {\n if (meetingState === 'error') {\n onLeave();\n }\n }, [meetingState, onLeave]);\n\n // Initialize call when conversation is available\n useEffect(() => {\n joinCall({ url: conversationUrl });\n // Release the singleton call on unmount (see conversation-01): prevents\n // stale-room error cascades on the next conversation; StrictMode-safe.\n return () => {\n leaveCall();\n };\n }, []);\n\n const handleLeave = useCallback(() => {\n leaveCall();\n onLeave();\n }, [leaveCall, onLeave]);\n\n return (\n <div className={styles.containerWrapper}>\n <div className={styles.container}>\n <div className={styles.videoContainer}>\n {hasMicError && (\n <div className={styles.errorContainer}>\n <p>Camera or microphone access denied. Please check your settings and try again.</p>\n </div>\n )}\n\n {/* Main video */}\n <div className={styles.mainVideoContainer}>\n <MainVideo />\n </div>\n\n {/* Self view */}\n <div className={styles.selfViewContainer}>\n <PreviewVideos />\n </div>\n </div>\n\n <div\n className={`${styles.footer} ${meetingState === 'left-meeting' ? styles.footerLeaving : ''}`}\n aria-hidden={meetingState === 'left-meeting'}\n >\n <div className={styles.footerControls}>\n <MicSelectBtn />\n <CameraSelectBtn />\n <ScreenShareButton />\n <button type=\"button\" className={styles.leaveButton} onClick={handleLeave}>\n <span className={styles.leaveButtonIcon}>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n role=\"img\"\n aria-label=\"Leave Call\"\n >\n <path\n d=\"M18 6L6 18M6 6L18 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </span>\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n});\n",
548
576
  styles: ".containerWrapper {\n width: 100%;\n container-type: inline-size;\n container-name: conversation;\n}\n\n.container {\n position: relative;\n width: 100%;\n text-align: center;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n aspect-ratio: 9/16;\n overflow: hidden;\n border-radius: 0.5rem;\n max-height: 90vh;\n background: linear-gradient(135deg, #4b5563 0%, #1f2937 100%);\n background-size: 400% 400%;\n animation: gradient 15s ease infinite;\n}\n\n/* Aspect ratio tracks the wrapper's actual width: portrait when narrow,\n landscape when wide. */\n@container conversation (min-width: 768px) {\n .container {\n aspect-ratio: 16/9;\n }\n}\n\n.errorContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(248, 250, 252, 0.08);\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n text-align: center;\n}\n\n.videoContainer {\n position: relative;\n z-index: 5;\n width: 100%;\n height: 100%;\n}\n\n.footer {\n position: absolute;\n bottom: 1.5rem;\n left: 0;\n right: 0;\n z-index: 20;\n transition:\n opacity 0.3s ease,\n transform 0.3s ease;\n}\n\n.footerLeaving {\n opacity: 0;\n transform: translateY(20px);\n pointer-events: none;\n}\n\n.footerControls {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 16px;\n}\n\n.leaveButton {\n background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);\n color: white;\n border: none;\n font-size: 16px;\n cursor: pointer;\n transition: all 0.3s ease;\n height: 3rem;\n width: 3rem;\n border-radius: 9999px;\n background-color: #ef4444;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.leaveButton:hover {\n opacity: 0.8;\n}\n\n.leaveButtonIcon {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n/* ReplicaVideo styles */\n.mainVideoContainer {\n background: transparent;\n width: 100%;\n height: 100%;\n position: relative;\n}\n\n.mainVideoContainerScreenSharing {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.mainVideo {\n position: absolute;\n inset: 0;\n object-position: center;\n object-fit: cover !important;\n height: 100%;\n width: 100%;\n transition: all 0.3s ease;\n}\n\n.mainVideoScreenSharing {\n object-fit: contain !important;\n}\n\n.mainVideoHidden {\n display: none;\n}\n\n/* PreviewVideo styles */\n.previewVideoContainer {\n position: relative;\n background: rgba(2, 6, 23, 0.3);\n aspect-ratio: 16/9;\n width: 13rem;\n border-radius: 1rem;\n overflow: hidden;\n max-height: 140px;\n z-index: 10;\n}\n\n@container conversation (min-width: 768px) {\n .previewVideoContainer {\n width: 15rem;\n max-height: 160px;\n }\n}\n\n@container conversation (min-width: 1024px) {\n .previewVideoContainer {\n width: 18rem;\n max-height: 200px;\n }\n}\n\n.previewVideoContainerVertical {\n height: 40.5rem;\n width: 6rem;\n}\n\n.previewVideoContainerHidden {\n background: transparent;\n display: none;\n}\n\n.previewVideo {\n width: 100%;\n height: auto;\n max-height: 120px;\n}\n\n@container conversation (min-width: 768px) {\n .previewVideo {\n max-height: 100%;\n }\n}\n\n.previewVideoVertical {\n height: 40.5rem;\n width: 6rem;\n object-fit: cover;\n}\n\n.previewVideoHidden {\n display: none;\n}\n\n/* Main video container */\n.mainVideoContainer {\n width: 100%;\n height: 100%;\n}\n\n/* Self view container */\n.selfViewContainer {\n position: absolute;\n bottom: 5.5rem;\n left: 1rem;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n@container conversation (min-width: 1024px) {\n .selfViewContainer {\n bottom: 1rem;\n }\n}\n\n/* Waiting message container */\n/* Start of Selection */\n.waitingContainer {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: transparent;\n color: white;\n height: 100%;\n font-size: 1.5rem;\n font-weight: 600;\n}\n\n@keyframes gradient {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n}\n/* End of Selection */\n\n.audioWaveContainer {\n position: absolute;\n bottom: 0.5rem;\n right: 0.5rem;\n}\n",
549
577
  componentsDependencies: [
550
578
  "device-select",
@@ -597,6 +625,33 @@ var hair_check_01_default = {
597
625
  ]
598
626
  };
599
627
  //#endregion
628
+ //#region src/templates/jsx/components/magic-canvas.json
629
+ var magic_canvas_default = {
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",
633
+ extraFiles: [
634
+ {
635
+ "path": "allowlists.js",
636
+ "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) {\n let 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) {\n return isAllowedCanvasSandboxUrl(rawUrl);\n}\n\nexport function isAllowedCanvasApiUrl(rawUrl) {\n let 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) {\n return isLocalCanvasUrl(url);\n}\n\nfunction isLocalCanvasUrl(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"
637
+ },
638
+ {
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"
641
+ },
642
+ {
643
+ "path": "native-host.jsx",
644
+ "content": "// Data-only \"bring-your-own-renderer\" host for Magic Canvas. Not part of the\n// SHA-pinned parity set; it reuses the pinned runtime helpers as pure\n// functions so native and iframe paths emit identical downstream behavior.\n\nimport React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';\n\nimport styles from './magic-canvas.module.css';\nimport { postInteraction } from './bridge';\nimport {\n buildCanvasModelContextAppend,\n normalizeInteraction,\n shouldCompleteCanvasInteraction,\n} from './runtime';\n\nimport { useSendAppMessage } from '../../hooks/cvi-events-hooks';\n\n// Mirrors MODEL_CONTEXT_RELAY_INTERVAL_MS in index.tsx (the iframe relay);\n// duplicated here because index.tsx imports this module (no circular import).\nconst MODEL_CONTEXT_RELAY_INTERVAL_MS = 1000;\n\n// SoT decision 17 (PROD-3246): after a successful `submit` interaction, hold\n// the card for this long before completing/removing it, so the component's\n// submitted confirmation (\"Sent.\") is actually visible instead of lasting only\n// for the POST round-trip. Mirrors SUBMIT_CONFIRMATION_CLEAR_DELAY_MS in\n// magic-canvas-apps src/components/confirmation.ts; the two must move\n// together, but they cannot share an import across repos today (PROD-3238).\n// Skip/dismiss/clear stay instant, and failed POSTs never complete at all.\nexport const CANVAS_SUBMIT_TEARDOWN_DELAY_MS = 1200;\n\n// Single implementation shared by BOTH cvi paths (iframe CanvasFrame and\n// NativeCanvasHost) so the deferred-teardown semantics cannot drift between\n// them. Failure paths never reach this: callers stop on a failed delivery\n// (the F16 contract), so a failed POST neither defers nor completes.\nexport function createCanvasCompletionScheduler(isClosed) {\n let timer = null;\n\n return {\n complete(interaction, onComplete) {\n if (!shouldCompleteCanvasInteraction(interaction)) return;\n\n if (interaction.type !== 'submit') {\n onComplete();\n return;\n }\n\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null;\n if (!isClosed()) onComplete();\n }, CANVAS_SUBMIT_TEARDOWN_DELAY_MS);\n },\n cancel() {\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n },\n };\n}\n\n/**\n * Data-only props handed to a consumer-supplied renderer: the instance's\n * validated `{ component, version, args }` plus callbacks that drive the same\n * downstream behavior as the iframe path.\n */\n\n/** A consumer-supplied native renderer for a single `component@version`. */\n\n/**\n * Registry of native renderers keyed by `\"<component>@<version>\"`. When\n * undefined or missing an entry, the iframe (`CanvasFrame`) path is used\n * unchanged — the default-off guarantee.\n */\n\n/** Build the registry key for an instance's component + version. */\nexport function canvasRendererKey(component, version) {\n return `${component}@${version}`;\n}\n\n// Shared interaction delivery for the iframe and native paths: run the\n// consumer onInteraction callback (a throw is reported but does not block the\n// POST), then POST the interaction row. A failed POST is reported through the\n// onError channel AND returned as `ok: false` so callers stop before\n// respond/complete instead of signaling success while the canvas_interaction\n// row is silently missing. Mirrors the tavus-deployment host ordering.\nexport async function deliverCanvasInteraction(options) {\n const { interaction, canvasConfig, onInteraction, reportError, buildError } = options;\n\n if (onInteraction) {\n try {\n await onInteraction(interaction);\n } catch (error) {\n reportError(\n buildError(\n 'on_interaction_callback_failed',\n 'Magic Canvas onInteraction callback threw.',\n error\n )\n );\n }\n }\n\n try {\n await postInteraction(interaction, canvasConfig);\n } catch (error) {\n const postError = buildError(\n 'interaction_post_failed',\n 'Magic Canvas interaction POST failed.',\n error\n );\n reportError(postError);\n return { ok: false, error: postError };\n }\n\n return { ok: true };\n}\n\n/**\n * Resolve the native renderer for an instance, or `null` to fall back to the\n * iframe path. Single chokepoint for default-off: no registry or no matching\n * entry → `null`.\n */\nexport function resolveCanvasRenderer(registry, instance) {\n if (!registry) return null;\n const key = canvasRendererKey(\n instance.canvas_config.component,\n instance.canvas_config.component_version\n );\n return registry[key] ?? null;\n}\n\nexport const NativeCanvasHost = memo(\n ({ instance, layout, render, dimmed, onComplete, onInteraction, onError }) => {\n const instanceRef = useRef(instance);\n const sendAppMessage = useSendAppMessage();\n const sendAppMessageRef = useRef(sendAppMessage);\n const onCompleteRef = useRef(onComplete);\n const onInteractionRef = useRef(onInteraction);\n const onErrorRef = useRef(onError);\n // Mirror CanvasFrame's `closed` flag: in-flight submit continuations must\n // not fire callbacks after unmount (a stale onComplete could clear an\n // unrelated instance that reused the same id).\n const closedRef = useRef(false);\n\n // Decision 17 deferred-submit completion, shared with CanvasFrame. One\n // scheduler per host; the unmount cleanup cancels any pending defer so a\n // late completion can never fire on behalf of a dead host.\n const completionSchedulerRef = useRef(null);\n if (!completionSchedulerRef.current) {\n completionSchedulerRef.current = createCanvasCompletionScheduler(() => closedRef.current);\n }\n const completionScheduler = completionSchedulerRef.current;\n\n useEffect(() => {\n closedRef.current = false;\n return () => {\n closedRef.current = true;\n completionScheduler.cancel();\n };\n }, [completionScheduler]);\n\n useEffect(() => {\n instanceRef.current = instance;\n }, [instance]);\n\n useEffect(() => {\n sendAppMessageRef.current = sendAppMessage;\n onCompleteRef.current = onComplete;\n onInteractionRef.current = onInteraction;\n onErrorRef.current = onError;\n }, [sendAppMessage, onComplete, onInteraction, onError]);\n\n const buildHostError = useCallback((code, defaultMessage, cause) => {\n const current = instanceRef.current;\n return {\n code,\n message: cause instanceof Error && cause.message ? cause.message : defaultMessage,\n conversation_id: current.conversation_id,\n tool_call_id: current.tool_call_id,\n component: current.canvas_config.component,\n cause,\n };\n }, []);\n\n const reportError = useCallback((event) => {\n if (!closedRef.current) onErrorRef.current?.(event);\n }, []);\n\n // submit(value) → the SAME pure helpers + host path CanvasFrame uses:\n // normalizeInteraction → onInteraction → postInteraction →\n // shouldCompleteCanvasInteraction drives completion/clear.\n const submit = useCallback(\n (interactionPayload) => {\n const current = instanceRef.current;\n const payload = {\n type: 'submit',\n ...interactionPayload,\n };\n\n let interaction;\n try {\n interaction = normalizeInteraction(current, payload);\n } catch (error) {\n reportError(\n buildHostError(\n 'interaction_normalization_failed',\n 'Magic Canvas failed to normalize an interaction.',\n error\n )\n );\n return;\n }\n\n void (async () => {\n const delivery = await deliverCanvasInteraction({\n interaction,\n canvasConfig: current.canvas_config,\n onInteraction: onInteractionRef.current,\n reportError,\n buildError: buildHostError,\n });\n\n // A teardown can race the awaited delivery: don't complete an\n // unmounted host. A failed POST must not complete (and clear)\n // the card either: the interaction row was never recorded, so\n // the renderer stays up and the consumer keeps a retry signal\n // via onError.\n if (closedRef.current || !delivery.ok) return;\n\n // Decision 17: submit defers by CANVAS_SUBMIT_TEARDOWN_DELAY_MS\n // so the renderer's confirmation stays visible; everything else\n // completes instantly.\n completionScheduler.complete(interaction, () => onCompleteRef.current?.());\n })();\n },\n [buildHostError, completionScheduler, reportError]\n );\n\n // sendContext(message) → the append_llm_context path, throttled like\n // CanvasFrame's onupdatemodelcontext relay (latest update wins,\n // trailing-edge flush) so a chatty renderer can't flood the channel.\n const pendingContextUpdateRef = useRef(null);\n const contextRelayTimerRef = useRef(null);\n\n const flushContextUpdate = useCallback(() => {\n const update = pendingContextUpdateRef.current;\n pendingContextUpdateRef.current = null;\n if (!update) return;\n const current = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.append_llm_context',\n conversation_id: current.conversation_id,\n properties: {\n context: buildCanvasModelContextAppend(current, update),\n },\n });\n }, []);\n\n const sendContext = useCallback(\n (update) => {\n pendingContextUpdateRef.current = update;\n if (!contextRelayTimerRef.current) {\n contextRelayTimerRef.current = setTimeout(() => {\n contextRelayTimerRef.current = null;\n flushContextUpdate();\n }, MODEL_CONTEXT_RELAY_INTERVAL_MS);\n }\n },\n [flushContextUpdate]\n );\n\n // Mirror the iframe teardown: clear the timer and flush the last buffered\n // update instead of dropping it.\n useEffect(() => {\n return () => {\n if (contextRelayTimerRef.current) {\n clearTimeout(contextRelayTimerRef.current);\n contextRelayTimerRef.current = null;\n }\n flushContextUpdate();\n };\n }, [flushContextUpdate]);\n\n // respond(text) → the conversation.respond path CanvasFrame uses.\n const respond = useCallback((text) => {\n const current = instanceRef.current;\n sendAppMessageRef.current({\n message_type: 'conversation',\n event_type: 'conversation.respond',\n conversation_id: current.conversation_id,\n properties: { text },\n });\n }, []);\n\n const onRendererError = useCallback(\n (error) => {\n reportError(\n buildHostError(\n 'on_interaction_callback_failed',\n 'Magic Canvas native renderer reported an error.',\n error\n )\n );\n },\n [buildHostError, reportError]\n );\n\n const rendererProps = useMemo(\n () => ({\n component: instance.canvas_config.component,\n version: instance.canvas_config.component_version,\n args: instance.arguments,\n submit,\n sendContext,\n respond,\n onError: onRendererError,\n }),\n [\n instance.canvas_config.component,\n instance.canvas_config.component_version,\n instance.arguments,\n submit,\n sendContext,\n respond,\n onRendererError,\n ]\n );\n\n const isFull = layout.viable_slot === 'full' || layout.display_mode === 'fullscreen';\n\n return (\n <div\n className={[\n styles.frameShell,\n styles.frameShellReady,\n isFull ? styles.frameShellFull : '',\n dimmed ? styles.frameShellDimmed : '',\n ]\n .filter(Boolean)\n .join(' ')}\n data-magic-canvas-native=\"\"\n data-component={instance.canvas_config.component}\n data-component-version={instance.canvas_config.component_version}\n data-dimmed={dimmed ? '' : undefined}\n style={{ height: isFull ? '100%' : undefined }}\n >\n {render(rendererProps)}\n </div>\n );\n }\n);\n\nNativeCanvasHost.displayName = 'NativeCanvasHost';\n"
645
+ },
646
+ {
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"
649
+ }
650
+ ],
651
+ componentsDependencies: ["cvi-events-hooks"],
652
+ dependencies: ["@modelcontextprotocol/ext-apps@^1.7.1"]
653
+ };
654
+ //#endregion
600
655
  //#region src/templates/jsx/components/media-controls.json
601
656
  var media_controls_default = {
602
657
  type: "components",
@@ -700,6 +755,7 @@ var jsx_exports = /* @__PURE__ */ __exportAll({
700
755
  "cvi-provider": () => cvi_provider_default,
701
756
  "device-select": () => device_select_default,
702
757
  "hair-check-01": () => hair_check_01_default,
758
+ "magic-canvas": () => magic_canvas_default,
703
759
  "media-controls": () => media_controls_default,
704
760
  "use-chat": () => use_chat_default,
705
761
  "use-closed-caption": () => use_closed_caption_default,
@@ -817,6 +873,10 @@ const components = [
817
873
  name: "hair-check-01",
818
874
  path: "components/hair-check-01"
819
875
  },
876
+ {
877
+ name: "magic-canvas",
878
+ path: "components/magic-canvas"
879
+ },
820
880
  {
821
881
  name: "media-controls",
822
882
  path: "components/media-controls"
@@ -959,6 +1019,12 @@ async function updateFiles(files, config, options) {
959
1019
  const cssPath = path$1.join(targetDir, `${fileDisplayName}.module.css`);
960
1020
  await promises.writeFile(cssPath, template.styles);
961
1021
  }
1022
+ if (template.extraFiles && template.extraFiles.length > 0) for (const extraFile of template.extraFiles) {
1023
+ const extraPath = path$1.join(targetDir, extraFile.path);
1024
+ const extraDir = path$1.dirname(extraPath);
1025
+ if (!existsSync(extraDir)) await promises.mkdir(extraDir, { recursive: true });
1026
+ await promises.writeFile(extraPath, extraFile.content);
1027
+ }
962
1028
  } else {
963
1029
  logger.warn(`Component ${actualComponentName} is not yet implemented`);
964
1030
  continue;
@@ -1373,7 +1439,7 @@ const info = new Command().name("info").description("get information about your
1373
1439
  });
1374
1440
  //#endregion
1375
1441
  //#region package.json
1376
- var version = "0.0.3";
1442
+ var version = "0.0.4-beta.1";
1377
1443
  //#endregion
1378
1444
  //#region src/index.ts
1379
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.3",
3
+ "version": "0.0.4-beta.1",
4
4
  "description": "A CLI tool for installing and managing CVI components",
5
5
  "keywords": [
6
6
  "ai-replica",
@@ -34,14 +34,17 @@
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit",
37
- "build": "npm run typecheck && vp pack",
37
+ "build": "npm run typecheck && npm run check:canvas-runtime-parity && 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
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",
44
+ "test:contract": "node --test \"tests/**/*.test.mjs\"",
42
45
  "clean": "rm -rf jsx-templates dist src/templates",
43
- "build-all": "npm run clean && npm run build:templates && npm run build && vp check --fix",
44
- "test": "npm run build:templates && vp test run test/unit test/integration",
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
48
  "test:watch": "vp test watch",
46
49
  "test:unit": "npm run build:templates && vp test run test/unit",
47
50
  "test:integration": "npm run build-all && vp test run test/integration",
@@ -85,6 +88,7 @@
85
88
  "@types/prompts": "^2.4.9",
86
89
  "@types/update-notifier": "^6.0.8",
87
90
  "concurrently": "^9.2.1",
91
+ "happy-dom": "^20.10.2",
88
92
  "prettier": "^3.8.3",
89
93
  "tslib": "^2.8.1",
90
94
  "type-fest": "^5.6.0",