@convai/web-sdk 1.8.0-beta.6 → 1.8.0-beta.8

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 (99) hide show
  1. package/README.md +303 -1
  2. package/dist/core/ConvaiClient.d.ts +59 -2
  3. package/dist/core/ConvaiClient.d.ts.map +1 -1
  4. package/dist/core/ConvaiClient.js +457 -6
  5. package/dist/core/ConvaiClient.js.map +1 -1
  6. package/dist/core/MessageHandler.d.ts +11 -0
  7. package/dist/core/MessageHandler.d.ts.map +1 -1
  8. package/dist/core/MessageHandler.js +325 -30
  9. package/dist/core/MessageHandler.js.map +1 -1
  10. package/dist/core/SSESession.js +2 -2
  11. package/dist/core/SSESession.js.map +1 -1
  12. package/dist/core/connectRequest.d.ts +38 -3
  13. package/dist/core/connectRequest.d.ts.map +1 -1
  14. package/dist/core/connectRequest.js +57 -11
  15. package/dist/core/connectRequest.js.map +1 -1
  16. package/dist/core/index.d.ts +5 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +8 -1
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/logicalTurn.d.ts +4 -1
  21. package/dist/core/logicalTurn.d.ts.map +1 -1
  22. package/dist/core/logicalTurn.js +35 -4
  23. package/dist/core/logicalTurn.js.map +1 -1
  24. package/dist/core/modelOutput.d.ts +40 -0
  25. package/dist/core/modelOutput.d.ts.map +1 -0
  26. package/dist/core/modelOutput.js +187 -0
  27. package/dist/core/modelOutput.js.map +1 -0
  28. package/dist/core/publishedChat.d.ts +9 -0
  29. package/dist/core/publishedChat.d.ts.map +1 -0
  30. package/dist/core/publishedChat.js +13 -0
  31. package/dist/core/publishedChat.js.map +1 -0
  32. package/dist/core/types.d.ts +225 -25
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/embed/ConvaiChatElement.d.ts +103 -0
  36. package/dist/embed/ConvaiChatElement.d.ts.map +1 -0
  37. package/dist/embed/ConvaiChatElement.js +679 -0
  38. package/dist/embed/ConvaiChatElement.js.map +1 -0
  39. package/dist/embed/browser.d.ts +3 -0
  40. package/dist/embed/browser.d.ts.map +1 -0
  41. package/dist/embed/browser.js +5 -0
  42. package/dist/embed/browser.js.map +1 -0
  43. package/dist/embed/chat-embed-v1.js +200 -0
  44. package/dist/embed/chat-embed-v1.js.map +7 -0
  45. package/dist/embed/index.d.ts +9 -0
  46. package/dist/embed/index.d.ts.map +1 -0
  47. package/dist/embed/index.js +9 -0
  48. package/dist/embed/index.js.map +1 -0
  49. package/dist/embed/publicationApi.d.ts +23 -0
  50. package/dist/embed/publicationApi.d.ts.map +1 -0
  51. package/dist/embed/publicationApi.js +141 -0
  52. package/dist/embed/publicationApi.js.map +1 -0
  53. package/dist/react/components/ConvaiWidget.d.ts +11 -1
  54. package/dist/react/components/ConvaiWidget.d.ts.map +1 -1
  55. package/dist/react/components/ConvaiWidget.js +67 -11
  56. package/dist/react/components/ConvaiWidget.js.map +1 -1
  57. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts +7 -0
  58. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  59. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +111 -17
  60. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  61. package/dist/react/components/rtc-widget/components/UserMessage.js +1 -1
  62. package/dist/react/components/rtc-widget/components/UserMessage.js.map +1 -1
  63. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts +1 -1
  64. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.d.ts.map +1 -1
  65. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js +2 -2
  66. package/dist/react/components/rtc-widget/components/conviComponents/SettingsTray.js.map +1 -1
  67. package/dist/react/hooks/useConvaiClient.d.ts +2 -0
  68. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  69. package/dist/react/hooks/useConvaiClient.js +8 -0
  70. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  71. package/dist/react/index.d.ts +4 -1
  72. package/dist/react/index.d.ts.map +1 -1
  73. package/dist/react/index.js +8 -1
  74. package/dist/react/index.js.map +1 -1
  75. package/dist/utils/inlineMarkdown.d.ts +22 -0
  76. package/dist/utils/inlineMarkdown.d.ts.map +1 -1
  77. package/dist/utils/inlineMarkdown.js +84 -7
  78. package/dist/utils/inlineMarkdown.js.map +1 -1
  79. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  80. package/dist/vanilla/ConvaiWidget.js +290 -43
  81. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  82. package/dist/vanilla/WidgetEventSubscriptions.d.ts +15 -0
  83. package/dist/vanilla/WidgetEventSubscriptions.d.ts.map +1 -0
  84. package/dist/vanilla/WidgetEventSubscriptions.js +37 -0
  85. package/dist/vanilla/WidgetEventSubscriptions.js.map +1 -0
  86. package/dist/vanilla/index.d.ts +3 -1
  87. package/dist/vanilla/index.d.ts.map +1 -1
  88. package/dist/vanilla/index.js +3 -1
  89. package/dist/vanilla/index.js.map +1 -1
  90. package/dist/vanilla/styles.d.ts.map +1 -1
  91. package/dist/vanilla/styles.js +12 -0
  92. package/dist/vanilla/styles.js.map +1 -1
  93. package/dist/vanilla/types.d.ts +8 -1
  94. package/dist/vanilla/types.d.ts.map +1 -1
  95. package/dist/vanilla/types.js.map +1 -1
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/version.js.map +1 -1
  99. package/package.json +48 -13
