From 5afc53e3e6c22c9ee5cb6e870f6ef35053b7f39e Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Mon, 23 Mar 2026 14:59:02 +0100 Subject: [PATCH 1/7] refactor: correct event naming and payload handling --- README.md | 75 +++++---- demo/demo.ts | 214 +++++++++++++------------- demo/react-demo.tsx | 122 +++++++-------- docs/react-usage.md | 46 +++--- src/CortiEmbedded.ts | 11 +- src/react/CortiEmbeddedReact.ts | 18 +-- test/corti-embedded.test.ts | 261 ++++++++++++++++---------------- 7 files changed, 370 insertions(+), 377 deletions(-) diff --git a/README.md b/README.md index f331eba..63064d6 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ await myComponent.show() #### Generic Event Listener (Web Component) -Use `embedded-event` as the canonical event stream for web component integrations. +Use `event` as the canonical event stream for web component integrations. - Event detail shape is `{ name: string; payload: unknown }`. - Full event catalog and payload details are documented at: - https://docs.corti.ai/assistant/events ```js -myComponent.addEventListener('embedded-event', event => { +myComponent.addEventListener("event", event => { const { detail } = event; console.log(detail.name, detail.payload); }); @@ -67,13 +67,13 @@ myComponent.addEventListener('embedded-event', event => { ### React Component ```tsx -import React, { useRef } from 'react'; +import React, { useRef } from "react"; import { CortiEmbeddedReact, type CortiEmbeddedReactRef, useCortiEmbeddedApi, useCortiEmbeddedStatus, -} from '@corti/embedded-web/react'; +} from "@corti/embedded-web/react"; function App() { const cortiRef = useRef(null); @@ -81,39 +81,39 @@ function App() { const { status } = useCortiEmbeddedStatus(cortiRef); const handleReady = () => { - console.log('Corti component is ready!'); + console.log("Corti component is ready!"); }; const handleEvent = (detail: { name: string; payload: unknown }) => { - console.log('Event name:', detail.name); - console.log('Event payload:', detail.payload); + console.log("Event name:", detail.name); + console.log("Event payload:", detail.payload); }; const handleAuth = async () => { try { const user = await api.auth({ - access_token: 'your-token', - token_type: 'Bearer', + access_token: "your-token", + token_type: "Bearer", // ... rest of the token response }); - console.log('Authenticated:', user); + console.log("Authenticated:", user); - await api.configureSession({ defaultTemplateKey: 'soap_note' }); + await api.configureSession({ defaultTemplateKey: "soap_note" }); await api.createInteraction({ encounter: { identifier: `encounter-${Date.now()}`, - status: 'planned', - type: 'first_consultation', + status: "planned", + type: "first_consultation", period: { startedAt: new Date().toISOString() }, }, }); } catch (error) { - console.error('Auth failed:', error); + console.error("Auth failed:", error); } }; return ( -
+
console.error('Embedded error:', detail)} - style={{ width: '100%', height: '500px' }} + onError={detail => console.error("Embedded error:", detail)} + style={{ width: "100%", height: "500px" }} />
{JSON.stringify(status, null, 2)}
@@ -135,7 +135,7 @@ function App() { ### Show/Hide the Component ```javascript -const component = document.getElementById('corti-component'); +const component = document.getElementById("corti-component"); // Show the chat interface component.show(); @@ -174,10 +174,10 @@ const authResponse = await component.auth({ ```javascript await component.configureSession({ - defaultLanguage: 'en', - defaultOutputLanguage: 'en', - defaultTemplateKey: 'discharge-summary', - defaultMode: 'virtual', + defaultLanguage: "en", + defaultOutputLanguage: "en", + defaultTemplateKey: "discharge-summary", + defaultMode: "virtual", }); ``` @@ -193,7 +193,7 @@ await component.addFacts([ #### navigate ```javascript -await component.navigate('/interactions/123'); +await component.navigate("/interactions/123"); ``` #### createInteraction @@ -202,14 +202,14 @@ await component.navigate('/interactions/123'); const created = await component.createInteraction({ assignedUserId: null, encounter: { - identifier: 'enc-123', - status: 'in-progress', - type: 'consult', + identifier: "enc-123", + status: "in-progress", + type: "consult", period: { startedAt: new Date().toISOString() }, - title: 'Visit for cough', + title: "Visit for cough", }, patient: { - identifier: 'pat-456', + identifier: "pat-456", }, }); ``` @@ -242,7 +242,7 @@ The component uses a `PostMessageHandler` utility class that: The React component (`CortiEmbeddedReact`) is available as an additional export and provides: - **Hook-based API access**: `useCortiEmbeddedApi(ref)` exposes instance-bound methods (`auth`, `navigate`, `createInteraction`, etc.) -- **Generic event stream**: `onEvent` receives all embedded events as `{ name, payload }` +- **Generic event stream**: `onEvent` receives all embedded events as `CustomEvent<{ name, payload }>` - **Status hook**: `useCortiEmbeddedStatus(ref)` keeps latest status/reactive state - **Multi-instance safety**: API methods are scoped to the ref you pass - **React Props**: Standard React props like `className`, `style`, etc. @@ -256,28 +256,27 @@ import { type CortiEmbeddedReactRef, useCortiEmbeddedApi, useCortiEmbeddedStatus, -} from '@corti/embedded-web/react'; +} from "@corti/embedded-web/react"; ``` ### Event Listener Setup - Use `onEvent` for all embedded events. -- Event detail shape is `{ name: string; payload: unknown }`. +- Event detail shape is `CustomEvent<{ name: string; payload: unknown }>` via `event.detail`. - `onReady` fires when the raw `embedded.ready` event is received. -- `onEvent` receives the generic `embedded-event` stream. +- `onEvent` receives the raw generic `event` stream. - Full event catalog and payload details are documented at: - https://docs.corti.ai/assistant/events -- The event listeners for `onEvent`, `onError` and `onReady` are unwrapping the CustomEvent emitted by the Lit component and cleanly return the "detail" - that contains the payload of the event. This makes it easier to work with the events in React without having to deal with the CustomEvent wrapper. +- `onEvent` passes through the raw `CustomEvent`, while `onError` and `onReady` still receive the event detail directly. ```tsx { - if (detail.name === 'ready' || detail.name === 'loaded') return; - console.log(detail.name, detail.payload); + onEvent={event => { + if (event.detail.name === "ready" || event.detail.name === "loaded") return; + console.log(event.detail.name, event.detail.payload); }} - onReady={() => console.log('Ready')} + onReady={() => console.log("Ready")} onError={detail => console.error(detail)} /> ``` diff --git a/demo/demo.ts b/demo/demo.ts index 375bac2..c4dd76c 100644 --- a/demo/demo.ts +++ b/demo/demo.ts @@ -6,7 +6,7 @@ import type { SessionConfig, GetStatusResponse, KeycloakTokenResponse, -} from '../dist'; +} from "../dist"; interface CortiEmbeddedEventDetail { name: string; @@ -21,22 +21,22 @@ interface CortiEmbeddedErrorDetail { // Get the component with proper typing — querySelector('corti-embedded') is // automatically typed as CortiEmbeddedElement via HTMLElementTagNameMap -const component = document.querySelector('corti-embedded'); +const component = document.querySelector("corti-embedded"); // Define log entry types -type LogType = 'info' | 'success' | 'error' | 'warning'; +type LogType = "info" | "success" | "error" | "warning"; // Utils export const clearLog = (): void => { - const logElement = document.getElementById('log'); + const logElement = document.getElementById("log"); if (!logElement) return; logElement.innerHTML = '
Log cleared...
'; }; -export const addLogEntry = (message: string, type: LogType = 'info'): void => { - const logElement = document.getElementById('log'); +export const addLogEntry = (message: string, type: LogType = "info"): void => { + const logElement = document.getElementById("log"); if (!logElement) return; - const entry = document.createElement('div'); + const entry = document.createElement("div"); entry.className = `log-entry log-${type}`; entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; logElement.appendChild(entry); @@ -44,19 +44,19 @@ export const addLogEntry = (message: string, type: LogType = 'info'): void => { }; export const updateStatus = (): void => { - const statusElement = document.getElementById('status'); + const statusElement = document.getElementById("status"); if (!statusElement) return; if (component) { - const baseURL = component.getAttribute('baseURL'); + const baseURL = component.getAttribute("baseURL"); statusElement.innerHTML = ` Current Status:
Base URL: ${baseURL}
- Component Ready: ${typeof component.show === 'function' && typeof component.hide === 'function' ? 'Yes' : 'No'}
+ Component Ready: ${typeof component.show === "function" && typeof component.hide === "function" ? "Yes" : "No"}
`; } else { - statusElement.innerHTML = 'Status: Component not found'; + statusElement.innerHTML = "Status: Component not found"; } }; @@ -65,7 +65,7 @@ export const showCorti = (): void => { if (component?.show) { component.show(); updateStatus(); - addLogEntry('Corti component shown', 'info'); + addLogEntry("Corti component shown", "info"); } }; @@ -73,7 +73,7 @@ export const hideCorti = (): void => { if (component?.hide) { component.hide(); updateStatus(); - addLogEntry('Corti component hidden', 'info'); + addLogEntry("Corti component hidden", "info"); } }; @@ -82,7 +82,7 @@ export const testAuthentication = async (): Promise => { try { // Parse the JSON from the textarea const authPayloadElement = document.getElementById( - 'auth-payload', + "auth-payload", ) as HTMLTextAreaElement; const authPayloadText = authPayloadElement.value; let authPayload: KeycloakTokenResponse; @@ -91,23 +91,23 @@ export const testAuthentication = async (): Promise => { authPayload = JSON.parse(authPayloadText); } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } addLogEntry( `Sending authentication request with payload: ${JSON.stringify(authPayload)}`, - 'info', + "info", ); await component.auth(authPayload); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - console.log('Auth failed - ', errorMessage); + error instanceof Error ? error.message : "Unknown error"; + console.log("Auth failed - ", errorMessage); } } else { - addLogEntry('Component not ready for authentication', 'error'); + addLogEntry("Component not ready for authentication", "error"); } }; @@ -116,7 +116,7 @@ export const configureSession = async (): Promise => { try { // Parse the JSON from the textarea const payloadElement = document.getElementById( - 'configure-session-payload', + "configure-session-payload", ) as HTMLTextAreaElement; const payloadText = payloadElement.value; let payload: SessionConfig; @@ -125,23 +125,23 @@ export const configureSession = async (): Promise => { payload = JSON.parse(payloadText) as SessionConfig; } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } addLogEntry( `Configuring session with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await component.configureSession(payload); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Session configuration failed: ${errorMessage}`); } } else { - addLogEntry('Component not ready for configureSession', 'error'); + addLogEntry("Component not ready for configureSession", "error"); } }; @@ -150,7 +150,7 @@ export const addFacts = async (): Promise => { try { // Parse the JSON from the textarea const payloadElement = document.getElementById( - 'add-facts-payload', + "add-facts-payload", ) as HTMLTextAreaElement; const payloadText = payloadElement.value; let payload: Fact[]; @@ -159,23 +159,23 @@ export const addFacts = async (): Promise => { payload = JSON.parse(payloadText) as Fact[]; } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } addLogEntry( `Adding facts with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await component.addFacts(payload); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Add facts failed: ${errorMessage}`); } } else { - addLogEntry('Component not ready for addFacts', 'error'); + addLogEntry("Component not ready for addFacts", "error"); } }; @@ -184,7 +184,7 @@ export const navigate = async (): Promise => { try { // Parse the JSON from the textarea const payloadElement = document.getElementById( - 'navigate-payload', + "navigate-payload", ) as HTMLTextAreaElement; const payloadText = payloadElement.value; let payload: { path: string }; @@ -193,23 +193,23 @@ export const navigate = async (): Promise => { payload = JSON.parse(payloadText) as { path: string }; } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } addLogEntry( `Navigating with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await component.navigate(payload.path); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Navigation failed: ${errorMessage}`); } } else { - addLogEntry('Component not ready for navigate', 'error'); + addLogEntry("Component not ready for navigate", "error"); } }; @@ -218,7 +218,7 @@ export const createInteraction = async (): Promise => { try { // Parse the JSON from the textarea const payloadElement = document.getElementById( - 'create-interaction-payload', + "create-interaction-payload", ) as HTMLTextAreaElement; const payloadText = payloadElement.value; let payload: CreateInteractionPayload; @@ -227,14 +227,14 @@ export const createInteraction = async (): Promise => { payload = JSON.parse(payloadText); } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } addLogEntry( `Creating interaction with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); const response: InteractionDetails = await component.createInteraction(payload); @@ -244,102 +244,102 @@ export const createInteraction = async (): Promise => { const interactionId = response.id; if (interactionId) { const navTextarea = document.getElementById( - 'navigate-payload', + "navigate-payload", ) as HTMLTextAreaElement; if (navTextarea) { try { const navPayload = JSON.parse(navTextarea.value); if ( navPayload && - typeof navPayload === 'object' && - 'path' in navPayload + typeof navPayload === "object" && + "path" in navPayload ) { navPayload.path = String(navPayload.path).replace( - '{interaction_id}', + "{interaction_id}", interactionId, ); navTextarea.value = JSON.stringify(navPayload, null, 2); } else { // Fallback to string replace if unexpected structure navTextarea.value = navTextarea.value.replace( - '{interaction_id}', + "{interaction_id}", interactionId, ); } } catch { // Fallback to string replace if JSON is invalid navTextarea.value = navTextarea.value.replace( - '{interaction_id}', + "{interaction_id}", interactionId, ); } addLogEntry( `Navigate payload updated with interaction ID: ${interactionId}`, - 'success', + "success", ); } } } catch (updateError) { const errorMessage = - updateError instanceof Error ? updateError.message : 'Unknown error'; + updateError instanceof Error ? updateError.message : "Unknown error"; console.error(`Failed to update navigate payload: ${errorMessage}`); } } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Interaction creation failed: ${errorMessage}`); } } else { - addLogEntry('Component not ready for createInteraction', 'error'); + addLogEntry("Component not ready for createInteraction", "error"); } }; export const startRecording = async (): Promise => { if (component?.startRecording) { try { - addLogEntry('Starting recording...', 'info'); + addLogEntry("Starting recording...", "info"); await component.startRecording(); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Failed to start recording: ${errorMessage}`); } } else { - addLogEntry('Component not ready for startRecording', 'error'); + addLogEntry("Component not ready for startRecording", "error"); } }; export const stopRecording = async (): Promise => { if (component?.stopRecording) { try { - addLogEntry('Stopping recording...', 'info'); + addLogEntry("Stopping recording...", "info"); await component.stopRecording(); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Failed to stop recording: ${errorMessage}`); } } else { - addLogEntry('Component not ready for stopRecording', 'error'); + addLogEntry("Component not ready for stopRecording", "error"); } }; export const getStatus = async (): Promise => { if (component?.getStatus) { try { - addLogEntry('Getting component status...', 'info'); + addLogEntry("Getting component status...", "info"); const status: GetStatusResponse = await component.getStatus(); addLogEntry( `Component status: ${JSON.stringify(status, null, 2)}`, - 'success', + "success", ); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Failed to get status: ${errorMessage}`); } } else { - addLogEntry('Component not ready for getStatus', 'error'); + addLogEntry("Component not ready for getStatus", "error"); } }; @@ -348,7 +348,7 @@ export const configure = async (): Promise => { try { // Parse the JSON from the textarea const payloadElement = document.getElementById( - 'configure-payload', + "configure-payload", ) as HTMLTextAreaElement; const payloadText = payloadElement.value; let payload: ConfigureAppPayload; @@ -357,23 +357,23 @@ export const configure = async (): Promise => { payload = JSON.parse(payloadText) as ConfigureAppPayload; } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } addLogEntry( `Configuring app with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await component.configure(payload); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`App configuration failed: ${errorMessage}`); } } else { - addLogEntry('Component not ready for configure', 'error'); + addLogEntry("Component not ready for configure", "error"); } }; @@ -382,7 +382,7 @@ export const setCredentials = async (): Promise => { try { // Parse the JSON from the textarea const payloadElement = document.getElementById( - 'set-credentials-payload', + "set-credentials-payload", ) as HTMLTextAreaElement; const payloadText = payloadElement.value; let payload: { password: string }; @@ -391,20 +391,20 @@ export const setCredentials = async (): Promise => { payload = JSON.parse(payloadText) as { password: string }; } catch (jsonError) { const errorMessage = - jsonError instanceof Error ? jsonError.message : 'Unknown JSON error'; - addLogEntry(`Invalid JSON in payload: ${errorMessage}`, 'error'); + jsonError instanceof Error ? jsonError.message : "Unknown JSON error"; + addLogEntry(`Invalid JSON in payload: ${errorMessage}`, "error"); return; } - addLogEntry('Setting credentials...', 'info'); + addLogEntry("Setting credentials...", "info"); await component.setCredentials(payload); } catch (error) { const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : "Unknown error"; console.error(`Failed to set credentials: ${errorMessage}`); } } else { - addLogEntry('Component not ready for setCredentials', 'error'); + addLogEntry("Component not ready for setCredentials", "error"); } }; @@ -449,10 +449,10 @@ Object.assign(window, { }); // Initialize demo when DOM is ready -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener("DOMContentLoaded", () => { // Populate create-interaction-payload with current date const createInteractionPayload = document.getElementById( - 'create-interaction-payload', + "create-interaction-payload", ) as HTMLTextAreaElement; if (createInteractionPayload) { const currentDate = new Date().toISOString(); @@ -461,12 +461,12 @@ document.addEventListener('DOMContentLoaded', () => { const payload = { encounter: { identifier: randomId, - status: 'planned', - type: 'first_consultation', + status: "planned", + type: "first_consultation", period: { startedAt: currentDate, }, - title: 'Initial Consultation', + title: "Initial Consultation", }, }; @@ -474,52 +474,52 @@ document.addEventListener('DOMContentLoaded', () => { } // Add event listeners to buttons - document.getElementById('show-btn')?.addEventListener('click', showCorti); - document.getElementById('hide-btn')?.addEventListener('click', hideCorti); + document.getElementById("show-btn")?.addEventListener("click", showCorti); + document.getElementById("hide-btn")?.addEventListener("click", hideCorti); document - .getElementById('auth-btn') - ?.addEventListener('click', testAuthentication); - document.getElementById('clear-log-btn')?.addEventListener('click', clearLog); + .getElementById("auth-btn") + ?.addEventListener("click", testAuthentication); + document.getElementById("clear-log-btn")?.addEventListener("click", clearLog); document - .getElementById('create-interaction-btn') - ?.addEventListener('click', createInteraction); + .getElementById("create-interaction-btn") + ?.addEventListener("click", createInteraction); document - .getElementById('configure-session-btn') - ?.addEventListener('click', configureSession); - document.getElementById('add-facts-btn')?.addEventListener('click', addFacts); - document.getElementById('navigate-btn')?.addEventListener('click', navigate); + .getElementById("configure-session-btn") + ?.addEventListener("click", configureSession); + document.getElementById("add-facts-btn")?.addEventListener("click", addFacts); + document.getElementById("navigate-btn")?.addEventListener("click", navigate); document - .getElementById('start-recording-btn') - ?.addEventListener('click', startRecording); + .getElementById("start-recording-btn") + ?.addEventListener("click", startRecording); document - .getElementById('stop-recording-btn') - ?.addEventListener('click', stopRecording); + .getElementById("stop-recording-btn") + ?.addEventListener("click", stopRecording); document - .getElementById('get-status-btn') - ?.addEventListener('click', getStatus); + .getElementById("get-status-btn") + ?.addEventListener("click", getStatus); document - .getElementById('configure-btn') - ?.addEventListener('click', configure); + .getElementById("configure-btn") + ?.addEventListener("click", configure); document - .getElementById('set-credentials-btn') - ?.addEventListener('click', setCredentials); + .getElementById("set-credentials-btn") + ?.addEventListener("click", setCredentials); }); // Initialize when component is defined -customElements.whenDefined('corti-embedded').then(() => { +customElements.whenDefined("corti-embedded").then(() => { updateStatus(); - addLogEntry('Corti component loaded and ready', 'success'); + addLogEntry("Corti component loaded and ready", "success"); - component?.addEventListener('embedded-event', (event: Event) => { + component?.addEventListener("event", (event: Event) => { const { detail } = event as CustomEvent; - if (detail.name === 'ready') return; + if (detail.name === "ready") return; addLogEntry( `[EMBEDDED-EVENT] - ${detail.name}: ${JSON.stringify(detail.payload)}`, - 'info', + "info", ); }); - component?.addEventListener('error', (event: Event) => { + component?.addEventListener("error", (event: Event) => { const { detail } = event as CustomEvent; - addLogEntry(`[ERROR] - ${JSON.stringify(detail)}`, 'error'); + addLogEntry(`[ERROR] - ${JSON.stringify(detail)}`, "error"); }); }); diff --git a/demo/react-demo.tsx b/demo/react-demo.tsx index a5aa846..02fd351 100644 --- a/demo/react-demo.tsx +++ b/demo/react-demo.tsx @@ -1,8 +1,8 @@ -import { useCallback, useRef, useState } from 'react'; -import { createRoot } from 'react-dom/client'; +import { useCallback, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; import { type KeycloakTokenResponse, - type CortiEmbeddedEventDetail, + type CortiEmbeddedEvent, type CortiEmbeddedErrorDetail, type ConfigureAppPayload, CortiEmbeddedReact, @@ -11,7 +11,7 @@ import { type SessionConfig, useCortiEmbeddedApi, useCortiEmbeddedStatus, -} from '../dist/index.js'; +} from "../dist/index.js"; function CortiEmbeddedDemo() { const componentRef = useRef(null); @@ -20,14 +20,14 @@ function CortiEmbeddedDemo() { Array<{ time: string; message: string; type: string }> >([]); const [isReady, setIsReady] = useState(false); - const baseURL = 'https://assistant.eu.corti.app'; + const baseURL = "https://assistant.eu.corti.app"; // Form states const [authPayload, setAuthPayload] = useState( JSON.stringify( { - token: 'test-token-123', - userId: 'demo-user', + token: "test-token-123", + userId: "demo-user", }, null, 2, @@ -53,12 +53,12 @@ function CortiEmbeddedDemo() { { encounter: { identifier: `encounter-${Date.now()}`, - status: 'planned', - type: 'inpatient', + status: "planned", + type: "inpatient", period: { startedAt: new Date().toISOString(), }, - title: 'New Encounter', + title: "New Encounter", }, }, null, @@ -70,10 +70,10 @@ function CortiEmbeddedDemo() { useState( JSON.stringify( { - defaultLanguage: 'en', - defaultOutputLanguage: 'en', - defaultTemplateKey: 'soap_note', - defaultMode: 'virtual', + defaultLanguage: "en", + defaultOutputLanguage: "en", + defaultTemplateKey: "soap_note", + defaultMode: "virtual", }, null, 2, @@ -83,11 +83,11 @@ function CortiEmbeddedDemo() { const [addFactsPayload, setAddFactsPayload] = useState( JSON.stringify( [ - { text: 'Chest pain', group: 'other' }, - { text: 'Shortness of breath', group: 'other' }, - { text: 'Fatigue', group: 'other' }, - { text: 'Dizziness', group: 'other' }, - { text: 'Nausea', group: 'other' }, + { text: "Chest pain", group: "other" }, + { text: "Shortness of breath", group: "other" }, + { text: "Fatigue", group: "other" }, + { text: "Dizziness", group: "other" }, + { text: "Nausea", group: "other" }, ], null, 2, @@ -95,12 +95,12 @@ function CortiEmbeddedDemo() { ); const [navigatePayload, setNavigatePayload] = useState( - '/session/{interaction_id}', + "/session/{interaction_id}", ); // Helper to add log entries const addLogEntry = useCallback( - (message: string, type: 'info' | 'success' | 'error' = 'info') => { + (message: string, type: "info" | "success" | "error" = "info") => { const time = new Date().toLocaleTimeString(); setLog(prev => [...prev, { time, message, type }]); }, @@ -116,7 +116,7 @@ function CortiEmbeddedDemo() { onError: error => { addLogEntry( `Status refresh failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); }, }); @@ -124,25 +124,25 @@ function CortiEmbeddedDemo() { // Event handlers const handleReady = useCallback(() => { setIsReady(true); - addLogEntry('Corti component loaded and ready', 'success'); + addLogEntry("Corti component loaded and ready", "success"); }, [addLogEntry]); const handleError = useCallback( (detail: CortiEmbeddedErrorDetail) => { - addLogEntry(`Error: ${JSON.stringify(detail)}`, 'error'); + addLogEntry(`Error: ${JSON.stringify(detail)}`, "error"); }, [addLogEntry], ); const handleEvent = useCallback( - (detail: CortiEmbeddedEventDetail) => { - const { name, payload } = detail; - addLogEntry(`Event ${name}: ${JSON.stringify(payload)}`, 'info'); + (event: CortiEmbeddedEvent) => { + const { name, payload } = event.detail; + addLogEntry(`Event ${name}: ${JSON.stringify(payload)}`, "info"); if ( - name === 'embedded.interactionCreated' && + name === "embedded.interactionCreated" && payload && - typeof payload === 'object' && - 'interaction' in payload + typeof payload === "object" && + "interaction" in payload ) { const { interaction } = payload as { interaction?: { id?: string } }; if (interaction?.id) { @@ -157,11 +157,11 @@ function CortiEmbeddedDemo() { const handleShow = () => { try { api.show(); - addLogEntry('Corti component shown', 'info'); + addLogEntry("Corti component shown", "info"); } catch (error) { addLogEntry( `Show failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -169,11 +169,11 @@ function CortiEmbeddedDemo() { const handleHide = () => { try { api.hide(); - addLogEntry('Corti component hidden', 'info'); + addLogEntry("Corti component hidden", "info"); } catch (error) { addLogEntry( `Hide failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -183,17 +183,17 @@ function CortiEmbeddedDemo() { const payload = JSON.parse(authPayload) as KeycloakTokenResponse; addLogEntry( `Sending authentication request with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); const response = await api.auth(payload); addLogEntry( `Authentication successful: ${JSON.stringify(response)}`, - 'success', + "success", ); } catch (error) { console.error( `Authentication failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -203,14 +203,14 @@ function CortiEmbeddedDemo() { const payload = JSON.parse(configureAppPayload) as ConfigureAppPayload; addLogEntry( `Configuring app with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await api.configure(payload); - addLogEntry('App configuration successful', 'success'); + addLogEntry("App configuration successful", "success"); } catch (error) { console.error( `App configuration failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -220,14 +220,14 @@ function CortiEmbeddedDemo() { const payload = JSON.parse(configureSessionPayload) as SessionConfig; addLogEntry( `Configuring session with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await api.configureSession(payload); - addLogEntry('Session configuration successful', 'success'); + addLogEntry("Session configuration successful", "success"); } catch (error) { console.error( `Session configuration failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -237,26 +237,26 @@ function CortiEmbeddedDemo() { const payload = JSON.parse(addFactsPayload) as Fact[]; addLogEntry( `Adding facts with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); await api.addFacts(payload); - addLogEntry('Add facts successful', 'success'); + addLogEntry("Add facts successful", "success"); } catch (error) { console.error( `Add facts failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; const handleNavigate = async () => { try { - addLogEntry(`Navigating with payload: ${navigatePayload}`, 'info'); + addLogEntry(`Navigating with payload: ${navigatePayload}`, "info"); await api.navigate(navigatePayload); } catch (error) { console.error( `Navigation failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -266,7 +266,7 @@ function CortiEmbeddedDemo() { const payload = JSON.parse(createInteractionPayload); addLogEntry( `Creating interaction with payload: ${JSON.stringify(payload)}`, - 'info', + "info", ); const response = await api.createInteraction(payload); @@ -275,13 +275,13 @@ function CortiEmbeddedDemo() { setNavigatePayload(`/session/${response.id}`); addLogEntry( `Navigate payload updated with interaction ID: ${response.id}`, - 'success', + "success", ); } } catch (error) { console.error( `Interaction creation failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -291,12 +291,12 @@ function CortiEmbeddedDemo() { const response = await api.getTemplates(); addLogEntry( `Templates retrieved successfully: ${JSON.stringify(response)}`, - 'success', + "success", ); } catch (error) { console.error( `Template retrieval failed: ${error instanceof Error ? error.message : String(error)}`, - 'error', + "error", ); } }; @@ -335,20 +335,20 @@ function CortiEmbeddedDemo() {
Base URL: {baseURL}
- Component Ready: {isReady ? 'Yes' : 'No'} + Component Ready: {isReady ? "Yes" : "No"}
- Status Loading: {isStatusLoading ? 'Yes' : 'No'} + Status Loading: {isStatusLoading ? "Yes" : "No"}
- Authenticated:{' '} - {embeddedStatus?.auth?.isAuthenticated ? 'Yes' : 'No'} + Authenticated:{" "} + {embeddedStatus?.auth?.isAuthenticated ? "Yes" : "No"}
- Interaction ID: {embeddedStatus?.interaction?.id || 'N/A'} + Interaction ID: {embeddedStatus?.interaction?.id || "N/A"}
- Current URL: {embeddedStatus?.currentUrl || 'N/A'} + Current URL: {embeddedStatus?.currentUrl || "N/A"} {statusError ? ( <>
- Status Error:{' '} + Status Error:{" "} {statusError instanceof Error ? statusError.message : String(statusError)} @@ -567,7 +567,7 @@ function CortiEmbeddedDemo() { ); } -const rootElement = document.getElementById('root'); +const rootElement = document.getElementById("root"); if (rootElement) { const root = createRoot(rootElement); root.render(); diff --git a/docs/react-usage.md b/docs/react-usage.md index 3d59ba2..3fb3884 100644 --- a/docs/react-usage.md +++ b/docs/react-usage.md @@ -11,23 +11,23 @@ npm install @corti/embedded-web ## Basic Usage ```tsx -import React, { useRef } from 'react'; +import React, { useRef } from "react"; import { CortiEmbeddedReact, + type CortiEmbeddedEvent, type CortiEmbeddedReactRef, - type CortiEmbeddedEventDetail, type CortiEmbeddedErrorDetail, -} from '@corti/embedded-web/react'; +} from "@corti/embedded-web/react"; function App() { const cortiRef = useRef(null); - const handleEvent = (detail: CortiEmbeddedEventDetail) => { - console.log(detail.name, detail.payload); + const handleEvent = (event: CortiEmbeddedEvent) => { + console.log(event.detail.name, event.detail.payload); }; const handleError = (detail: CortiEmbeddedErrorDetail) => { - console.error('Embedded error:', detail); + console.error("Embedded error:", detail); }; return ( @@ -35,10 +35,10 @@ function App() { ref={cortiRef} baseURL="https://assistant.eu.corti.app" visibility="visible" - onReady={() => console.log('Corti embedded is ready')} + onReady={() => console.log("Corti embedded is ready")} onEvent={handleEvent} onError={handleError} - style={{ width: '100%', height: '600px' }} + style={{ width: "100%", height: "600px" }} /> ); } @@ -48,10 +48,10 @@ function App() { Use `onEvent` as the canonical event listener. -- Event shape: `{ name: string; payload: unknown }` +- Event shape: `CustomEvent<{ name: string; payload: unknown }>` - This receives all embedded events - `onReady` is triggered by the raw `embedded.ready` event -- `onEvent` receives the generic `embedded-event` stream +- `onEvent` receives the raw `event` `CustomEvent` - Event names and payload contracts are documented publicly at: - https://docs.corti.ai/assistant/events @@ -60,12 +60,12 @@ Use `onEvent` as the canonical event listener. Use `useCortiEmbeddedApi(ref)` to get stable API methods bound to your component instance. ```tsx -import React, { useRef } from 'react'; +import React, { useRef } from "react"; import { CortiEmbeddedReact, type CortiEmbeddedReactRef, useCortiEmbeddedApi, -} from '@corti/embedded-web/react'; +} from "@corti/embedded-web/react"; function ApiExample() { const ref = useRef(null); @@ -73,26 +73,26 @@ function ApiExample() { const run = async () => { await api.auth({ - access_token: '...', - token_type: 'Bearer', + access_token: "...", + token_type: "Bearer", // ... rest of the token response }); const created = await api.createInteraction({ encounter: { identifier: `encounter-${Date.now()}`, - status: 'planned', - type: 'first_consultation', + status: "planned", + type: "first_consultation", period: { startedAt: new Date().toISOString() }, }, }); - await api.configureSession({ defaultTemplateKey: 'soap_note' }); - await api.addFacts([{ text: 'Chest pain', group: 'other' }]); + await api.configureSession({ defaultTemplateKey: "soap_note" }); + await api.addFacts([{ text: "Chest pain", group: "other" }]); await api.navigate(`/session/${created.id}`); await api.startRecording(); await api.stopRecording(); const status = await api.getStatus(); await api.configure({ features: { aiChat: false } }); - await api.setCredentials({ password: '...' }); + await api.setCredentials({ password: "..." }); api.show(); api.hide(); console.log(status); @@ -123,7 +123,7 @@ The hook returns: How it works: - It fetches status when mounted (if enabled). -- It listens to `embedded-event` from the mounted component and automatically refreshes status on incoming events. +- It listens to `event` from the mounted component and automatically refreshes status on incoming events. - Internal filtering avoids refresh recursion from status request/response events. Suggested usage: @@ -133,12 +133,12 @@ Suggested usage: - Keep event-specific logic in `onEvent` while letting the hook handle status synchronization. ```tsx -import React, { useRef } from 'react'; +import React, { useRef } from "react"; import { CortiEmbeddedReact, type CortiEmbeddedReactRef, useCortiEmbeddedStatus, -} from '@corti/embedded-web/react'; +} from "@corti/embedded-web/react"; function StatusExample() { const ref = useRef(null); @@ -147,7 +147,7 @@ function StatusExample() { return (
Loading: {String(isLoading)}
-
Last Event: {lastEvent?.name ?? 'none'}
+
Last Event: {lastEvent?.name ?? "none"}
{JSON.stringify(status, null, 2)}
{error ?
{String(error)}
: null} diff --git a/src/CortiEmbedded.ts b/src/CortiEmbedded.ts index 4c633af..7c47124 100644 --- a/src/CortiEmbedded.ts +++ b/src/CortiEmbedded.ts @@ -123,14 +123,13 @@ export class CortiEmbedded extends LitElement implements CortiEmbeddedAPI { } private dispatchEmbeddedEvent(rawEventName: string, payload: unknown) { - if (rawEventName !== "ready" && rawEventName !== "loaded") { - // Pass all other events through as raw DOM events for direct listeners. - this.dispatchPublicEvent(rawEventName, payload); - } + if (rawEventName === "ready" || rawEventName === "loaded") return; + // Pass all other events through as raw DOM events for direct listeners. + this.dispatchPublicEvent(rawEventName, payload); - // Always forward through the generic 'embedded-event' stream so consumers + // Always forward through the generic 'event' stream so consumers // can observe the full event feed regardless of event name. - this.dispatchPublicEvent("embedded-event", { + this.dispatchPublicEvent("event", { name: rawEventName, payload, }); diff --git a/src/react/CortiEmbeddedReact.ts b/src/react/CortiEmbeddedReact.ts index 2231d8a..b325843 100644 --- a/src/react/CortiEmbeddedReact.ts +++ b/src/react/CortiEmbeddedReact.ts @@ -8,6 +8,8 @@ export interface CortiEmbeddedEventDetail { payload: unknown; } +export type CortiEmbeddedEvent = CustomEvent; + export interface CortiEmbeddedErrorDetail { message: string; code?: string; @@ -19,8 +21,8 @@ export interface CortiEmbeddedReactProps { baseURL: string; visibility?: "visible" | "hidden"; - // Event handlers receive the unwrapped detail, not the raw CustomEvent - onEvent?: (detail: CortiEmbeddedEventDetail) => void; + // Event handlers receive the raw CustomEvent emitted by the custom element. + onEvent?: (event: CortiEmbeddedEvent) => void; onReady?: (detail: unknown) => void; onError?: (detail: CortiEmbeddedErrorDetail) => void; @@ -79,9 +81,7 @@ export const CortiEmbeddedReact = React.forwardRef< if (!el) return undefined; const handleEvent = (e: Event) => - onEventRef.current?.( - (e as CustomEvent).detail, - ); + onEventRef.current?.(e as CortiEmbeddedEvent); const handleReady = (e: Event) => { if (hasEmittedReadyRef.current) return; hasEmittedReadyRef.current = true; @@ -92,11 +92,11 @@ export const CortiEmbeddedReact = React.forwardRef< (e as CustomEvent).detail, ); - el.addEventListener("embedded-event", handleEvent); + el.addEventListener("event", handleEvent); el.addEventListener("embedded.ready", handleReady); el.addEventListener("error", handleError); return () => { - el.removeEventListener("embedded-event", handleEvent); + el.removeEventListener("event", handleEvent); el.removeEventListener("embedded.ready", handleReady); el.removeEventListener("error", handleError); }; @@ -188,8 +188,8 @@ export function useCortiEmbeddedStatus( refresh(); }; - target.addEventListener("embedded-event", handleEvent); - return () => target.removeEventListener("embedded-event", handleEvent); + target.addEventListener("event", handleEvent); + return () => target.removeEventListener("event", handleEvent); }, [enabled, ref, refresh]); return { diff --git a/test/corti-embedded.test.ts b/test/corti-embedded.test.ts index e5fbe76..fb73b2a 100644 --- a/test/corti-embedded.test.ts +++ b/test/corti-embedded.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { expect, fixture } from '@open-wc/testing'; -import { html } from 'lit'; -import { CortiEmbedded } from '../src/CortiEmbedded.js'; -import '../src/corti-embedded.js'; +import { expect, fixture } from "@open-wc/testing"; +import { html } from "lit"; +import { CortiEmbedded } from "../src/CortiEmbedded.js"; +import "../src/corti-embedded.js"; import type { SetCredentialsPayload, GetStatusResponse, @@ -10,60 +10,60 @@ import type { SessionConfig, User, KeycloakTokenResponse, -} from '../src/types'; +} from "../src/types"; -describe('CortiEmbedded', () => { - const validBaseURL = 'https://assistant.eu.corti.app'; +describe("CortiEmbedded", () => { + const validBaseURL = "https://assistant.eu.corti.app"; const ensureContentWindow = (iframe: HTMLIFrameElement) => { if (!iframe.contentWindow) { - Object.defineProperty(iframe, 'contentWindow', { + Object.defineProperty(iframe, "contentWindow", { value: window, configurable: true, }); } }; - it('registers the custom element', () => { - const ctor = customElements.get('corti-embedded'); + it("registers the custom element", () => { + const ctor = customElements.get("corti-embedded"); expect(ctor).to.equal(CortiEmbedded); }); - it('renders an iframe with the expected src and attributes', async () => { + it("renders an iframe with the expected src and attributes", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; expect(iframe).to.exist; - expect(iframe.getAttribute('sandbox')).to.include('allow-scripts'); + expect(iframe.getAttribute("sandbox")).to.include("allow-scripts"); const expectedSrc = `${validBaseURL}/embedded`; - expect(iframe.getAttribute('src')).to.equal(expectedSrc); - const allowAttr = iframe.getAttribute('allow')!; + expect(iframe.getAttribute("src")).to.equal(expectedSrc); + const allowAttr = iframe.getAttribute("allow")!; expect(allowAttr).to.include(`microphone ${validBaseURL}`); expect(allowAttr).to.include(`camera ${validBaseURL}`); expect(allowAttr).to.include(`clipboard-write ${validBaseURL}`); }); - it('is hidden by default and toggles visibility with show/hide', async () => { + it("is hidden by default and toggles visibility with show/hide", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; - expect(iframe.getAttribute('style')).to.contain('display: none'); + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; + expect(iframe.getAttribute("style")).to.contain("display: none"); el.show(); await el.updateComplete; - expect(iframe.getAttribute('style')).to.contain('display: block'); + expect(iframe.getAttribute("style")).to.contain("display: block"); el.hide(); await el.updateComplete; - expect(iframe.getAttribute('style')).to.contain('display: none'); + expect(iframe.getAttribute("style")).to.contain("display: none"); }); - it('dispatches an error event on invalid baseURL (connectedCallback) without throwing', async () => { + it("dispatches an error event on invalid baseURL (connectedCallback) without throwing", async () => { const el = new CortiEmbedded(); let errorEvent: CustomEvent | null = null; - el.addEventListener('error', evt => { + el.addEventListener("error", evt => { errorEvent = evt as unknown as CustomEvent; }); - el.baseURL = 'https://example.com'; + el.baseURL = "https://example.com"; let thrown: Error | null = null; try { el.connectedCallback(); @@ -76,26 +76,26 @@ describe('CortiEmbedded', () => { expect((errorEvent!.detail as any).message).to.match(/Invalid baseURL/i); }); - it('does not dispatch error event from API catch when error was already notified', async () => { + it("does not dispatch error event from API catch when error was already notified", async () => { const el = await fixture( html``, ); let count = 0; let thrown: Error | null = null; - el.addEventListener('error', event => { + el.addEventListener("error", event => { const detail = (event as unknown as CustomEvent).detail as { message?: string; }; - if (detail.message === 'Failed to configure session') { + if (detail.message === "Failed to configure session") { count += 1; } }); (el as any).postMessageHandler = { postMessage: async () => { - throw new Error('User must be authenticated to configure session'); + throw new Error("User must be authenticated to configure session"); }, - destroy: () => { }, + destroy: () => {}, }; try { @@ -108,26 +108,26 @@ describe('CortiEmbedded', () => { expect(count).to.equal(0); }); - it('does not dispatch error event from API catch when the handler rejects', async () => { + it("does not dispatch error event from API catch when the handler rejects", async () => { const el = await fixture( html``, ); let count = 0; let thrown: Error | null = null; - el.addEventListener('error', event => { + el.addEventListener("error", event => { const detail = (event as unknown as CustomEvent).detail as { message?: string; }; - if (detail.message === 'Failed to configure session') { + if (detail.message === "Failed to configure session") { count += 1; } }); (el as any).postMessageHandler = { postMessage: async () => { - throw new Error('User must be authenticated to configure session'); + throw new Error("User must be authenticated to configure session"); }, - destroy: () => { }, + destroy: () => {}, }; try { @@ -140,53 +140,53 @@ describe('CortiEmbedded', () => { expect(count).to.equal(0); }); - it('dispatches both direct error events without component-side dedupe', async () => { + it("dispatches both direct error events without component-side dedupe", async () => { const el = await fixture( html``, ); let count = 0; - el.addEventListener('error', () => { + el.addEventListener("error", () => { count += 1; }); (el as any).dispatchErrorEvent({ - message: 'Duplicate test error', - code: 'TEST', + message: "Duplicate test error", + code: "TEST", details: { a: 1 }, }); (el as any).dispatchErrorEvent({ - message: 'Duplicate test error', - code: 'TEST', + message: "Duplicate test error", + code: "TEST", details: { a: 1 }, }); expect(count).to.equal(2); }); - it('forwards iframe error.triggered through the error handler once', async () => { + it("forwards iframe error.triggered through the error handler once", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; ensureContentWindow(iframe); - iframe.setAttribute('src', `${validBaseURL}/embedded`); - iframe.dispatchEvent(new Event('load')); + iframe.setAttribute("src", `${validBaseURL}/embedded`); + iframe.dispatchEvent(new Event("load")); let embeddedEventCalled = false; let errorDetail: unknown; - el.addEventListener('embedded-event', (event: Event) => { + el.addEventListener("event", (event: Event) => { embeddedEventCalled = true; }); - el.addEventListener('error', (event: Event) => { + el.addEventListener("error", (event: Event) => { errorDetail = (event as CustomEvent).detail; }); window.dispatchEvent( - new MessageEvent('message', { + new MessageEvent("message", { data: { - type: 'CORTI_EMBEDDED_EVENT', - event: 'error.triggered', - payload: { message: 'Boom', code: 'UNAUTHORIZED' }, + type: "CORTI_EMBEDDED_EVENT", + event: "error.triggered", + payload: { message: "Boom", code: "UNAUTHORIZED" }, }, origin: validBaseURL, source: iframe.contentWindow as any, @@ -195,21 +195,21 @@ describe('CortiEmbedded', () => { expect(embeddedEventCalled).to.equal(false); expect(errorDetail).to.deep.equal({ - message: 'Boom', - code: 'UNAUTHORIZED', + message: "Boom", + code: "UNAUTHORIZED", details: { - type: 'CORTI_EMBEDDED_EVENT', - event: 'error.triggered', - payload: { message: 'Boom', code: 'UNAUTHORIZED' }, + type: "CORTI_EMBEDDED_EVENT", + event: "error.triggered", + payload: { message: "Boom", code: "UNAUTHORIZED" }, }, }); }); - it('updates iframe src and resets handler when baseURL changes', async () => { + it("updates iframe src and resets handler when baseURL changes", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; let destroyed = false; (el as any).postMessageHandler = { destroy: () => { @@ -217,74 +217,74 @@ describe('CortiEmbedded', () => { }, }; // Change baseURL - el.baseURL = 'https://assistant.us.corti.app'; + el.baseURL = "https://assistant.us.corti.app"; await el.updateComplete; expect(destroyed).to.equal(true); expect((el as any).postMessageHandler).to.equal(null); // Check new iframe src - expect(iframe.getAttribute('src')).to.equal( - 'https://assistant.us.corti.app/embedded', + expect(iframe.getAttribute("src")).to.equal( + "https://assistant.us.corti.app/embedded", ); - const allowAttr = iframe.getAttribute('allow')!; + const allowAttr = iframe.getAttribute("allow")!; expect(allowAttr).to.include(`microphone ${el.baseURL}`); expect(allowAttr).to.include(`camera ${el.baseURL}`); expect(allowAttr).to.include(`clipboard-write ${el.baseURL}`); }); - it('ignores about:blank iframe loads (no handler setup)', async () => { + it("ignores about:blank iframe loads (no handler setup)", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; ensureContentWindow(iframe); // Force about:blank then emit load - iframe.setAttribute('src', 'about:blank'); - iframe.dispatchEvent(new Event('load')); + iframe.setAttribute("src", "about:blank"); + iframe.dispatchEvent(new Event("load")); expect((el as any).postMessageHandler).to.equal(null); }); - it('accepts iframe loads with trailing slash in path', async () => { + it("accepts iframe loads with trailing slash in path", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; ensureContentWindow(iframe); - iframe.setAttribute('src', `${validBaseURL}/embedded/`); - iframe.dispatchEvent(new Event('load')); + iframe.setAttribute("src", `${validBaseURL}/embedded/`); + iframe.dispatchEvent(new Event("load")); expect((el as any).postMessageHandler).to.exist; }); - it('accepts iframe loads with query params', async () => { + it("accepts iframe loads with query params", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; ensureContentWindow(iframe); - iframe.setAttribute('src', `${validBaseURL}/embedded?x=1&y=2`); - iframe.dispatchEvent(new Event('load')); + iframe.setAttribute("src", `${validBaseURL}/embedded?x=1&y=2`); + iframe.dispatchEvent(new Event("load")); expect((el as any).postMessageHandler).to.exist; }); - it('dispatches raw event and embedded-event payload via dispatchEmbeddedEvent', async () => { + it("dispatches raw event and event payload via dispatchEmbeddedEvent", async () => { const el = await fixture( html``, ); let rawDetail: unknown; let embeddedDetail: unknown; - el.addEventListener('embedded.navigated', (event: Event) => { + el.addEventListener("embedded.navigated", (event: Event) => { rawDetail = (event as CustomEvent).detail; }); - el.addEventListener('embedded-event', (event: Event) => { + el.addEventListener("event", (event: Event) => { embeddedDetail = (event as CustomEvent).detail; }); - (el as any).dispatchEmbeddedEvent('embedded.navigated', { path: '/test' }); + (el as any).dispatchEmbeddedEvent("embedded.navigated", { path: "/test" }); - expect(rawDetail).to.deep.equal({ path: '/test' }); + expect(rawDetail).to.deep.equal({ path: "/test" }); expect(embeddedDetail).to.deep.equal({ - name: 'embedded.navigated', - payload: { path: '/test' }, + name: "embedded.navigated", + payload: { path: "/test" }, }); }); @@ -294,67 +294,62 @@ describe('CortiEmbedded', () => { ); const fired: string[] = []; - for (const name of [ - 'ready', - 'loaded', - 'embedded.ready', - 'embedded-event', - ]) { + for (const name of ["ready", "loaded", "embedded.ready", "event"]) { el.addEventListener(name, () => fired.push(name)); } - // 'embedded.ready' should fire raw 'embedded.ready' + 'embedded-event' - (el as any).dispatchEmbeddedEvent('embedded.ready', {}); - expect(fired).to.not.include('ready'); - expect(fired).to.include('embedded-event'); - expect(fired).to.include('embedded.ready'); + // 'embedded.ready' should fire raw 'embedded.ready' + 'event' + (el as any).dispatchEmbeddedEvent("embedded.ready", {}); + expect(fired).to.not.include("ready"); + expect(fired).to.include("event"); + expect(fired).to.include("embedded.ready"); fired.length = 0; - // 'ready' from iframe should only fire 'embedded-event', NOT the public 'ready' - (el as any).dispatchEmbeddedEvent('ready', {}); - expect(fired).to.not.include('ready'); - expect(fired).to.include('embedded-event'); + // 'ready' from iframe should only fire 'event', NOT the public 'ready' + (el as any).dispatchEmbeddedEvent("ready", {}); + expect(fired).to.not.include("ready"); + expect(fired).to.include("event"); fired.length = 0; - // 'loaded' from iframe should only fire 'embedded-event', NOT 'loaded' raw - (el as any).dispatchEmbeddedEvent('loaded', {}); - expect(fired).to.not.include('loaded'); - expect(fired).to.include('embedded-event'); + // 'loaded' from iframe should only fire 'event', NOT 'loaded' raw + (el as any).dispatchEmbeddedEvent("loaded", {}); + expect(fired).to.not.include("loaded"); + expect(fired).to.include("event"); }); - it('auth throws if component not ready', async () => { + it("auth throws if component not ready", async () => { const el = await fixture( html``, ); const credentials: KeycloakTokenResponse = { - access_token: 'test-token', - token_type: 'Bearer', + access_token: "test-token", + token_type: "Bearer", }; try { await el.auth(credentials); - expect.fail('Expected component not ready error'); + expect.fail("Expected component not ready error"); } catch (e: any) { expect(String(e.message || e)).to.match(/Component not ready/); } }); - it('delegates methods to PostMessageHandler when available', async () => { + it("delegates methods to PostMessageHandler when available", async () => { const el = await fixture( html``, ); // Mock user data const mockUser: User = { - id: 'user123', - email: 'test@example.com', + id: "user123", + email: "test@example.com", }; const mockInteraction: InteractionDetails = { - id: 'int123', - createdAt: '2024-01-01T00:00:00Z', + id: "int123", + createdAt: "2024-01-01T00:00:00Z", }; const mockStatus: GetStatusResponse = { @@ -365,7 +360,7 @@ describe('CortiEmbedded', () => { email: mockUser.email, }, }, - currentUrl: 'https://example.com', + currentUrl: "https://example.com", interaction: null, }; @@ -373,33 +368,33 @@ describe('CortiEmbedded', () => { (el as any).postMessageHandler = { postMessage: async (msg: { action: string }) => { switch (msg.action) { - case 'auth': + case "auth": return { success: true, payload: { user: mockUser } }; - case 'createInteraction': + case "createInteraction": return { success: true, payload: mockInteraction }; - case 'getStatus': + case "getStatus": return { success: true, payload: mockStatus }; default: return { success: true, payload: {} }; } }, - waitForReady: async () => { }, - destroy: () => { }, + waitForReady: async () => {}, + destroy: () => {}, ready: true, }; const credentials: SetCredentialsPayload = { - password: 'test-password', + password: "test-password", }; const sessionConfig: SessionConfig = { - defaultLanguage: 'en', - defaultMode: 'virtual', + defaultLanguage: "en", + defaultMode: "virtual", }; const authPayload: KeycloakTokenResponse = { - access_token: 'test-token', - token_type: 'Bearer', + access_token: "test-token", + token_type: "Bearer", }; // Test all the API methods @@ -411,25 +406,25 @@ describe('CortiEmbedded', () => { assignedUserId: user.id, encounter: { identifier: randomId, - status: 'planned', - type: 'first_consultation', + status: "planned", + type: "first_consultation", period: { startedAt: new Date().toDateString(), }, - title: 'Initial Consultation', + title: "Initial Consultation", }, patient: { identifier: randomId, - name: 'John Doe', - gender: 'male', + name: "John Doe", + gender: "male", birthDate: new Date().toDateString(), }, }); expect(interaction).to.deep.equal(mockInteraction); await el.configureSession(sessionConfig); - await el.addFacts([{ text: 'test', group: 'my note' }]); - await el.navigate('/test'); + await el.addFacts([{ text: "test", group: "my note" }]); + await el.navigate("/test"); await el.startRecording(); await el.stopRecording(); await el.setCredentials(credentials); @@ -440,30 +435,30 @@ describe('CortiEmbedded', () => { // Note: configure method would normally change baseURL, but our mock doesn't handle that }); - it('dispatches error event when baseURL becomes invalid (updated) without throwing', async () => { + it("dispatches error event when baseURL becomes invalid (updated) without throwing", async () => { const el = await fixture( html``, ); - const iframe = el.shadowRoot!.querySelector('iframe') as HTMLIFrameElement; + const iframe = el.shadowRoot!.querySelector("iframe") as HTMLIFrameElement; // Ensure it starts valid - expect(iframe.getAttribute('src')).to.equal(`${validBaseURL}/embedded`); + expect(iframe.getAttribute("src")).to.equal(`${validBaseURL}/embedded`); let errorEvent: CustomEvent | null = null; - el.addEventListener('error', evt => { + el.addEventListener("error", evt => { errorEvent = evt as unknown as CustomEvent; }); - el.baseURL = 'https://example.com'; + el.baseURL = "https://example.com"; // updateComplete must not reject after the fix await el.updateComplete; expect(errorEvent).to.exist; expect((errorEvent!.detail as any).message).to.match(/Invalid baseURL/i); // The iframe src should have been reset to about:blank - expect(iframe.getAttribute('src')).to.equal('about:blank'); + expect(iframe.getAttribute("src")).to.equal("about:blank"); }); - it('returns proper status when component is not ready', async () => { + it("returns proper status when component is not ready", async () => { const el = await fixture( html``, ); @@ -474,7 +469,7 @@ describe('CortiEmbedded', () => { isAuthenticated: false, user: undefined, }, - currentUrl: '', + currentUrl: "", interaction: null, }); }); From b1567c9c47d3d6954b09533eae722798dc5fbd18 Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Mon, 23 Mar 2026 15:05:14 +0100 Subject: [PATCH 2/7] test: correct event emitter test --- test/corti-embedded.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/corti-embedded.test.ts b/test/corti-embedded.test.ts index fb73b2a..766bbb7 100644 --- a/test/corti-embedded.test.ts +++ b/test/corti-embedded.test.ts @@ -288,7 +288,7 @@ describe("CortiEmbedded", () => { }); }); - it("forwards 'embedded.ready' raw and suppresses raw 'ready'/'loaded'", async () => { + it("forwards 'embedded.ready' raw and fully suppresses raw 'ready'/'loaded'", async () => { const el = await fixture( html``, ); @@ -306,17 +306,17 @@ describe("CortiEmbedded", () => { fired.length = 0; - // 'ready' from iframe should only fire 'event', NOT the public 'ready' + // 'ready' from iframe should be fully suppressed. (el as any).dispatchEmbeddedEvent("ready", {}); expect(fired).to.not.include("ready"); - expect(fired).to.include("event"); + expect(fired).to.deep.equal([]); fired.length = 0; - // 'loaded' from iframe should only fire 'event', NOT 'loaded' raw + // 'loaded' from iframe should also be fully suppressed. (el as any).dispatchEmbeddedEvent("loaded", {}); expect(fired).to.not.include("loaded"); - expect(fired).to.include("event"); + expect(fired).to.deep.equal([]); }); it("auth throws if component not ready", async () => { From 0e20e8c975fef3a667bb062c94e81227c01fd5a1 Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Mon, 23 Mar 2026 15:22:36 +0100 Subject: [PATCH 3/7] fix: event unwrapping for errors --- demo/react-demo.tsx | 4 ++-- src/react/CortiEmbeddedReact.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/demo/react-demo.tsx b/demo/react-demo.tsx index 02fd351..946dfb9 100644 --- a/demo/react-demo.tsx +++ b/demo/react-demo.tsx @@ -128,8 +128,8 @@ function CortiEmbeddedDemo() { }, [addLogEntry]); const handleError = useCallback( - (detail: CortiEmbeddedErrorDetail) => { - addLogEntry(`Error: ${JSON.stringify(detail)}`, "error"); + (event: CustomEvent) => { + addLogEntry(`Error: ${JSON.stringify(event.detail)}`, "error"); }, [addLogEntry], ); diff --git a/src/react/CortiEmbeddedReact.ts b/src/react/CortiEmbeddedReact.ts index b325843..862b0dd 100644 --- a/src/react/CortiEmbeddedReact.ts +++ b/src/react/CortiEmbeddedReact.ts @@ -24,7 +24,7 @@ export interface CortiEmbeddedReactProps { // Event handlers receive the raw CustomEvent emitted by the custom element. onEvent?: (event: CortiEmbeddedEvent) => void; onReady?: (detail: unknown) => void; - onError?: (detail: CortiEmbeddedErrorDetail) => void; + onError?: (event: CustomEvent) => void; // Additional props className?: string; @@ -88,9 +88,7 @@ export const CortiEmbeddedReact = React.forwardRef< onReadyRef.current?.((e as CustomEvent).detail); }; const handleError = (e: Event) => - onErrorRef.current?.( - (e as CustomEvent).detail, - ); + onErrorRef.current?.(e as CustomEvent); el.addEventListener("event", handleEvent); el.addEventListener("embedded.ready", handleReady); From c4d30735cd300dd54bd7779b25ac528c46769b5b Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Tue, 24 Mar 2026 13:50:51 +0100 Subject: [PATCH 4/7] refactor: correct types and documentation --- README.md | 23 ++++++++++++----------- docs/react-usage.md | 12 +++++++----- src/CortiEmbedded.ts | 4 ++-- src/react/CortiEmbeddedReact.ts | 4 ++-- test/react-corti-embedded.test.ts | 9 +++++---- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 63064d6..f9b13e2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ import React, { useRef } from "react"; import { CortiEmbeddedReact, type CortiEmbeddedReactRef, + type CortiEmbeddedEvent, useCortiEmbeddedApi, useCortiEmbeddedStatus, } from "@corti/embedded-web/react"; @@ -80,13 +81,13 @@ function App() { const api = useCortiEmbeddedApi(cortiRef); const { status } = useCortiEmbeddedStatus(cortiRef); - const handleReady = () => { - console.log("Corti component is ready!"); + const handleReady = (event: CustomEvent) => { + console.log("Corti component is ready!", event.detail); }; - const handleEvent = (detail: { name: string; payload: unknown }) => { - console.log("Event name:", detail.name); - console.log("Event payload:", detail.payload); + const handleEvent = (event: CortiEmbeddedEvent) => { + console.log("Event name:", event.detail.name); + console.log("Event payload:", event.detail.payload); }; const handleAuth = async () => { @@ -122,7 +123,7 @@ function App() { visibility="visible" onReady={handleReady} onEvent={handleEvent} - onError={detail => console.error("Embedded error:", detail)} + onError={event => console.error("Embedded error:", event.detail)} style={{ width: "100%", height: "500px" }} /> @@ -242,7 +243,7 @@ The component uses a `PostMessageHandler` utility class that: The React component (`CortiEmbeddedReact`) is available as an additional export and provides: - **Hook-based API access**: `useCortiEmbeddedApi(ref)` exposes instance-bound methods (`auth`, `navigate`, `createInteraction`, etc.) -- **Generic event stream**: `onEvent` receives all embedded events as `CustomEvent<{ name, payload }>` +- **Generic event stream**: `onEvent` receives the generic `event` stream as `CustomEvent<{ name, payload }>` - **Status hook**: `useCortiEmbeddedStatus(ref)` keeps latest status/reactive state - **Multi-instance safety**: API methods are scoped to the ref you pass - **React Props**: Standard React props like `className`, `style`, etc. @@ -265,19 +266,19 @@ import { - Event detail shape is `CustomEvent<{ name: string; payload: unknown }>` via `event.detail`. - `onReady` fires when the raw `embedded.ready` event is received. - `onEvent` receives the raw generic `event` stream. +- Raw `ready`, `loaded`, and `error.triggered` are not forwarded through `onEvent`. - Full event catalog and payload details are documented at: - https://docs.corti.ai/assistant/events -- `onEvent` passes through the raw `CustomEvent`, while `onError` and `onReady` still receive the event detail directly. +- `onEvent`, `onReady`, and `onError` pass through the raw `CustomEvent`. ```tsx { - if (event.detail.name === "ready" || event.detail.name === "loaded") return; console.log(event.detail.name, event.detail.payload); }} - onReady={() => console.log("Ready")} - onError={detail => console.error(detail)} + onReady={event => console.log("Ready", event.detail)} + onError={event => console.error(event.detail)} /> ``` diff --git a/docs/react-usage.md b/docs/react-usage.md index 3fb3884..7ff8348 100644 --- a/docs/react-usage.md +++ b/docs/react-usage.md @@ -26,8 +26,8 @@ function App() { console.log(event.detail.name, event.detail.payload); }; - const handleError = (detail: CortiEmbeddedErrorDetail) => { - console.error("Embedded error:", detail); + const handleError = (event: CustomEvent) => { + console.error("Embedded error:", event.detail); }; return ( @@ -35,7 +35,7 @@ function App() { ref={cortiRef} baseURL="https://assistant.eu.corti.app" visibility="visible" - onReady={() => console.log("Corti embedded is ready")} + onReady={event => console.log("Corti embedded is ready", event.detail)} onEvent={handleEvent} onError={handleError} style={{ width: "100%", height: "600px" }} @@ -49,9 +49,11 @@ function App() { Use `onEvent` as the canonical event listener. - Event shape: `CustomEvent<{ name: string; payload: unknown }>` -- This receives all embedded events -- `onReady` is triggered by the raw `embedded.ready` event +- This receives the generic embedded `event` stream +- `onReady` is triggered by the raw `embedded.ready` event and receives the raw `CustomEvent` - `onEvent` receives the raw `event` `CustomEvent` +- Raw `ready`, `loaded`, and `error.triggered` are not forwarded through `onEvent` +- `onError` receives the raw `CustomEvent`, so use `event.detail` - Event names and payload contracts are documented publicly at: - https://docs.corti.ai/assistant/events diff --git a/src/CortiEmbedded.ts b/src/CortiEmbedded.ts index 7c47124..5ecf218 100644 --- a/src/CortiEmbedded.ts +++ b/src/CortiEmbedded.ts @@ -127,8 +127,8 @@ export class CortiEmbedded extends LitElement implements CortiEmbeddedAPI { // Pass all other events through as raw DOM events for direct listeners. this.dispatchPublicEvent(rawEventName, payload); - // Always forward through the generic 'event' stream so consumers - // can observe the full event feed regardless of event name. + // Forward supported embedded events through the generic 'event' stream so + // consumers can observe them without subscribing to each raw event name. this.dispatchPublicEvent("event", { name: rawEventName, payload, diff --git a/src/react/CortiEmbeddedReact.ts b/src/react/CortiEmbeddedReact.ts index 862b0dd..c50b9f2 100644 --- a/src/react/CortiEmbeddedReact.ts +++ b/src/react/CortiEmbeddedReact.ts @@ -23,7 +23,7 @@ export interface CortiEmbeddedReactProps { // Event handlers receive the raw CustomEvent emitted by the custom element. onEvent?: (event: CortiEmbeddedEvent) => void; - onReady?: (detail: unknown) => void; + onReady?: (event: CustomEvent) => void; onError?: (event: CustomEvent) => void; // Additional props @@ -85,7 +85,7 @@ export const CortiEmbeddedReact = React.forwardRef< const handleReady = (e: Event) => { if (hasEmittedReadyRef.current) return; hasEmittedReadyRef.current = true; - onReadyRef.current?.((e as CustomEvent).detail); + onReadyRef.current?.(e as CustomEvent); }; const handleError = (e: Event) => onErrorRef.current?.(e as CustomEvent); diff --git a/test/react-corti-embedded.test.ts b/test/react-corti-embedded.test.ts index 6f3d7c6..14eeff9 100644 --- a/test/react-corti-embedded.test.ts +++ b/test/react-corti-embedded.test.ts @@ -46,15 +46,15 @@ describe('CortiEmbeddedReact', () => { }); it('fires onReady only once per mounted component instance', async () => { - const readyCalls: unknown[] = []; + const readyCalls: Array> = []; const ref = React.createRef(); root!.render( React.createElement(CortiEmbeddedReact, { ref, baseURL: 'https://assistant.eu.corti.app', - onReady: detail => { - readyCalls.push(detail); + onReady: event => { + readyCalls.push(event); }, }), ); @@ -66,6 +66,7 @@ describe('CortiEmbeddedReact', () => { el.dispatchEvent(new CustomEvent('embedded.ready', { detail: { a: 1 } })); el.dispatchEvent(new CustomEvent('embedded.ready', { detail: { a: 2 } })); - expect(readyCalls).to.deep.equal([{ a: 1 }]); + expect(readyCalls).to.have.length(1); + expect(readyCalls[0].detail).to.deep.equal({ a: 1 }); }); }); From 8d58ef3a41435d22c310161867cd9d9f7798ac12 Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Tue, 24 Mar 2026 17:20:57 +0100 Subject: [PATCH 5/7] rerfactor: correct docs and types --- README.md | 10 +++--- docs/react-usage.md | 6 ++-- src/react/CortiEmbeddedReact.ts | 4 +-- test/react-corti-embedded.test.ts | 51 +++++++++++++++++++++++++------ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f9b13e2..26847db 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ The component uses a `PostMessageHandler` utility class that: The React component (`CortiEmbeddedReact`) is available as an additional export and provides: - **Hook-based API access**: `useCortiEmbeddedApi(ref)` exposes instance-bound methods (`auth`, `navigate`, `createInteraction`, etc.) -- **Generic event stream**: `onEvent` receives the generic `event` stream as `CustomEvent<{ name, payload }>` +- **Generic event stream**: `onEvent` receives the wrapper's `event` `CustomEvent`, with `event.detail` shaped as `{ name, payload }` - **Status hook**: `useCortiEmbeddedStatus(ref)` keeps latest status/reactive state - **Multi-instance safety**: API methods are scoped to the ref you pass - **React Props**: Standard React props like `className`, `style`, etc. @@ -262,10 +262,10 @@ import { ### Event Listener Setup -- Use `onEvent` for all embedded events. -- Event detail shape is `CustomEvent<{ name: string; payload: unknown }>` via `event.detail`. -- `onReady` fires when the raw `embedded.ready` event is received. -- `onEvent` receives the raw generic `event` stream. +- Use `onEvent` as the catch-all listener for the wrapper's generic `event` stream. +- `onEvent` receives the raw `event` `CustomEvent`, and `event.detail` has shape the following shape: `{ name: string; payload: unknown }`. +- That generic stream includes `embedded.ready` and other forwarded embedded events listed in our documentation. +- `onReady` is a convenience listener for the raw `embedded.ready` `CustomEvent`. - Raw `ready`, `loaded`, and `error.triggered` are not forwarded through `onEvent`. - Full event catalog and payload details are documented at: - https://docs.corti.ai/assistant/events diff --git a/docs/react-usage.md b/docs/react-usage.md index 7ff8348..b4ff1f6 100644 --- a/docs/react-usage.md +++ b/docs/react-usage.md @@ -49,9 +49,9 @@ function App() { Use `onEvent` as the canonical event listener. - Event shape: `CustomEvent<{ name: string; payload: unknown }>` -- This receives the generic embedded `event` stream -- `onReady` is triggered by the raw `embedded.ready` event and receives the raw `CustomEvent` -- `onEvent` receives the raw `event` `CustomEvent` +- `onEvent` receives the wrapper's generic `event` `CustomEvent` +- That generic stream includes `embedded.ready` and other forwarded embedded events +- `onReady` listens to the raw `embedded.ready` `CustomEvent` - Raw `ready`, `loaded`, and `error.triggered` are not forwarded through `onEvent` - `onError` receives the raw `CustomEvent`, so use `event.detail` - Event names and payload contracts are documented publicly at: diff --git a/src/react/CortiEmbeddedReact.ts b/src/react/CortiEmbeddedReact.ts index c50b9f2..21f60e4 100644 --- a/src/react/CortiEmbeddedReact.ts +++ b/src/react/CortiEmbeddedReact.ts @@ -23,7 +23,7 @@ export interface CortiEmbeddedReactProps { // Event handlers receive the raw CustomEvent emitted by the custom element. onEvent?: (event: CortiEmbeddedEvent) => void; - onReady?: (event: CustomEvent) => void; + onReady?: (event: CortiEmbeddedEvent) => void; onError?: (event: CustomEvent) => void; // Additional props @@ -85,7 +85,7 @@ export const CortiEmbeddedReact = React.forwardRef< const handleReady = (e: Event) => { if (hasEmittedReadyRef.current) return; hasEmittedReadyRef.current = true; - onReadyRef.current?.(e as CustomEvent); + onReadyRef.current?.(e as CortiEmbeddedEvent); }; const handleError = (e: Event) => onErrorRef.current?.(e as CustomEvent); diff --git a/test/react-corti-embedded.test.ts b/test/react-corti-embedded.test.ts index 14eeff9..e41f097 100644 --- a/test/react-corti-embedded.test.ts +++ b/test/react-corti-embedded.test.ts @@ -1,13 +1,13 @@ -import { expect } from '@open-wc/testing'; +import { expect } from "@open-wc/testing"; import { React, createRoot, type Root, CortiEmbeddedReact, type CortiEmbeddedReactRef, -} from './vendor/react-test-bundle.js'; +} from "./vendor/react-test-bundle.js"; -describe('CortiEmbeddedReact', () => { +describe("CortiEmbeddedReact", () => { let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -28,11 +28,11 @@ describe('CortiEmbeddedReact', () => { await flushReact(); } - throw new Error('CortiEmbeddedReact ref was not attached'); + throw new Error("CortiEmbeddedReact ref was not attached"); } beforeEach(() => { - container = document.createElement('div'); + container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); @@ -45,14 +45,14 @@ describe('CortiEmbeddedReact', () => { container = null; }); - it('fires onReady only once per mounted component instance', async () => { - const readyCalls: Array> = []; + it("fires onReady only once per mounted component instance", async () => { + const readyCalls: CustomEvent[] = []; const ref = React.createRef(); root!.render( React.createElement(CortiEmbeddedReact, { ref, - baseURL: 'https://assistant.eu.corti.app', + baseURL: "https://assistant.eu.corti.app", onReady: event => { readyCalls.push(event); }, @@ -63,10 +63,41 @@ describe('CortiEmbeddedReact', () => { await flushReact(); expect(el).to.exist; - el.dispatchEvent(new CustomEvent('embedded.ready', { detail: { a: 1 } })); - el.dispatchEvent(new CustomEvent('embedded.ready', { detail: { a: 2 } })); + el.dispatchEvent(new CustomEvent("embedded.ready", { detail: { a: 1 } })); + el.dispatchEvent(new CustomEvent("embedded.ready", { detail: { a: 2 } })); expect(readyCalls).to.have.length(1); expect(readyCalls[0].detail).to.deep.equal({ a: 1 }); }); + + it("forwards embedded.ready through onEvent as the generic event stream", async () => { + const eventCalls: Array> = []; + const ref = React.createRef(); + + root!.render( + React.createElement(CortiEmbeddedReact, { + ref, + baseURL: "https://assistant.eu.corti.app", + onEvent: event => { + eventCalls.push(event); + }, + }), + ); + + const el = await waitForRef(ref); + await flushReact(); + expect(el).to.exist; + + el.dispatchEvent( + new CustomEvent("event", { + detail: { name: "embedded.ready", payload: { version: "1.0.0" } }, + }), + ); + + expect(eventCalls).to.have.length(1); + expect(eventCalls[0].detail).to.deep.equal({ + name: "embedded.ready", + payload: { version: "1.0.0" }, + }); + }); }); From 0df98ed5d62b032fe0dac4bfa91fa81cf4853c77 Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Tue, 24 Mar 2026 18:06:48 +0100 Subject: [PATCH 6/7] refactor: align events and docs --- README.md | 7 ++++--- docs/react-usage.md | 7 ++++++- src/CortiEmbedded.ts | 8 ++++++++ src/react/CortiEmbeddedReact.ts | 5 +++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 26847db..63a60d8 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ await myComponent.show() #### Generic Event Listener (Web Component) -Use `event` as the canonical event stream for web component integrations. +Use `event` as the canonical event stream for web component integrations (formerly `embedded-event`). - Event detail shape is `{ name: string; payload: unknown }`. - Full event catalog and payload details are documented at: @@ -72,6 +72,7 @@ import { CortiEmbeddedReact, type CortiEmbeddedReactRef, type CortiEmbeddedEvent, + type CortiEmbeddedReadyEvent, useCortiEmbeddedApi, useCortiEmbeddedStatus, } from "@corti/embedded-web/react"; @@ -81,7 +82,7 @@ function App() { const api = useCortiEmbeddedApi(cortiRef); const { status } = useCortiEmbeddedStatus(cortiRef); - const handleReady = (event: CustomEvent) => { + const handleReady = (event: CortiEmbeddedReadyEvent) => { console.log("Corti component is ready!", event.detail); }; @@ -263,7 +264,7 @@ import { ### Event Listener Setup - Use `onEvent` as the catch-all listener for the wrapper's generic `event` stream. -- `onEvent` receives the raw `event` `CustomEvent`, and `event.detail` has shape the following shape: `{ name: string; payload: unknown }`. +- `onEvent` receives the raw `event` `CustomEvent`, and `event.detail` has the following shape: `{ name: string; payload: unknown }`. - That generic stream includes `embedded.ready` and other forwarded embedded events listed in our documentation. - `onReady` is a convenience listener for the raw `embedded.ready` `CustomEvent`. - Raw `ready`, `loaded`, and `error.triggered` are not forwarded through `onEvent`. diff --git a/docs/react-usage.md b/docs/react-usage.md index b4ff1f6..cf236d8 100644 --- a/docs/react-usage.md +++ b/docs/react-usage.md @@ -15,6 +15,7 @@ import React, { useRef } from "react"; import { CortiEmbeddedReact, type CortiEmbeddedEvent, + type CortiEmbeddedReadyEvent, type CortiEmbeddedReactRef, type CortiEmbeddedErrorDetail, } from "@corti/embedded-web/react"; @@ -30,12 +31,16 @@ function App() { console.error("Embedded error:", event.detail); }; + const handleReady = (event: CortiEmbeddedReadyEvent) => { + console.log("Corti embedded is ready", event.detail); + }; + return ( console.log("Corti embedded is ready", event.detail)} + onReady={handleReady} onEvent={handleEvent} onError={handleError} style={{ width: "100%", height: "600px" }} diff --git a/src/CortiEmbedded.ts b/src/CortiEmbedded.ts index 5ecf218..7c550cc 100644 --- a/src/CortiEmbedded.ts +++ b/src/CortiEmbedded.ts @@ -133,6 +133,14 @@ export class CortiEmbedded extends LitElement implements CortiEmbeddedAPI { name: rawEventName, payload, }); + + // Also emit the legacy 'embedded-event' stream for backward compatibility. + // This allows existing integrations listening for 'embedded-event' to continue + // working while newer consumers can use the 'event' stream. + this.dispatchPublicEvent("embedded-event", { + name: rawEventName, + payload, + }); } private dispatchErrorEvent(error: { diff --git a/src/react/CortiEmbeddedReact.ts b/src/react/CortiEmbeddedReact.ts index 21f60e4..4d319e7 100644 --- a/src/react/CortiEmbeddedReact.ts +++ b/src/react/CortiEmbeddedReact.ts @@ -9,6 +9,7 @@ export interface CortiEmbeddedEventDetail { } export type CortiEmbeddedEvent = CustomEvent; +export type CortiEmbeddedReadyEvent = CustomEvent>; export interface CortiEmbeddedErrorDetail { message: string; @@ -23,7 +24,7 @@ export interface CortiEmbeddedReactProps { // Event handlers receive the raw CustomEvent emitted by the custom element. onEvent?: (event: CortiEmbeddedEvent) => void; - onReady?: (event: CortiEmbeddedEvent) => void; + onReady?: (event: CortiEmbeddedReadyEvent) => void; onError?: (event: CustomEvent) => void; // Additional props @@ -85,7 +86,7 @@ export const CortiEmbeddedReact = React.forwardRef< const handleReady = (e: Event) => { if (hasEmittedReadyRef.current) return; hasEmittedReadyRef.current = true; - onReadyRef.current?.(e as CortiEmbeddedEvent); + onReadyRef.current?.(e as CortiEmbeddedReadyEvent); }; const handleError = (e: Event) => onErrorRef.current?.(e as CustomEvent); From 632614439666909404a8222294224658d37d8d8a Mon Sep 17 00:00:00 2001 From: Zoltan Hricz Date: Wed, 1 Apr 2026 10:55:32 +0200 Subject: [PATCH 7/7] test: validate that the legacy event is still emitted --- test/corti-embedded.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/corti-embedded.test.ts b/test/corti-embedded.test.ts index 766bbb7..844c6f3 100644 --- a/test/corti-embedded.test.ts +++ b/test/corti-embedded.test.ts @@ -271,6 +271,7 @@ describe("CortiEmbedded", () => { ); let rawDetail: unknown; let embeddedDetail: unknown; + let legacyDetail: unknown; el.addEventListener("embedded.navigated", (event: Event) => { rawDetail = (event as CustomEvent).detail; @@ -278,6 +279,9 @@ describe("CortiEmbedded", () => { el.addEventListener("event", (event: Event) => { embeddedDetail = (event as CustomEvent).detail; }); + el.addEventListener("embedded-event", (event: Event) => { + legacyDetail = (event as CustomEvent).detail; + }); (el as any).dispatchEmbeddedEvent("embedded.navigated", { path: "/test" }); @@ -286,6 +290,10 @@ describe("CortiEmbedded", () => { name: "embedded.navigated", payload: { path: "/test" }, }); + expect(legacyDetail).to.deep.equal({ + name: "embedded.navigated", + payload: { path: "/test" }, + }); }); it("forwards 'embedded.ready' raw and fully suppresses raw 'ready'/'loaded'", async () => {