@@ -5,8 +5,10 @@
5
5
  import { AudioRenderer } from "./AudioRenderer.js";
6
6
  import { aeroTheme, injectGlobalStyles, injectKeyframes, } from "./styles.js";
7
7
  import { Icons } from "./icons.js";
8
+ import { WidgetEventSubscriptions } from "./WidgetEventSubscriptions.js";
8
9
  import { ConvaiClient } from "../core/ConvaiClient.js";
9
- import { parseInlineMarkdown } from "../utils/inlineMarkdown.js";
10
+ import { parseInlineMarkdown, compactUrl } from "../utils/inlineMarkdown.js";
11
+ import { freshPublicationGrantOptions } from "../core/publishedChat.js";
10
12
  /**
11
13
  * Positioning declarations for the widget root, keyed by `placement`. The
12
14
  * `bottom-right` entry must stay byte-identical to the literals it replaces --
@@ -171,7 +173,7 @@ export function createConvaiWidget(container, options) {
171
173
  blendshapeConfig: options.blendshapeConfig,
172
174
  });
173
175
  })();
174
- const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, onConnect, onDisconnect, onMessage, } = options;
176
+ const { showVideo = true, showScreenShare = true, defaultVoiceMode = true, requestPublicationGrant, publishedChatConnectOptions, publishedChatDescriptor, onConnect, onDisconnect, onMessage, } = options;
175
177
  // Unknown/typo'd values fall back to the default rather than throwing --
176
178
  // a bad `placement` attribute on a customer's page must not blank the
177
179
  // widget.
@@ -180,15 +182,21 @@ export function createConvaiWidget(container, options) {
180
182
  : "bottom-right";
181
183
  // Inject global styles
182
184
  injectGlobalStyles(styleTarget);
185
+ // Keyframes were injected lazily by the connecting overlay and the settings
186
+ // tray, but a link-preview skeleton can render before either exists — a
187
+ // replayed transcript paints message bubbles immediately. Idempotent.
188
+ injectKeyframes(styleTarget);
183
189
  // State
184
190
  let isOpen = false;
185
191
  let isSettingsOpen = false;
186
- let isVoiceMode = defaultVoiceMode;
192
+ let isVoiceMode = defaultVoiceMode &&
193
+ !requestPublicationGrant &&
194
+ client.connectionType !== "text";
187
195
  let isMuted = false;
188
196
  let isVideoVisible = false;
189
197
  let inputValue = "";
190
- let characterName = "Character";
191
- let characterImage = "";
198
+ let characterName = publishedChatDescriptor?.display_name || "Character";
199
+ let characterImage = publishedChatDescriptor?.image_url || "";
192
200
  let audioRenderer = null;
193
201
  let hasEnteredDefaultVoiceMode = false;
194
202
  /** True after we have connected at least once; used to call reconnect() instead of connect() when opening after disconnect */
@@ -209,6 +217,9 @@ export function createConvaiWidget(container, options) {
209
217
  * so a cancellation during the await is honoured.
210
218
  */
211
219
  let pendingOpen = false;
220
+ /** Prevent an in-flight publication grant request from connecting after teardown. */
221
+ let isDestroyed = false;
222
+ const eventSubscriptions = new WidgetEventSubscriptions(() => isDestroyed);
212
223
  // DOM elements (will be created below)
213
224
  let rootElement;
214
225
  let morphingContainer;
@@ -237,6 +248,8 @@ export function createConvaiWidget(container, options) {
237
248
  let source = null;
238
249
  // Fetch character info - matches React useCharacterInfo hook
239
250
  const fetchCharacterInfo = async () => {
251
+ if (publishedChatDescriptor)
252
+ return;
240
253
  // Prefer authToken over apiKey, matching the precedence ConvaiClient
241
254
  // uses when building /connect headers -- a client carrying both must
242
255
  // behave identically in both places.
@@ -1204,7 +1217,8 @@ export function createConvaiWidget(container, options) {
1204
1217
  if (inputValue.length > 0) {
1205
1218
  handleSend();
1206
1219
  }
1207
- else {
1220
+ else if (!requestPublicationGrant &&
1221
+ client.connectionType !== "text") {
1208
1222
  // Toggle Voice Mode
1209
1223
  client.sendInterruptMessage();
1210
1224
  try {
@@ -1319,7 +1333,9 @@ export function createConvaiWidget(container, options) {
1319
1333
  const resetIcon = Icons.Redo("md");
1320
1334
  resetIcon.style.width = "18px";
1321
1335
  resetIcon.style.height = "18px";
1322
- settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1336
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1337
+ settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1338
+ }
1323
1339
  // Video - only show if connection type is video
1324
1340
  const connectionType = client.connectionType;
1325
1341
  if (connectionType === "video" && showVideo) {
@@ -1350,7 +1366,9 @@ export function createConvaiWidget(container, options) {
1350
1366
  const disconnectIcon = document.createElement("span");
1351
1367
  disconnectIcon.innerHTML = "⏻";
1352
1368
  disconnectIcon.style.fontSize = "18px";
1353
- settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1369
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1370
+ settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1371
+ }
1354
1372
  tray.appendChild(settingsRow);
1355
1373
  return tray;
1356
1374
  };
@@ -1419,7 +1437,9 @@ export function createConvaiWidget(container, options) {
1419
1437
  const resetIcon = Icons.Redo("md");
1420
1438
  resetIcon.style.width = "18px";
1421
1439
  resetIcon.style.height = "18px";
1422
- settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1440
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1441
+ settingsRow.appendChild(createOption(resetIcon, "Reset", handleReset));
1442
+ }
1423
1443
  // Video - Always show if showVideo is true and connection type is video
1424
1444
  const connectionType = client.connectionType;
1425
1445
  if (connectionType === "video" && showVideo) {
@@ -1450,7 +1470,9 @@ export function createConvaiWidget(container, options) {
1450
1470
  const disconnectIcon = document.createElement("span");
1451
1471
  disconnectIcon.innerHTML = "⏻";
1452
1472
  disconnectIcon.style.fontSize = "18px";
1453
- settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1473
+ if (!client.isPublishedChatSession || requestPublicationGrant) {
1474
+ settingsRow.appendChild(createOption(disconnectIcon, "Disconnect", handleDisconnect, false, true));
1475
+ }
1454
1476
  };
1455
1477
  // Create floating video - Matches React FloatingVideo component
1456
1478
  const createFloatingVideo = () => {
@@ -1643,6 +1665,8 @@ export function createConvaiWidget(container, options) {
1643
1665
  };
1644
1666
  // Event handlers
1645
1667
  const handleToggle = async () => {
1668
+ if (isDestroyed)
1669
+ return;
1646
1670
  if (isOpen || pendingOpen) {
1647
1671
  // Already open, or an open is already in flight: nothing to do. The
1648
1672
  // `pendingOpen` half is what keeps a repeat call from falling through
@@ -1654,24 +1678,54 @@ export function createConvaiWidget(container, options) {
1654
1678
  if (!client.state.isConnected && !client.state.isConnecting) {
1655
1679
  pendingOpen = true;
1656
1680
  try {
1657
- // Use reconnect when opening again after a disconnect (reuses stored config)
1658
- if (hasConnectedBefore && typeof client.reconnect === "function") {
1681
+ if (requestPublicationGrant || client.isPublishedChatSession) {
1682
+ if (!requestPublicationGrant) {
1683
+ throw new Error("Published chat widgets require requestPublicationGrant to start a new session");
1684
+ }
1685
+ if (!client.connectWithPublicationGrant) {
1686
+ throw new Error("This Convai client does not support published chat connections");
1687
+ }
1688
+ const launchToken = await requestPublicationGrant();
1689
+ // destroy() can run while the grant endpoint is in flight. A one-time
1690
+ // grant must not start a session for a widget which no longer exists.
1691
+ if (isDestroyed) {
1692
+ return;
1693
+ }
1694
+ await client.connectWithPublicationGrant(launchToken, freshPublicationGrantOptions(publishedChatConnectOptions));
1695
+ }
1696
+ else if (hasConnectedBefore && typeof client.reconnect === "function") {
1659
1697
  await client.reconnect();
1660
1698
  }
1661
1699
  else {
1662
1700
  await client.connect();
1663
1701
  }
1702
+ if (isDestroyed) {
1703
+ // destroy() can also run after the grant is exchanged but before
1704
+ // Core or LiveKit finishes connecting. Disconnect that late session
1705
+ // instead of leaving an invisible room alive.
1706
+ await client.disconnect();
1707
+ return;
1708
+ }
1664
1709
  // Re-checked *after* the await: a close()/toggle()/launcher click
1665
1710
  // during the connect clears the flag, and that cancellation has to
1666
1711
  // win. The client stays connected either way -- only the panel is
1667
1712
  // held back -- so a later open() takes the already-connected branch
1668
1713
  // and opens instantly.
1714
+ if (isDestroyed) {
1715
+ // destroy() can also run after the grant is exchanged but before
1716
+ // Core or LiveKit finishes connecting. Disconnect that late session
1717
+ // instead of leaving an invisible room alive.
1718
+ await client.disconnect();
1719
+ return;
1720
+ }
1669
1721
  if (!pendingOpen)
1670
1722
  return;
1671
1723
  setIsOpen(true);
1672
1724
  }
1673
1725
  catch (error) {
1674
- console.error("Failed to connect:", error);
1726
+ if (!isDestroyed) {
1727
+ console.error("Failed to connect:", error);
1728
+ }
1675
1729
  }
1676
1730
  finally {
1677
1731
  pendingOpen = false;
@@ -1697,8 +1751,14 @@ export function createConvaiWidget(container, options) {
1697
1751
  pendingOpen = false;
1698
1752
  setIsOpen(false);
1699
1753
  };
1754
+ // Published sessions must wait for Core's bot-ready handshake. A normal
1755
+ // authenticated text session still gets the text-only UI, but can send as
1756
+ // soon as it connects.
1757
+ const requiresBotReadyForText = () => Boolean(requestPublicationGrant) || Boolean(client.isPublishedChatSession);
1758
+ const isTextInputReady = () => client.state.isConnected &&
1759
+ (!requiresBotReadyForText() || client.isBotReady);
1700
1760
  const handleSend = () => {
1701
- if (inputValue.trim() && client.state.isConnected) {
1761
+ if (inputValue.trim() && isTextInputReady()) {
1702
1762
  client.sendUserTextMessage(inputValue);
1703
1763
  inputValue = "";
1704
1764
  inputElement.value = "";
@@ -1805,6 +1865,8 @@ export function createConvaiWidget(container, options) {
1805
1865
  chatContent.style.pointerEvents = "auto";
1806
1866
  // Auto-enter voice mode if defaultVoiceMode is true and not already entered
1807
1867
  if (defaultVoiceMode &&
1868
+ !requestPublicationGrant &&
1869
+ client.connectionType !== "text" &&
1808
1870
  !hasEnteredDefaultVoiceMode &&
1809
1871
  client.state.isConnected) {
1810
1872
  setTimeout(async () => {
@@ -1848,7 +1910,15 @@ export function createConvaiWidget(container, options) {
1848
1910
  return;
1849
1911
  // Clear previous content
1850
1912
  sendButton.innerHTML = "";
1851
- if (inputValue.length > 0) {
1913
+ // A published session must wait for Core's bot-ready handshake before
1914
+ // accepting text. Preserve the established interactive-widget behavior
1915
+ // for authenticated voice/video sessions.
1916
+ const isPublishedTextChat = requiresBotReadyForText();
1917
+ const textInputReady = isTextInputReady();
1918
+ inputElement.disabled = isPublishedTextChat && !textInputReady;
1919
+ if (inputValue.length > 0 ||
1920
+ requestPublicationGrant ||
1921
+ client.connectionType === "text") {
1852
1922
  // Show Send Button
1853
1923
  sendButton.appendChild(Icons.Send("md"));
1854
1924
  sendButton.style.background = `var(--convai-accent, ${aeroTheme.colors.text.primary})`;
@@ -1861,7 +1931,20 @@ export function createConvaiWidget(container, options) {
1861
1931
  // must contrast with each other, since this is the one place they meet.
1862
1932
  sendButton.style.color = "var(--convai-panel-bg, white)";
1863
1933
  sendButton.style.border = "none";
1864
- sendButton.title = "Send";
1934
+ sendButton.disabled =
1935
+ isPublishedTextChat && (inputValue.length === 0 || !textInputReady);
1936
+ if (isPublishedTextChat) {
1937
+ sendButton.style.opacity =
1938
+ inputValue.length > 0 && textInputReady ? "1" : "0.5";
1939
+ sendButton.title = textInputReady
1940
+ ? inputValue.length > 0
1941
+ ? "Send"
1942
+ : "Type a message"
1943
+ : "Waiting for character";
1944
+ }
1945
+ else {
1946
+ sendButton.title = "Send";
1947
+ }
1865
1948
  }
1866
1949
  else {
1867
1950
  // Show Voice Mode Toggle
@@ -1869,6 +1952,8 @@ export function createConvaiWidget(container, options) {
1869
1952
  sendButton.style.background = "transparent";
1870
1953
  sendButton.style.color = `var(--convai-panel-fg, ${aeroTheme.colors.text.primary})`;
1871
1954
  sendButton.style.border = `1px solid ${aeroTheme.colors.neutral[300]}`;
1955
+ sendButton.style.opacity = "1";
1956
+ sendButton.disabled = false;
1872
1957
  sendButton.title = "Voice Mode";
1873
1958
  }
1874
1959
  };
@@ -1990,7 +2075,11 @@ export function createConvaiWidget(container, options) {
1990
2075
  };
1991
2076
  // Microphone button removed - only controlled via voice mode now
1992
2077
  // Helper for Markdown - matches React MarkdownRenderer.tsx
1993
- const renderMarkdown = (text, container) => {
2078
+ // `media` renders inline image/video previews under a media link. Bot messages
2079
+ // want them; the user's own bubble does not — you already know what you typed,
2080
+ // and a preview there would let a URL a user pasted embed remote media into
2081
+ // the transcript. Links stay clickable either way.
2082
+ const renderMarkdown = (text, container, { media = true } = {}) => {
1994
2083
  if (!text)
1995
2084
  return;
1996
2085
  // Handle both actual newlines and \n escape sequences
@@ -1999,19 +2088,106 @@ export function createConvaiWidget(container, options) {
1999
2088
  lines.forEach((line, lineIndex) => {
2000
2089
  // Process markdown within each line
2001
2090
  // Bold, italic, links, images and bare URLs — see utils/inlineMarkdown.
2091
+ // `cdn.example.com/a/b/c.png` -> `cdn.example.com · c.png`
2092
+ const describeUrl = (url) => {
2093
+ try {
2094
+ const { host, pathname } = new URL(url);
2095
+ const file = pathname.split("/").filter(Boolean).pop();
2096
+ return file ? `${host} · ${file}` : host;
2097
+ }
2098
+ catch {
2099
+ return url;
2100
+ }
2101
+ };
2102
+ /**
2103
+ * A preview card that reserves its space before the media arrives, so the
2104
+ * bubble does not jump when an image decodes, and collapses to one muted
2105
+ * line if the URL does not resolve — a character can easily invent one,
2106
+ * and an empty player in the transcript reads as a broken widget.
2107
+ *
2108
+ * Chrome is derived from the inherited text colour: the widget is embedded
2109
+ * in someone else's page, on a background this code cannot know.
2110
+ */
2002
2111
  const mediaPreview = (src, alt, media) => {
2112
+ const card = document.createElement("span");
2113
+ card.setAttribute("part", "message-media-card");
2114
+ card.style.cssText = `
2115
+ display: block;
2116
+ position: relative;
2117
+ margin-top: 6px;
2118
+ max-width: 320px;
2119
+ aspect-ratio: 16 / 10;
2120
+ border-radius: 8px;
2121
+ overflow: hidden;
2122
+ background: color-mix(in srgb, currentColor 6%, transparent);
2123
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent);
2124
+ `;
2003
2125
  const el = media === "image"
2004
2126
  ? Object.assign(document.createElement("img"), { src, alt, loading: "lazy" })
2005
2127
  : Object.assign(document.createElement("video"), { src, controls: true, preload: "metadata" });
2006
2128
  el.setAttribute("part", "message-media");
2007
2129
  el.style.cssText = `
2008
2130
  display: block;
2009
- max-width: 100%;
2010
- max-height: 240px;
2011
- margin-top: 6px;
2131
+ width: 100%;
2132
+ height: 100%;
2133
+ object-fit: ${media === "image" ? "cover" : "contain"};
2012
2134
  border-radius: 8px;
2135
+ opacity: 0;
2136
+ transition: opacity .18s ease;
2013
2137
  `;
2014
- return el;
2138
+ const skeleton = document.createElement("span");
2139
+ skeleton.setAttribute("part", "message-media-skeleton");
2140
+ skeleton.setAttribute("aria-hidden", "true");
2141
+ skeleton.dataset.convaiSkeleton = "";
2142
+ skeleton.style.cssText = `
2143
+ position: absolute;
2144
+ inset: 0;
2145
+ border-radius: 8px;
2146
+ background: color-mix(in srgb, currentColor 8%, transparent);
2147
+ background-image: linear-gradient(90deg, transparent, color-mix(in srgb, currentColor 12%, transparent), transparent);
2148
+ background-size: 200% 100%;
2149
+ animation: convai-preview-shimmer 1.4s ease-in-out infinite;
2150
+ `;
2151
+ const caption = document.createElement("span");
2152
+ caption.setAttribute("part", "message-media-caption");
2153
+ caption.textContent = describeUrl(src);
2154
+ caption.style.cssText = `
2155
+ display: block;
2156
+ margin-top: 4px;
2157
+ font-size: 11px;
2158
+ opacity: .7;
2159
+ white-space: nowrap;
2160
+ overflow: hidden;
2161
+ text-overflow: ellipsis;
2162
+ `;
2163
+ const settled = () => {
2164
+ el.style.opacity = "1";
2165
+ skeleton.remove();
2166
+ };
2167
+ el.addEventListener(media === "image" ? "load" : "loadedmetadata", settled);
2168
+ el.addEventListener("error", () => {
2169
+ const dead = document.createElement("span");
2170
+ dead.setAttribute("part", "message-media-unavailable");
2171
+ dead.textContent = `Preview unavailable — ${describeUrl(src)}`;
2172
+ dead.style.cssText = `
2173
+ display: block;
2174
+ margin-top: 6px;
2175
+ font-size: 11px;
2176
+ opacity: .55;
2177
+ font-style: italic;
2178
+ white-space: nowrap;
2179
+ overflow: hidden;
2180
+ text-overflow: ellipsis;
2181
+ `;
2182
+ card.replaceWith(dead);
2183
+ caption.remove();
2184
+ });
2185
+ card.appendChild(el);
2186
+ card.appendChild(skeleton);
2187
+ const frag = document.createDocumentFragment();
2188
+ frag.appendChild(card);
2189
+ frag.appendChild(caption);
2190
+ return frag;
2015
2191
  };
2016
2192
  const processLine = (text) => {
2017
2193
  const parts = [];
@@ -2038,7 +2214,7 @@ export function createConvaiWidget(container, options) {
2038
2214
  case "link": {
2039
2215
  const a = document.createElement("a");
2040
2216
  a.href = node.href;
2041
- a.textContent = node.label;
2217
+ a.textContent = node.label === node.href ? compactUrl(node.label) : node.label;
2042
2218
  a.target = "_blank";
2043
2219
  a.rel = "noopener noreferrer";
2044
2220
  a.setAttribute("part", "message-link");
@@ -2048,13 +2224,44 @@ export function createConvaiWidget(container, options) {
2048
2224
  word-break: break-all;
2049
2225
  `;
2050
2226
  parts.push(a);
2051
- if (node.media)
2227
+ if (media && node.media)
2052
2228
  parts.push(mediaPreview(node.href, node.label, node.media));
2053
2229
  break;
2054
2230
  }
2055
- case "image":
2056
- parts.push(mediaPreview(node.src, node.alt, "image"));
2231
+ case "blocked": {
2232
+ const blocked = document.createElement("span");
2233
+ blocked.setAttribute("part", "message-link-blocked");
2234
+ blocked.textContent = node.label;
2235
+ blocked.title = `Link blocked: ${node.href}`;
2236
+ blocked.style.cssText = `
2237
+ opacity: .75;
2238
+ text-decoration: underline dotted;
2239
+ cursor: help;
2240
+ `;
2241
+ parts.push(blocked);
2057
2242
  break;
2243
+ }
2244
+ case "image": {
2245
+ if (media) {
2246
+ parts.push(mediaPreview(node.src, node.alt, "image"));
2247
+ break;
2248
+ }
2249
+ // With previews off, an image still has to say what it pointed at,
2250
+ // so it degrades to the same anchor a plain link would render.
2251
+ const imgLink = document.createElement("a");
2252
+ imgLink.href = node.src;
2253
+ imgLink.textContent = node.alt || node.src;
2254
+ imgLink.target = "_blank";
2255
+ imgLink.rel = "noopener noreferrer";
2256
+ imgLink.setAttribute("part", "message-link");
2257
+ imgLink.style.cssText = `
2258
+ color: #10b981;
2259
+ text-decoration: underline;
2260
+ word-break: break-all;
2261
+ `;
2262
+ parts.push(imgLink);
2263
+ break;
2264
+ }
2058
2265
  default:
2059
2266
  parts.push(document.createTextNode(node.text));
2060
2267
  }
@@ -2215,7 +2422,7 @@ export function createConvaiWidget(container, options) {
2215
2422
  white-space: pre-wrap;
2216
2423
  word-wrap: break-word;
2217
2424
  `;
2218
- renderMarkdown(msg.text, contentDiv);
2425
+ renderMarkdown(msg.text, contentDiv, { media: !msg.isUser });
2219
2426
  bubble.appendChild(contentDiv);
2220
2427
  messageWrapper.appendChild(bubble);
2221
2428
  // Timestamp - matches React exactly
@@ -2270,7 +2477,17 @@ export function createConvaiWidget(container, options) {
2270
2477
  };
2271
2478
  // Setup event listeners for client state changes
2272
2479
  const setupClientListeners = () => {
2273
- client.on("stateChange", () => {
2480
+ eventSubscriptions.register((listener) => client.on("stateChange", listener), () => {
2481
+ // Published chat (connectionType "text") has no microphone: never let
2482
+ // a voice-mode transition leave the panel in a mode it cannot honour.
2483
+ if (client.connectionType === "text") {
2484
+ if (isVoiceMode) {
2485
+ isVoiceMode = false;
2486
+ hasEnteredDefaultVoiceMode = true;
2487
+ void updateVoiceMode();
2488
+ }
2489
+ updateSendButton();
2490
+ }
2274
2491
  updateHeader();
2275
2492
  // Update pulse animation based on connecting state
2276
2493
  if (client.state.isConnecting) {
@@ -2328,7 +2545,7 @@ export function createConvaiWidget(container, options) {
2328
2545
  });
2329
2546
  // Audio state changes now only affect voice mode (microphone button removed)
2330
2547
  // Video state change listener
2331
- client.videoControls.on("videoStateChange", (videoState) => {
2548
+ eventSubscriptions.register((listener) => client.videoControls.on("videoStateChange", listener), (videoState) => {
2332
2549
  if (videoState.isVideoEnabled !== undefined) {
2333
2550
  isVideoVisible = videoState.isVideoEnabled;
2334
2551
  applyVoiceMode(); // synchronous listener; see applyVoiceMode()
@@ -2381,19 +2598,15 @@ export function createConvaiWidget(container, options) {
2381
2598
  }
2382
2599
  }
2383
2600
  });
2384
- client.on("messagesChange", () => {
2601
+ eventSubscriptions.register((listener) => client.on("messagesChange", listener), () => {
2385
2602
  updateMessageList();
2386
- // Call onMessage callback if provided
2387
- if (onMessage && client.chatMessages.length > 0) {
2388
- const lastMessage = client.chatMessages[client.chatMessages.length - 1];
2389
- onMessage(lastMessage);
2390
- }
2391
2603
  });
2392
2604
  // Bot ready listener - updates UI when bot becomes ready
2393
- client.on("botReady", () => {
2605
+ eventSubscriptions.register((listener) => client.on("botReady", listener), () => {
2394
2606
  updateHeader(); // Update header to show green status
2607
+ updateSendButton();
2395
2608
  });
2396
- client.on("connect", () => {
2609
+ eventSubscriptions.register((listener) => client.on("connect", listener), () => {
2397
2610
  hasConnectedBefore = true;
2398
2611
  // Initialize audio renderer on connection
2399
2612
  // NOTE: the audio elements live under rootElement, so playback ends if
@@ -2411,7 +2624,7 @@ export function createConvaiWidget(container, options) {
2411
2624
  onConnect();
2412
2625
  }
2413
2626
  });
2414
- client.on("disconnect", () => {
2627
+ eventSubscriptions.register((listener) => client.on("disconnect", listener), () => {
2415
2628
  // Cleanup audio renderer
2416
2629
  if (audioRenderer) {
2417
2630
  audioRenderer.destroy();
@@ -2423,6 +2636,42 @@ export function createConvaiWidget(container, options) {
2423
2636
  onDisconnect();
2424
2637
  }
2425
2638
  });
2639
+ // `message` carries the row actually updated. That can differ from the
2640
+ // list tail when correlated v2 streams interleave, which is why onMessage
2641
+ // no longer reads the last element of chatMessages.
2642
+ eventSubscriptions.register((listener) => client.on("message", listener), (message) => {
2643
+ const row = message;
2644
+ if (onMessage && row) {
2645
+ onMessage(row);
2646
+ }
2647
+ });
2648
+ // Screen share state change listener
2649
+ eventSubscriptions.register((listener) => client.screenShareControls.on("screenShareStateChange", listener), (screenShareState) => {
2650
+ if (screenShareState.isScreenShareActive !== undefined) {
2651
+ const isSharing = screenShareState.isScreenShareActive;
2652
+ const shareBtn = document.getElementById("convai-settings-share-btn");
2653
+ if (shareBtn) {
2654
+ const isActive = isSharing;
2655
+ shareBtn.style.backgroundColor = isActive
2656
+ ? "rgba(16, 185, 129, 0.15)"
2657
+ : "transparent";
2658
+ shareBtn.style.color = isActive
2659
+ ? "#10b981"
2660
+ : aeroTheme.colors.text.primary;
2661
+ // Update Icon - first child is icon container
2662
+ const iconContainer = shareBtn.querySelector("div");
2663
+ if (iconContainer) {
2664
+ iconContainer.innerHTML = "";
2665
+ const icon = isActive
2666
+ ? Icons.StopScreenShare("md")
2667
+ : Icons.ScreenShare("md");
2668
+ icon.style.width = "18px";
2669
+ icon.style.height = "18px";
2670
+ iconContainer.appendChild(icon);
2671
+ }
2672
+ }
2673
+ }
2674
+ });
2426
2675
  };
2427
2676
  // Initialize
2428
2677
  createDOM();
@@ -2509,6 +2758,7 @@ export function createConvaiWidget(container, options) {
2509
2758
  updateMessageList();
2510
2759
  },
2511
2760
  destroy: () => {
2761
+ isDestroyed = true;
2512
2762
  // Cleanup audio renderer
2513
2763
  if (audioRenderer) {
2514
2764
  audioRenderer.destroy();
@@ -2523,12 +2773,9 @@ export function createConvaiWidget(container, options) {
2523
2773
  if (floatingVideo.parentElement) {
2524
2774
  floatingVideo.remove();
2525
2775
  }
2526
- // Unsubscribe from client events
2527
- client.off("stateChange", () => { });
2528
- client.off("messagesChange", () => { });
2529
- client.off("botReady", () => { });
2530
- client.off("connect", () => { });
2531
- client.off("disconnect", () => { });
2776
+ // Unsubscribe using the exact callbacks registered above. The guards
2777
+ // also suppress an event that was queued just before teardown.
2778
+ eventSubscriptions.unsubscribeAll();
2532
2779
  },
2533
2780
  };
2534
2781
  return widget;