@runtypelabs/persona 3.34.1 → 3.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +11 -11
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
  5. package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +11 -11
  9. package/dist/codegen.js +10 -10
  10. package/dist/index.cjs +46 -44
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +514 -158
  13. package/dist/index.d.ts +514 -158
  14. package/dist/index.global.js +41 -89
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +46 -44
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/launcher.global.js +2 -2
  21. package/dist/launcher.global.js.map +1 -1
  22. package/dist/markdown-parsers.js +53 -0
  23. package/dist/plugin-kit.d.cts +4 -4
  24. package/dist/plugin-kit.d.ts +4 -4
  25. package/dist/runtype-tts.js +1 -0
  26. package/dist/smart-dom-reader.cjs +1 -1
  27. package/dist/smart-dom-reader.d.cts +352 -103
  28. package/dist/smart-dom-reader.d.ts +352 -103
  29. package/dist/smart-dom-reader.js +1 -1
  30. package/dist/theme-editor-preview.cjs +203 -0
  31. package/dist/theme-editor-preview.d.cts +5748 -0
  32. package/dist/theme-editor-preview.d.ts +5748 -0
  33. package/dist/theme-editor-preview.js +203 -0
  34. package/dist/theme-editor.cjs +14 -131
  35. package/dist/theme-editor.d.cts +362 -574
  36. package/dist/theme-editor.d.ts +362 -574
  37. package/dist/theme-editor.js +14 -131
  38. package/dist/theme-reference.cjs +1 -1
  39. package/dist/theme-reference.d.cts +1 -1
  40. package/dist/theme-reference.d.ts +1 -1
  41. package/dist/theme-reference.js +1 -1
  42. package/dist/voice-worklet-player.cjs +80 -0
  43. package/dist/voice-worklet-player.d.cts +215 -0
  44. package/dist/voice-worklet-player.d.ts +215 -0
  45. package/dist/voice-worklet-player.js +80 -0
  46. package/dist/widget.css +54 -36
  47. package/package.json +16 -2
  48. package/src/animations/glyph-cycle.ts +11 -14
  49. package/src/animations/wipe.ts +1 -1
  50. package/src/ask-user-question-tool.test.ts +1 -1
  51. package/src/ask-user-question-tool.ts +6 -8
  52. package/src/client.test.ts +56 -12
  53. package/src/client.ts +35 -31
  54. package/src/codegen.test.ts +1 -1
  55. package/src/codegen.ts +1 -1
  56. package/src/components/approval-bubble.test.ts +1 -1
  57. package/src/components/approval-bubble.ts +3 -3
  58. package/src/components/artifact-pane.ts +3 -3
  59. package/src/components/ask-user-question-bubble.test.ts +14 -14
  60. package/src/components/ask-user-question-bubble.ts +10 -10
  61. package/src/components/composer-builder.ts +17 -16
  62. package/src/components/composer-parts.test.ts +54 -0
  63. package/src/components/composer-parts.ts +95 -126
  64. package/src/components/demo-carousel.ts +1 -1
  65. package/src/components/event-stream-view.test.ts +7 -7
  66. package/src/components/event-stream-view.ts +5 -5
  67. package/src/components/header-builder.ts +33 -27
  68. package/src/components/header-layouts.ts +1 -1
  69. package/src/components/header-parts.test.ts +90 -0
  70. package/src/components/header-parts.ts +62 -93
  71. package/src/components/launcher.test.ts +11 -0
  72. package/src/components/launcher.ts +1 -1
  73. package/src/components/message-bubble.test.ts +74 -2
  74. package/src/components/message-bubble.ts +25 -11
  75. package/src/components/messages.ts +2 -2
  76. package/src/components/panel.test.ts +1 -1
  77. package/src/components/panel.ts +92 -97
  78. package/src/components/pill-composer-builder.ts +28 -24
  79. package/src/components/reasoning-bubble.ts +2 -2
  80. package/src/components/suggestions.ts +1 -1
  81. package/src/components/tool-bubble.ts +1 -1
  82. package/src/components/widget-view.test.ts +181 -0
  83. package/src/components/widget-view.ts +234 -0
  84. package/src/defaults.ts +54 -51
  85. package/src/generated/runtype-openapi-contract.ts +5 -0
  86. package/src/index-core.ts +30 -4
  87. package/src/index-global.ts +73 -6
  88. package/src/index.ts +7 -1
  89. package/src/install.test.ts +11 -11
  90. package/src/install.ts +28 -18
  91. package/src/launcher-global.ts +25 -14
  92. package/src/markdown-parsers-eager.ts +20 -0
  93. package/src/markdown-parsers-entry.ts +4 -0
  94. package/src/markdown-parsers-loader.ts +49 -0
  95. package/src/plugin-kit.test.ts +1 -1
  96. package/src/plugin-kit.ts +7 -7
  97. package/src/plugins/types.ts +5 -5
  98. package/src/postprocessors.ts +27 -14
  99. package/src/runtime/host-layout.test.ts +1 -1
  100. package/src/runtime/host-layout.ts +4 -5
  101. package/src/runtime/init.ts +2 -2
  102. package/src/session.test.ts +5 -6
  103. package/src/session.ts +333 -227
  104. package/src/session.voice.test.ts +146 -0
  105. package/src/session.webmcp.test.ts +24 -25
  106. package/src/smart-dom-reader.ts +3 -4
  107. package/src/styles/widget.css +54 -36
  108. package/src/suggest-replies-tool.test.ts +3 -3
  109. package/src/suggest-replies-tool.ts +5 -5
  110. package/src/testing/index.ts +1 -1
  111. package/src/theme-editor/color-utils.ts +2 -2
  112. package/src/theme-editor/index.ts +4 -13
  113. package/src/theme-editor/presets.ts +1 -1
  114. package/src/theme-editor/preview-utils.ts +22 -20
  115. package/src/theme-editor/preview.ts +4 -1
  116. package/src/theme-editor/role-mappings.ts +3 -3
  117. package/src/theme-editor/sections.ts +21 -20
  118. package/src/theme-editor/types.ts +1 -1
  119. package/src/theme-editor/webmcp/index.ts +1 -1
  120. package/src/theme-editor/webmcp/summary.ts +1 -2
  121. package/src/theme-editor/webmcp/tools.ts +30 -30
  122. package/src/theme-editor/webmcp/types.ts +2 -2
  123. package/src/theme-editor-preview.ts +10 -0
  124. package/src/theme-reference.ts +26 -26
  125. package/src/types.ts +368 -99
  126. package/src/ui.approval-plugin.test.ts +3 -3
  127. package/src/ui.ask-user-question-plugin.test.ts +9 -9
  128. package/src/ui.component-directive.test.ts +4 -4
  129. package/src/ui.composer-bar.test.ts +10 -11
  130. package/src/ui.composer-keyboard.test.ts +5 -5
  131. package/src/ui.header-icon-color.test.ts +59 -0
  132. package/src/ui.scroll.test.ts +5 -6
  133. package/src/ui.stop-button.test.ts +3 -4
  134. package/src/ui.suggest-replies.test.ts +2 -2
  135. package/src/ui.ts +292 -131
  136. package/src/utils/buttons.ts +1 -1
  137. package/src/utils/code-generators.ts +1 -1
  138. package/src/utils/composer-history.ts +3 -3
  139. package/src/utils/dom-context.test.ts +2 -2
  140. package/src/utils/dom-context.ts +7 -7
  141. package/src/utils/dom.test.ts +67 -0
  142. package/src/utils/dom.ts +75 -0
  143. package/src/utils/dropdown.ts +2 -2
  144. package/src/utils/event-stream-buffer.test.ts +2 -2
  145. package/src/utils/message-fingerprint.test.ts +9 -0
  146. package/src/utils/message-fingerprint.ts +5 -0
  147. package/src/utils/morph.ts +1 -1
  148. package/src/utils/sanitize.ts +32 -16
  149. package/src/utils/sequence-buffer.test.ts +8 -8
  150. package/src/utils/sequence-buffer.ts +4 -4
  151. package/src/utils/smart-dom-adapter.test.ts +1 -1
  152. package/src/utils/smart-dom-adapter.ts +4 -5
  153. package/src/utils/speech-text.test.ts +122 -0
  154. package/src/utils/speech-text.ts +101 -0
  155. package/src/utils/stream-animation.test.ts +3 -3
  156. package/src/utils/stream-animation.ts +6 -6
  157. package/src/utils/theme.test.ts +1 -1
  158. package/src/utils/throughput-tracker.test.ts +11 -11
  159. package/src/utils/throughput-tracker.ts +9 -9
  160. package/src/utils/tokens.ts +13 -13
  161. package/src/utils/virtual-scroller.ts +1 -1
  162. package/src/vendor/smart-dom-reader/README.md +4 -4
  163. package/src/vendor/smart-dom-reader/index.d.ts +1 -1
  164. package/src/vendor/smart-dom-reader/index.js +2 -2
  165. package/src/version.ts +1 -1
  166. package/src/voice/audio-playback-manager.test.ts +159 -0
  167. package/src/voice/audio-playback-manager.ts +145 -30
  168. package/src/voice/browser-speech-engine.ts +130 -0
  169. package/src/voice/fallback-speech-engine.ts +89 -0
  170. package/src/voice/index.ts +21 -0
  171. package/src/voice/read-aloud-controller.test.ts +143 -0
  172. package/src/voice/read-aloud-controller.ts +136 -0
  173. package/src/voice/runtype-speech-engine.test.ts +280 -0
  174. package/src/voice/runtype-speech-engine.ts +211 -0
  175. package/src/voice/runtype-tts-entry.ts +10 -0
  176. package/src/voice/runtype-tts-loader.test.ts +47 -0
  177. package/src/voice/runtype-tts-loader.ts +42 -0
  178. package/src/voice/runtype-voice-provider.ts +332 -537
  179. package/src/voice/voice-factory.ts +26 -4
  180. package/src/voice/voice.test.ts +321 -7
  181. package/src/voice/worklet-playback-engine.ts +272 -0
  182. package/src/voice-worklet-player.ts +39 -0
  183. package/src/webmcp-bridge.test.ts +6 -6
  184. package/src/webmcp-bridge.ts +24 -25
  185. package/src/webmcp-polyfill.ts +1 -1
  186. package/src/voice/voice-activity-detector.ts +0 -90
package/src/ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
2
2
  import { resolveSanitizer } from "./utils/sanitize";
3
+ import { loadMarkdownParsers, getMarkdownParsersSync } from "./markdown-parsers-loader";
3
4
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
4
5
  import {
5
6
  AgentWidgetConfig,
@@ -24,6 +25,7 @@ import {
24
25
  LoadingIndicatorRenderContext,
25
26
  IdleIndicatorRenderContext,
26
27
  VoiceStatus,
28
+ ReadAloudState,
27
29
  PersonaArtifactRecord,
28
30
  PersonaArtifactManualUpsert
29
31
  } from "./types";
@@ -64,8 +66,9 @@ import {
64
66
  import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
65
67
  import { acquireScrollLock } from "./utils/scroll-lock";
66
68
  import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./utils/dock";
67
- import { createLauncherButton } from "./components/launcher";
68
- import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
69
+ import { LauncherButton } from "./components/launcher";
70
+ import { buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
71
+ import { createWidgetView, resolveLauncher } from "./components/widget-view";
69
72
  import { HEADER_THEME_CSS } from "./components/header-builder";
70
73
  import { buildHeaderWithLayout } from "./components/header-layouts";
71
74
  import { positionMap } from "./utils/positioning";
@@ -313,7 +316,7 @@ type Controller = {
313
316
  injectMessageBatch: (optionsList: InjectMessageOptions[]) => AgentWidgetMessage[];
314
317
  /**
315
318
  * Convenience method for injecting an assistant message that renders as a
316
- * registered component same shape Persona produces from a streamed
319
+ * registered component: same shape Persona produces from a streamed
317
320
  * `{ "text": "...", "component": "...", "props": {...} }` payload.
318
321
  */
319
322
  injectComponentDirective: (
@@ -340,6 +343,13 @@ type Controller = {
340
343
  // State query methods
341
344
  isOpen: () => boolean;
342
345
  isVoiceActive: () => boolean;
346
+ // Read-aloud (text-to-speech) methods
347
+ toggleReadAloud: (messageId: string) => void;
348
+ stopReadAloud: () => void;
349
+ getReadAloudState: (messageId: string) => ReadAloudState;
350
+ onReadAloudChange: (
351
+ listener: (activeId: string | null, state: ReadAloudState) => void
352
+ ) => () => void;
343
353
  getState: () => AgentWidgetStateSnapshot;
344
354
  // Feedback methods (CSAT/NPS)
345
355
  showCSATFeedback: (options?: Partial<CSATFeedbackOptions>) => void;
@@ -520,7 +530,7 @@ export const createAgentExperience = (
520
530
  const eventBus = createEventBus<AgentWidgetControllerEventMap>();
521
531
 
522
532
  // When persistState is explicitly false, message-history persistence is
523
- // disabled including any user-supplied storageAdapter. This is the strict
533
+ // disabled: including any user-supplied storageAdapter. This is the strict
524
534
  // kill-switch semantic; pass `persistState: true` (or omit it) to opt in.
525
535
  const messagePersistenceDisabled = config.persistState === false;
526
536
  const storageAdapter: AgentWidgetStorageAdapter | null =
@@ -758,8 +768,13 @@ export const createAgentExperience = (
758
768
  }
759
769
  }
760
770
 
761
- const { wrapper, panel, pillRoot } = createWrapper(config);
762
- const panelElements = buildPanel(config, isPanelToggleable());
771
+ // The view layer (`components/widget-view.ts`) owns the one-time structural
772
+ // assembly (shell + panel) and groups the resulting refs into named regions.
773
+ // Behavior stays here in ui.ts; the locals below mirror the grouped refs so
774
+ // the surrounding orchestration code keeps its existing variable names.
775
+ const view = createWidgetView({ config, showClose: isPanelToggleable() });
776
+ const { wrapper, panel, pillRoot } = view.shell;
777
+ const panelElements = view.panelElements;
763
778
  let {
764
779
  container,
765
780
  body,
@@ -882,6 +897,9 @@ export const createAgentExperience = (
882
897
  if (existingHeader) {
883
898
  existingHeader.replaceWith(customHeader);
884
899
  header = customHeader;
900
+ // Keep the view's tracked header element in sync so a later
901
+ // header-layout rebuild (view.replaceHeader) targets the mounted node.
902
+ view.header.element = customHeader;
885
903
  }
886
904
  }
887
905
  }
@@ -985,12 +1003,15 @@ export const createAgentExperience = (
985
1003
  const ensureComposerAttachmentSurface = (rootFooter: HTMLElement) => {
986
1004
  const att = config.attachments;
987
1005
  if (!att?.enabled) return;
988
- let previews = rootFooter.querySelector<HTMLElement>(".persona-attachment-previews");
1006
+ let previews =
1007
+ rootFooter.querySelector<HTMLElement>("[data-persona-composer-attachment-previews]") ??
1008
+ rootFooter.querySelector<HTMLElement>(".persona-attachment-previews");
989
1009
  if (!previews) {
990
1010
  previews = createElement(
991
1011
  "div",
992
1012
  "persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2"
993
1013
  );
1014
+ previews.setAttribute("data-persona-composer-attachment-previews", "");
994
1015
  previews.style.display = "none";
995
1016
  const form = rootFooter.querySelector("[data-persona-composer-form]");
996
1017
  if (form?.parentNode) {
@@ -999,9 +1020,13 @@ export const createAgentExperience = (
999
1020
  rootFooter.insertBefore(previews, rootFooter.firstChild);
1000
1021
  }
1001
1022
  }
1002
- if (!rootFooter.querySelector<HTMLInputElement>('input[type="file"]')) {
1023
+ const hasFileInput =
1024
+ rootFooter.querySelector<HTMLInputElement>("[data-persona-composer-attachment-input]") ??
1025
+ rootFooter.querySelector<HTMLInputElement>('input[type="file"]');
1026
+ if (!hasFileInput) {
1003
1027
  const fileIn = createElement("input") as HTMLInputElement;
1004
1028
  fileIn.type = "file";
1029
+ fileIn.setAttribute("data-persona-composer-attachment-input", "");
1005
1030
  fileIn.accept = (att.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
1006
1031
  fileIn.multiple = (att.maxFiles ?? 4) > 1;
1007
1032
  fileIn.style.display = "none";
@@ -1063,13 +1088,24 @@ export const createAgentExperience = (
1063
1088
  : undefined
1064
1089
  });
1065
1090
  if (customComposer) {
1066
- // Replace the default footer with custom composer
1067
- footer.replaceWith(customComposer);
1068
- footer = customComposer;
1091
+ // Replace the default footer with custom composer (keeps view.composer.footer in sync).
1092
+ view.replaceComposer(customComposer);
1093
+ footer = view.composer.footer;
1069
1094
  }
1070
1095
  }
1071
1096
 
1072
1097
  const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
1098
+ // Prefer stable `data-persona-composer-*` refs (set by the default and
1099
+ // pill builders); fall back to the legacy class selectors so custom
1100
+ // plugin composers built before these refs existed still bind.
1101
+ const pick = <T extends HTMLElement>(...selectors: string[]): T | null => {
1102
+ for (const selector of selectors) {
1103
+ const found = rootFooter.querySelector<T>(selector);
1104
+ if (found) return found;
1105
+ }
1106
+ return null;
1107
+ };
1108
+
1073
1109
  const form = rootFooter.querySelector<HTMLFormElement>("[data-persona-composer-form]");
1074
1110
  const ta = rootFooter.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]");
1075
1111
  const sb = rootFooter.querySelector<HTMLButtonElement>("[data-persona-composer-submit]");
@@ -1083,18 +1119,31 @@ export const createAgentExperience = (
1083
1119
  micButtonWrapper = mic.parentElement as HTMLElement | null;
1084
1120
  }
1085
1121
  if (st) statusText = st;
1086
- const sug = rootFooter.querySelector<HTMLElement>(
1122
+ const sug = pick<HTMLElement>(
1123
+ "[data-persona-composer-suggestions]",
1087
1124
  ".persona-mb-3.persona-flex.persona-flex-wrap.persona-gap-2"
1088
1125
  );
1089
1126
  if (sug) suggestions = sug;
1090
- const attBtn = rootFooter.querySelector<HTMLButtonElement>(".persona-attachment-button");
1127
+ const attBtn = pick<HTMLButtonElement>(
1128
+ "[data-persona-composer-attachment-button]",
1129
+ ".persona-attachment-button"
1130
+ );
1091
1131
  if (attBtn) {
1092
1132
  attachmentButton = attBtn;
1093
1133
  attachmentButtonWrapper = attBtn.parentElement as HTMLElement | null;
1094
1134
  }
1095
- attachmentInput = rootFooter.querySelector<HTMLInputElement>('input[type="file"]');
1096
- attachmentPreviewsContainer = rootFooter.querySelector<HTMLElement>(".persona-attachment-previews");
1097
- const ar = rootFooter.querySelector<HTMLElement>(".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between");
1135
+ attachmentInput = pick<HTMLInputElement>(
1136
+ "[data-persona-composer-attachment-input]",
1137
+ 'input[type="file"]'
1138
+ );
1139
+ attachmentPreviewsContainer = pick<HTMLElement>(
1140
+ "[data-persona-composer-attachment-previews]",
1141
+ ".persona-attachment-previews"
1142
+ );
1143
+ const ar = pick<HTMLElement>(
1144
+ "[data-persona-composer-actions]",
1145
+ ".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between"
1146
+ );
1098
1147
  if (ar) _actionsRow = ar;
1099
1148
  };
1100
1149
  ensureComposerAttachmentSurface(footer);
@@ -1310,6 +1359,47 @@ export const createAgentExperience = (
1310
1359
  // This handles clicks even after idiomorph morphs the DOM and strips inline listeners
1311
1360
  const messageVoteState = new Map<string, "upvote" | "downvote">();
1312
1361
 
1362
+ // Read-aloud (text-to-speech) button state. The ReadAloudController in the
1363
+ // session is the source of truth; these mirror its last-known state so the
1364
+ // button visuals can be re-applied after every render/morph (which would
1365
+ // otherwise revert the swapped icon to the default "volume-2").
1366
+ let readAloudActiveId: string | null = null;
1367
+ let readAloudActiveState: ReadAloudState = "idle";
1368
+
1369
+ const READ_ALOUD_ICONS: Record<ReadAloudState, { icon: string; label: string }> = {
1370
+ idle: { icon: "volume-2", label: "Read aloud" },
1371
+ loading: { icon: "loader-circle", label: "Loading…" },
1372
+ playing: { icon: "pause", label: "Pause" },
1373
+ paused: { icon: "play", label: "Resume" },
1374
+ };
1375
+
1376
+ const applyReadAloudButton = (btn: HTMLElement, state: ReadAloudState) => {
1377
+ const { icon, label } = READ_ALOUD_ICONS[state];
1378
+ btn.setAttribute("aria-label", label);
1379
+ btn.title = label;
1380
+ btn.setAttribute("aria-pressed", state === "idle" ? "false" : "true");
1381
+ btn.classList.toggle("persona-message-action-active", state !== "idle");
1382
+ btn.classList.toggle("persona-message-action-loading", state === "loading");
1383
+ const svg = renderLucideIcon(icon, 14, "currentColor", 2);
1384
+ if (svg) {
1385
+ btn.innerHTML = "";
1386
+ btn.appendChild(svg);
1387
+ }
1388
+ };
1389
+
1390
+ // Re-apply the current read-aloud state to every read-aloud button in the
1391
+ // thread. Called on state change and after each render so a button that is
1392
+ // playing/paused keeps its icon across DOM morphs.
1393
+ const refreshReadAloudButtons = () => {
1394
+ const buttons = messagesWrapper.querySelectorAll<HTMLElement>('[data-action="read-aloud"]');
1395
+ buttons.forEach((btn) => {
1396
+ const container = btn.closest("[data-actions-for]");
1397
+ const id = container?.getAttribute("data-actions-for") ?? null;
1398
+ const state: ReadAloudState = id && id === readAloudActiveId ? readAloudActiveState : "idle";
1399
+ applyReadAloudButton(btn, state);
1400
+ });
1401
+ };
1402
+
1313
1403
  messagesWrapper.addEventListener('click', (event) => {
1314
1404
  const target = event.target as HTMLElement;
1315
1405
  const actionBtn = target.closest('.persona-message-action-btn[data-action]') as HTMLElement;
@@ -1356,13 +1446,17 @@ export const createAgentExperience = (
1356
1446
  });
1357
1447
  messageActionCallbacks.onCopy(message);
1358
1448
  }
1449
+ } else if (action === 'read-aloud') {
1450
+ // Toggle play/pause/resume; ReadAloudController drives the engine and
1451
+ // notifies onReadAloudChange, which refreshes the button icon.
1452
+ session.toggleReadAloud(messageId);
1359
1453
  } else if (action === 'upvote' || action === 'downvote') {
1360
1454
  const currentVote = messageVoteState.get(messageId) ?? null;
1361
1455
  const wasActive = currentVote === action;
1362
1456
  const iconName = action === 'upvote' ? 'thumbs-up' : 'thumbs-down';
1363
1457
 
1364
1458
  if (wasActive) {
1365
- // Toggle off revert to outline icon
1459
+ // Toggle off: revert to outline icon
1366
1460
  messageVoteState.delete(messageId);
1367
1461
  actionBtn.classList.remove("persona-message-action-active");
1368
1462
  const outlineIcon = renderLucideIcon(iconName, 14, "currentColor", 2);
@@ -1486,7 +1580,7 @@ export const createAgentExperience = (
1486
1580
  let markdown = artifact?.markdown;
1487
1581
  let title = artifact?.title || 'artifact';
1488
1582
  if (!markdown) {
1489
- // After page refresh, session state is gone read from the persisted card message
1583
+ // After page refresh, session state is gone: read from the persisted card message
1490
1584
  const cardEl = dlBtn.closest('[data-open-artifact]');
1491
1585
  const msgEl = cardEl?.closest('[data-message-id]');
1492
1586
  const msgId = msgEl?.getAttribute('data-message-id');
@@ -1617,7 +1711,7 @@ export const createAgentExperience = (
1617
1711
 
1618
1712
  /**
1619
1713
  * If `groupedAutoAdvance` is enabled (default) and we're not on the final
1620
- * page, advance one step. The final page never auto-submits users always
1714
+ * page, advance one step. The final page never auto-submits: users always
1621
1715
  * confirm with an explicit Submit-all click so they can review.
1622
1716
  */
1623
1717
  const maybeAutoAdvance = (sheet: HTMLElement): void => {
@@ -1657,7 +1751,7 @@ export const createAgentExperience = (
1657
1751
 
1658
1752
  // Best-effort: if this sheet corresponds to a LOCAL-awaiting tool,
1659
1753
  // unblock the paused execution with a sentinel answer so the server
1660
- // doesn't sit in waiting_for_local forever. Fire-and-forget errors
1754
+ // doesn't sit in waiting_for_local forever. Fire-and-forget: errors
1661
1755
  // are surfaced to the onError callback. Flip the answered flag first
1662
1756
  // so a racing render pass doesn't re-mount the sheet mid-dismissal.
1663
1757
  const sourceMessage = sessionRef.current
@@ -1693,7 +1787,7 @@ export const createAgentExperience = (
1693
1787
  return;
1694
1788
  }
1695
1789
 
1696
- // 1-question modes preserve original UX.
1790
+ // 1-question modes: preserve original UX.
1697
1791
  if (multiSelect) {
1698
1792
  const pressed = trigger.getAttribute("aria-pressed") === "true";
1699
1793
  trigger.setAttribute("aria-pressed", pressed ? "false" : "true");
@@ -2137,7 +2231,7 @@ export const createAgentExperience = (
2137
2231
  // Composer-bar mode: the pill (footer) and peek banner live in a
2138
2232
  // viewport-fixed sibling of the wrapper (`pillRoot`) so they're
2139
2233
  // independent of the wrapper's geometry transitions. Critical for
2140
- // modal mode the wrapper there has `transform: translate(-50%, -50%)`
2234
+ // modal mode: the wrapper there has `transform: translate(-50%, -50%)`
2141
2235
  // which would establish a containing block trapping any `position: fixed`
2142
2236
  // descendant. Order inside pillRoot: peekBanner (slim row above pill)
2143
2237
  // → footer (pill). pillRoot's `gap` spaces them; the peek is hidden by
@@ -2165,7 +2259,7 @@ export const createAgentExperience = (
2165
2259
  // `applyComposerBarGeometry()` (per-state inline on the wrapper), the
2166
2260
  // pill carries its own chrome via `.persona-pill-composer`, and the
2167
2261
  // expanded chat panel chrome (border + radius + shadow + bg) is painted
2168
- // inline on the `container` (NOT the panel the panel is a transparent
2262
+ // inline on the `container` (NOT the panel: the panel is a transparent
2169
2263
  // flex column with a gap so the pill renders as a sibling below the
2170
2264
  // chrome). Same theme contract as floating mode
2171
2265
  // (`theme.components.panel.{shadow,border,borderRadius}`); collapsed
@@ -2252,7 +2346,7 @@ export const createAgentExperience = (
2252
2346
  // `min-height: 0` / `overflow-y: auto` that make the messages area a
2253
2347
  // scroll container. Between the reset and the mode-specific reapply,
2254
2348
  // the body's clientHeight == scrollHeight momentarily, so the browser
2255
- // clamps scrollTop to 0 and a synchronous restore at the end of this
2349
+ // clamps scrollTop to 0, and a synchronous restore at the end of this
2256
2350
  // function runs before layout has reflowed, so the write is also
2257
2351
  // clamped. Defer the restore to the next frame, once the reapplied
2258
2352
  // styles have produced a scrollable container again.
@@ -2287,7 +2381,7 @@ export const createAgentExperience = (
2287
2381
  'persona-bottom-4', 'persona-right-4', 'persona-left-4', 'persona-top-4'
2288
2382
  );
2289
2383
 
2290
- // Wrapper fill entire viewport
2384
+ // Wrapper: fill entire viewport
2291
2385
  wrapper.style.cssText = `
2292
2386
  position: fixed !important;
2293
2387
  inset: 0 !important;
@@ -2302,7 +2396,7 @@ export const createAgentExperience = (
2302
2396
  background-color: var(--persona-surface, #ffffff) !important;
2303
2397
  `;
2304
2398
 
2305
- // Panel fill wrapper, no radius/shadow
2399
+ // Panel: fill wrapper, no radius/shadow
2306
2400
  panel.style.cssText = `
2307
2401
  position: relative !important;
2308
2402
  display: flex !important;
@@ -2318,7 +2412,7 @@ export const createAgentExperience = (
2318
2412
  border-radius: 0 !important;
2319
2413
  `;
2320
2414
 
2321
- // Container fill panel, no radius/border
2415
+ // Container: fill panel, no radius/border
2322
2416
  container.style.cssText = `
2323
2417
  display: flex !important;
2324
2418
  flex-direction: column !important;
@@ -2332,12 +2426,12 @@ export const createAgentExperience = (
2332
2426
  border: none !important;
2333
2427
  `;
2334
2428
 
2335
- // Body scrollable messages
2429
+ // Body: scrollable messages
2336
2430
  body.style.flex = '1 1 0%';
2337
2431
  body.style.minHeight = '0';
2338
2432
  body.style.overflowY = 'auto';
2339
2433
 
2340
- // Footer pinned at bottom
2434
+ // Footer: pinned at bottom
2341
2435
  footer.style.flexShrink = '0';
2342
2436
 
2343
2437
  wasMobileFullscreen = true;
@@ -2635,7 +2729,7 @@ export const createAgentExperience = (
2635
2729
  // chips win when the latest-turn rule yields any (last suggest_replies tool
2636
2730
  // message with no user message after it); otherwise static config chips
2637
2731
  // keep their before-first-user-message behavior. Config updates MUST route
2638
- // through here too re-rendering with only `config.suggestionChips` would
2732
+ // through here too: re-rendering with only `config.suggestionChips` would
2639
2733
  // drop a live agent chip row until the next message change.
2640
2734
  const renderSuggestions = (messages?: AgentWidgetMessage[]) => {
2641
2735
  if (!session) return;
@@ -2686,6 +2780,11 @@ export const createAgentExperience = (
2686
2780
  // pending.
2687
2781
  const lastApprovalBubbleFingerprint = new Map<string, string>();
2688
2782
  let configVersion = 0;
2783
+ // Whether the markdown parsers (marked + dompurify) were already loaded when
2784
+ // this widget mounted. False only on the IIFE/CDN lazy path before the
2785
+ // `markdown-parsers.js` chunk resolves; in that window messages render as
2786
+ // escaped plain text and are re-rendered once the chunk lands (see below).
2787
+ const markdownReadyAtInit = getMarkdownParsersSync() !== null;
2689
2788
  const autoFollow = createFollowStateController();
2690
2789
  let lastScrollTop = 0;
2691
2790
  let scrollRAF: number | null = null;
@@ -2856,7 +2955,7 @@ export const createAgentExperience = (
2856
2955
  updateScrollToBottomCountBadge();
2857
2956
  };
2858
2957
 
2859
- // Whether the user is currently away from the latest content drives both
2958
+ // Whether the user is currently away from the latest content: drives both
2860
2959
  // the scroll-to-bottom affordance and the new-messages badge. In follow
2861
2960
  // mode that's "auto-follow paused"; in anchor-top/none modes (where there
2862
2961
  // is no follow state) it's simply "not near the bottom".
@@ -2906,7 +3005,7 @@ export const createAgentExperience = (
2906
3005
 
2907
3006
  if (!force && !isStreaming) return;
2908
3007
 
2909
- // Only cancel the pending schedule rAF keep the ongoing smooth scroll
3008
+ // Only cancel the pending schedule rAF: keep the ongoing smooth scroll
2910
3009
  // animation alive so isAutoScrolling stays true. This prevents scroll
2911
3010
  // events fired by DOM morphing (between cancel and the next rAF) from
2912
3011
  // being misinterpreted as user-initiated upward scrolls that would
@@ -3044,7 +3143,7 @@ export const createAgentExperience = (
3044
3143
  cancelAnimationFrame(anchorRAF);
3045
3144
  anchorRAF = null;
3046
3145
  }
3047
- // Also stop an in-flight anchor scroll animation otherwise its
3146
+ // Also stop an in-flight anchor scroll animation: otherwise its
3048
3147
  // remaining frames keep easing scrollTop toward the stale anchor target
3049
3148
  // after a jump-to-latest, chat clear, or scroll-mode change.
3050
3149
  cancelSmoothScroll();
@@ -3103,7 +3202,7 @@ export const createAgentExperience = (
3103
3202
 
3104
3203
  // Content growth handler (ResizeObserver-driven). In follow mode this is
3105
3204
  // what keeps the transcript pinned when content grows *without* a render
3106
- // event images/embeds finishing loading mid-stream, fonts swapping,
3205
+ // event: images/embeds finishing loading mid-stream, fonts swapping,
3107
3206
  // the panel or composer resizing. In anchor-top mode it gives spacer room
3108
3207
  // back as the streamed response grows (shrink-only, so total scroll height
3109
3208
  // stays constant and nothing jumps).
@@ -3130,7 +3229,7 @@ export const createAgentExperience = (
3130
3229
 
3131
3230
  // Reacts to a user message the user just sent (seeded so restored history
3132
3231
  // never triggers it). Follow mode re-sticks to the bottom even if the user
3133
- // had scrolled up sending is an unambiguous "take me to the latest"
3232
+ // had scrolled up: sending is an unambiguous "take me to the latest"
3134
3233
  // signal. Anchor-top mode pins the sent message near the viewport top.
3135
3234
  const handleUserMessageSent = (messageId: string) => {
3136
3235
  const mode = getScrollMode();
@@ -3240,12 +3339,12 @@ export const createAgentExperience = (
3240
3339
  // Track active message IDs for cache pruning
3241
3340
  const activeMessageIds = new Set<string>();
3242
3341
  // Track ask_user_question tool-call ids whose bubbles were rendered this
3243
- // pass used to prune stale sheets from the composer overlay afterward.
3342
+ // pass: used to prune stale sheets from the composer overlay afterward.
3244
3343
  const liveAskToolIds = new Set<string>();
3245
3344
 
3246
3345
  // Plugins that render `ask_user_question` typically attach DOM listeners
3247
3346
  // directly to their buttons. The wrapper cache uses `cloneNode(true)` and
3248
- // idiomorph inserts new nodes via `document.importNode` both strip
3347
+ // idiomorph inserts new nodes via `document.importNode`: both strip
3249
3348
  // listeners. For plugin-handled ask messages we therefore append an empty
3250
3349
  // stub during the morph pass and hydrate the live plugin bubble into the
3251
3350
  // morphed wrapper afterward (see post-morph loop below). The stub carries
@@ -3365,7 +3464,7 @@ export const createAgentExperience = (
3365
3464
  }
3366
3465
  // Approval plugins are handled via the stub-and-hydrate path below
3367
3466
  // (see `approvalWithPlugin`), not this inline morph path, so their
3368
- // listeners survive so they are intentionally excluded here.
3467
+ // listeners survive, so they are intentionally excluded here.
3369
3468
  if (!message.variant && p.renderMessage) {
3370
3469
  return true;
3371
3470
  }
@@ -3376,9 +3475,9 @@ export const createAgentExperience = (
3376
3475
  const messageLayoutConfig = config.layout?.messages;
3377
3476
 
3378
3477
  // ask_user_question has two rendering modes while waiting for an answer:
3379
- // 1. Plugin `renderAskUserQuestion` returns an inline transcript
3478
+ // 1. Plugin `renderAskUserQuestion`: returns an inline transcript
3380
3479
  // element with its own UI; the composer-overlay sheet is suppressed.
3381
- // 2. Built-in composer-overlay answer-pill sheet no transcript stub.
3480
+ // 2. Built-in composer-overlay answer-pill sheet: no transcript stub.
3382
3481
  // Plugins win when they return a non-null element; otherwise fall
3383
3482
  // through to the built-in overlay.
3384
3483
  //
@@ -3400,7 +3499,7 @@ export const createAgentExperience = (
3400
3499
  return;
3401
3500
  }
3402
3501
 
3403
- // suggest_replies renders no transcript bubble the chips above the
3502
+ // suggest_replies renders no transcript bubble: the chips above the
3404
3503
  // composer are the only UI, and the session auto-resumes the call.
3405
3504
  // When the feature is disabled the message falls through to the generic
3406
3505
  // tool bubble (and is never auto-resumed), keeping the parked execution
@@ -3507,7 +3606,7 @@ export const createAgentExperience = (
3507
3606
  if (needsRebuild && approvalPlugin?.renderApproval) {
3508
3607
  // Re-find the live message at decision time so we resolve against
3509
3608
  // current state, and route WebMCP gate approvals to the local
3510
- // resolver mirroring the built-in delegated handler.
3609
+ // resolver: mirroring the built-in delegated handler.
3511
3610
  const approvalMessageId = message.id;
3512
3611
  const resolveDecision = (
3513
3612
  decision: "approved" | "denied",
@@ -3535,8 +3634,8 @@ export const createAgentExperience = (
3535
3634
  if (needsRebuild && liveBubble === null) {
3536
3635
  // Plugin opted out for this state (e.g. a resolved approval, where the
3537
3636
  // demo plugin defers to the built-in approved/denied bubble). Render
3538
- // the built-in bubble it resolves via the delegated `messagesWrapper`
3539
- // handler and morphs normally and drop any preserved live wrapper so
3637
+ // the built-in bubble: it resolves via the delegated `messagesWrapper`
3638
+ // handler and morphs normally, and drop any preserved live wrapper so
3540
3639
  // morph can replace the now-stale pending bubble.
3541
3640
  const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
3542
3641
  existing?.removeAttribute("data-preserve-runtime");
@@ -3866,7 +3965,7 @@ export const createAgentExperience = (
3866
3965
 
3867
3966
  // Also check if there's a recently completed assistant message (streaming just ended)
3868
3967
  // This prevents flicker when the message completes but isStreaming hasn't updated yet
3869
- // Approval-variant messages are UI controls, not content exclude them so the typing
3968
+ // Approval-variant messages are UI controls, not content: exclude them so the typing
3870
3969
  // indicator still shows while the agent resumes after approval
3871
3970
  const lastMessage = messages[messages.length - 1];
3872
3971
  const hasRecentAssistantResponse = lastMessage?.role === "assistant" && !lastMessage.streaming && lastMessage.variant !== "approval";
@@ -4006,14 +4105,14 @@ export const createAgentExperience = (
4006
4105
 
4007
4106
  // Hydrate plugin-rendered ask-question bubbles into their stub wrappers.
4008
4107
  // Idiomorph imports new nodes via `document.importNode`, which strips
4009
- // listeners so we built only an empty stub during morph and now inject
4108
+ // listeners, so we built only an empty stub during morph and now inject
4010
4109
  // the real, listener-bearing bubble directly into the live DOM.
4011
4110
  if (askPluginHydrate.length > 0) {
4012
4111
  for (const { messageId, fingerprint, bubble } of askPluginHydrate) {
4013
4112
  const wrapper = container.querySelector(`#wrapper-${messageId}`);
4014
4113
  if (!wrapper) continue;
4015
4114
  if (bubble === null) {
4016
- // No fresh bubble built this pass either the plugin opted out
4115
+ // No fresh bubble built this pass: either the plugin opted out
4017
4116
  // and a previously-mounted bubble already lives here (preserved by
4018
4117
  // `data-preserve-runtime`), or we skipped the rebuild because the
4019
4118
  // fingerprint matched. Either way, leave the live wrapper alone.
@@ -4040,7 +4139,7 @@ export const createAgentExperience = (
4040
4139
  const wrapper = container.querySelector(`#wrapper-${messageId}`);
4041
4140
  if (!wrapper) continue;
4042
4141
  if (bubble === null) {
4043
- // Fingerprint matched the previous pass the live wrapper (kept
4142
+ // Fingerprint matched the previous pass: the live wrapper (kept
4044
4143
  // alive by `data-preserve-runtime`) still holds the listener-bearing
4045
4144
  // bubble from a prior render. Leave it untouched.
4046
4145
  continue;
@@ -4065,7 +4164,7 @@ export const createAgentExperience = (
4065
4164
  if (!wrapper) continue;
4066
4165
  if (bubble === null) {
4067
4166
  // Fingerprint matched the previous pass (or the plugin opted out
4068
- // after a prior render) the live wrapper, kept alive by
4167
+ // after a prior render): the live wrapper, kept alive by
4069
4168
  // `data-preserve-runtime`, still holds the listener-bearing bubble.
4070
4169
  continue;
4071
4170
  }
@@ -4082,8 +4181,17 @@ export const createAgentExperience = (
4082
4181
  }
4083
4182
  };
4084
4183
 
4085
- // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
4086
- const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
4184
+ // Alias for clarity - the implementation handles flicker prevention via typing indicator logic.
4185
+ // Re-apply read-aloud button state after each render so a playing/paused
4186
+ // message keeps its icon across idiomorph DOM morphs.
4187
+ const renderMessagesWithPlugins = (
4188
+ container: HTMLElement,
4189
+ messages: AgentWidgetMessage[],
4190
+ transform: MessageTransform
4191
+ ) => {
4192
+ renderMessagesWithPluginsImpl(container, messages, transform);
4193
+ refreshReadAloudButtons();
4194
+ };
4087
4195
 
4088
4196
  /**
4089
4197
  * Composer-bar outside-click dismiss. While the chat is expanded, clicking
@@ -4127,7 +4235,7 @@ export const createAgentExperience = (
4127
4235
 
4128
4236
  /**
4129
4237
  * Composer-bar ESC dismiss. While the chat is expanded, pressing Escape
4130
- * collapses back to just the pill same end state as outside-click.
4238
+ * collapses back to just the pill: same end state as outside-click.
4131
4239
  * Matches the WAI-ARIA dialog pattern (modal mode is literally a dialog)
4132
4240
  * and the dominant chat-widget convention (Intercom, Drift, Crisp).
4133
4241
  * Guards on `event.isComposing` so dismissing an IME suggestion doesn't
@@ -4161,7 +4269,7 @@ export const createAgentExperience = (
4161
4269
  destroyCallbacks.push(() => detachComposerBarEscapeDismiss());
4162
4270
 
4163
4271
  /**
4164
- * Composer-bar "peek" affordance a chrome-less row above the pill that
4272
+ * Composer-bar "peek" affordance: a chrome-less row above the pill that
4165
4273
  * shows a chat-bubble icon, the trailing 100 chars of the most recent
4166
4274
  * assistant message, and a chevron-up. It is the user's path back into the
4167
4275
  * expanded chat from the collapsed pill.
@@ -4272,7 +4380,7 @@ export const createAgentExperience = (
4272
4380
  }
4273
4381
 
4274
4382
  // Apply buffering (word/line/plugin custom). If the buffer trims content
4275
- // to empty AND the placeholder is "skeleton", show the skeleton that's
4383
+ // to empty AND the placeholder is "skeleton", show the skeleton: that's
4276
4384
  // the "line buffer between completions" affordance. Otherwise no
4277
4385
  // pre-content placeholder on the peek (a typing-dots indicator inside a
4278
4386
  // 1-line ticker would feel cramped).
@@ -4297,7 +4405,7 @@ export const createAgentExperience = (
4297
4405
  } else {
4298
4406
  // Trailing 100 chars; for animated modes we keep the slice but use
4299
4407
  // ABSOLUTE indices so per-char/per-word span IDs stay stable as the
4300
- // window shifts each chunk idiomorph then preserves animations on
4408
+ // window shifts each chunk: idiomorph then preserves animations on
4301
4409
  // already-revealed units instead of restarting them. Plain "none" mode
4302
4410
  // keeps the legacy `…` ellipsis prefix for visual continuity with the
4303
4411
  // pre-animation behavior.
@@ -4344,7 +4452,7 @@ export const createAgentExperience = (
4344
4452
  // Fire the plugin's per-render hook so glyph-cycle / wipe / custom
4345
4453
  // plugins get a chance to mutate the peek's spans the same way they
4346
4454
  // mutate the main bubble's. The carve-out: `bubble` here is the peek
4347
- // banner root, not a message bubble plugins that target
4455
+ // banner root, not a message bubble: plugins that target
4348
4456
  // `bubbleClass` should no-op on that surface.
4349
4457
  plugin.onAfterRender?.({
4350
4458
  container: peekTextNode,
@@ -4421,7 +4529,7 @@ export const createAgentExperience = (
4421
4529
  *
4422
4530
  * Width is expressed as `width: <configured>; max-width: calc(100vw -
4423
4531
  * 32px)`. The two combine such that `width` wins on wide viewports and
4424
- * `max-width` clamps on narrow ones same effect as `min(...)` but
4532
+ * `max-width` clamps on narrow ones: same effect as `min(...)` but
4425
4533
  * jsdom-compatible. `100vw` is always the viewport, so the containing-
4426
4534
  * block edge case (host with `transform`/`filter` causing `100%` to
4427
4535
  * resolve against the host instead of the viewport) is neutralized.
@@ -4430,7 +4538,7 @@ export const createAgentExperience = (
4430
4538
  const cb = config.launcher?.composerBar ?? {};
4431
4539
  const expandedSize = cb.expandedSize ?? "anchored";
4432
4540
  const bottomOffset = cb.bottomOffset ?? "16px";
4433
- // No hardcoded default when undefined, CSS media queries provide the
4541
+ // No hardcoded default: when undefined, CSS media queries provide the
4434
4542
  // responsive width (90vw / 70vw / 50vw at <640 / <1024 / >=1024) on
4435
4543
  // pillRoot.
4436
4544
  const collapsedMaxWidth = cb.collapsedMaxWidth;
@@ -4470,7 +4578,7 @@ export const createAgentExperience = (
4470
4578
  }
4471
4579
 
4472
4580
  if (!isOpen) {
4473
- // Collapsed: wrapper has nothing visible to render the container
4581
+ // Collapsed: wrapper has nothing visible to render: the container
4474
4582
  // inside is `display: none` (via CSS keyed on `[data-state="collapsed"]`)
4475
4583
  // and the pill lives in pillRoot. Leave wrapper geometry empty so it
4476
4584
  // collapses to a zero-size positioning frame at the default fixed
@@ -4497,7 +4605,7 @@ export const createAgentExperience = (
4497
4605
  return;
4498
4606
  }
4499
4607
 
4500
- // Default: anchored pill stays at the viewport bottom (in pillRoot);
4608
+ // Default: anchored: pill stays at the viewport bottom (in pillRoot);
4501
4609
  // wrapper's bottom edge clears the pill area so the chrome doesn't
4502
4610
  // overlap it.
4503
4611
  s.left = "50%";
@@ -4541,7 +4649,7 @@ export const createAgentExperience = (
4541
4649
 
4542
4650
  // Toggle the entire container (chat chrome + body + close button) so
4543
4651
  // the collapsed pill only shows the footer (which lives as a SIBLING
4544
- // of the container in the panel see panel.appendChild(footer) above).
4652
+ // of the container in the panel: see panel.appendChild(footer) above).
4545
4653
  // The footer is always visible / interactive.
4546
4654
  container.style.display = open ? "flex" : "none";
4547
4655
 
@@ -4609,7 +4717,7 @@ export const createAgentExperience = (
4609
4717
  panel.classList.remove("persona-scale-100", "persona-opacity-100");
4610
4718
  panel.classList.add("persona-scale-95", "persona-opacity-0");
4611
4719
  }
4612
- // Show launcher when closed, except docked mode (0px column use controller.open()).
4720
+ // Show launcher when closed, except docked mode (0px column: use controller.open()).
4613
4721
  if (launcherButtonInstance) {
4614
4722
  launcherButtonInstance.element.style.display = dockedMode ? "none" : "";
4615
4723
  } else if (customLauncherElement) {
@@ -4634,7 +4742,7 @@ export const createAgentExperience = (
4634
4742
  const mb = config.launcher?.mobileBreakpoint ?? 640;
4635
4743
  const isMobile = ow.innerWidth <= mb;
4636
4744
  const dockedMF = isDockedMountMode(config) && mf && isMobile;
4637
- // Composer-bar in expanded fullscreen mode covers the viewport lock
4745
+ // Composer-bar in expanded fullscreen mode covers the viewport: lock
4638
4746
  // background scroll and elevate host stacking to match other
4639
4747
  // viewport-covering modes (mobile fullscreen, sidebar).
4640
4748
  const composerBarFS =
@@ -4700,7 +4808,7 @@ export const createAgentExperience = (
4700
4808
  };
4701
4809
 
4702
4810
  const setComposerDisabled = (disabled: boolean) => {
4703
- // The send button stays enabled while streaming it doubles as a stop
4811
+ // The send button stays enabled while streaming: it doubles as a stop
4704
4812
  // button. Ancillary controls (mic, suggestions, opt-in targets) still
4705
4813
  // disable so the user can't race a send against an in-flight stream.
4706
4814
  setSendButtonMode(disabled ? "stop" : "send");
@@ -4811,7 +4919,7 @@ export const createAgentExperience = (
4811
4919
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
4812
4920
  // Start elapsed timer if any active tool has a live duration span
4813
4921
  ensureToolElapsedTimer();
4814
- // Re-render suggestions agent chips vs config chips, one shared rule.
4922
+ // Re-render suggestions: agent chips vs config chips, one shared rule.
4815
4923
  // Pass messages directly to avoid calling session.getMessages() during construction
4816
4924
  renderSuggestions(messages);
4817
4925
  scheduleAutoScroll(!isStreaming);
@@ -4875,11 +4983,21 @@ export const createAgentExperience = (
4875
4983
  syncComposerBarPeek();
4876
4984
  },
4877
4985
  onVoiceStatusChanged(status: VoiceStatus) {
4986
+ // Surface the granular status publicly so consumers can render their own
4987
+ // per-state UI (e.g. a listening/speaking status dock). Fires for every
4988
+ // provider; the mic-button styling below is runtype-specific.
4989
+ eventBus.emit("voice:status", { status, timestamp: Date.now() });
4878
4990
  if (config.voiceRecognition?.provider?.type !== 'runtype') return;
4879
4991
 
4880
4992
  switch (status) {
4881
4993
  case 'listening':
4882
- // Recording styles are applied by toggleVoice() / startVoiceRecognition() flows
4994
+ // A continuous realtime call re-enters `listening` after every spoken
4995
+ // reply, so reassert the recording styles here (they were replaced by
4996
+ // the `processing`/`speaking` states during the turn). The initial
4997
+ // listen is also styled by the toggleVoice()/startVoiceRecognition()
4998
+ // flows; reapplying is idempotent.
4999
+ removeRuntypeMicStateStyles();
5000
+ applyRuntypeMicRecordingStyles();
4883
5001
  break;
4884
5002
  case 'processing':
4885
5003
  removeRuntypeMicStateStyles();
@@ -4892,7 +5010,7 @@ export const createAgentExperience = (
4892
5010
  default:
4893
5011
  // idle, connected, disconnected, error
4894
5012
  if (status === 'idle' && session.isBargeInActive()) {
4895
- // Barge-in mic is still hot between turns show it as active
5013
+ // Barge-in mic is still hot between turns: show it as active
4896
5014
  removeRuntypeMicStateStyles();
4897
5015
  applyRuntypeMicRecordingStyles();
4898
5016
  micButton?.setAttribute("aria-label", "End voice session");
@@ -4914,9 +5032,32 @@ export const createAgentExperience = (
4914
5032
 
4915
5033
  sessionRef.current = session;
4916
5034
 
5035
+ // Mirror read-aloud playback state into the action buttons, and surface it as
5036
+ // a controller event (parallel to message:copy / message:feedback).
5037
+ let lastReadAloudId: string | null = null;
5038
+ session.onReadAloudChange((activeId, state) => {
5039
+ readAloudActiveId = activeId;
5040
+ readAloudActiveState = state;
5041
+ refreshReadAloudButtons();
5042
+
5043
+ // On the terminal `idle` transition activeId is null, so fall back to the
5044
+ // last active id to identify the message that just finished/stopped.
5045
+ const messageId = activeId ?? lastReadAloudId;
5046
+ if (activeId) lastReadAloudId = activeId;
5047
+ const message = messageId
5048
+ ? session.getMessages().find((m) => m.id === messageId) ?? null
5049
+ : null;
5050
+ eventBus.emit("message:read-aloud", {
5051
+ messageId,
5052
+ message,
5053
+ state,
5054
+ timestamp: Date.now(),
5055
+ });
5056
+ if (state === "idle") lastReadAloudId = null;
5057
+ });
5058
+
4917
5059
  // The constructor only emits onMessagesChanged when it has initial
4918
- // messages, so seed send-detection explicitly for the empty-session case
4919
- // otherwise the user's very first send would be mistaken for the seed.
5060
+ // messages, so seed send-detection explicitly for the empty-session case: // otherwise the user's very first send would be mistaken for the seed.
4920
5061
  scrollSendSeeded = true;
4921
5062
 
4922
5063
  // Setup Runtype voice provider when configured (connects WebSocket for server-side STT)
@@ -5052,7 +5193,7 @@ export const createAgentExperience = (
5052
5193
  // --- Composer message-history navigation (Up/Down arrows) ---
5053
5194
  // Lets users recall and edit previously sent messages, shell/Slack style.
5054
5195
  // The pure state machine lives in utils/composer-history.ts; here we feed it
5055
- // caret info and apply the value it returns. Text-only recall attachments
5196
+ // caret info and apply the value it returns. Text-only recall: attachments
5056
5197
  // on past messages are not restored.
5057
5198
  const historyNavigationEnabled = () =>
5058
5199
  config.features?.composerHistory !== false;
@@ -5121,11 +5262,11 @@ export const createAgentExperience = (
5121
5262
  }
5122
5263
  return;
5123
5264
  }
5124
- // Not handled fall through to default cursor movement.
5265
+ // Not handled: fall through to default cursor movement.
5125
5266
  }
5126
5267
 
5127
5268
  // Enter: send, unless a response is streaming. While streaming, Enter is
5128
- // inert (never a stop trigger) the visible Stop button / Esc stop it.
5269
+ // inert (never a stop trigger): the visible Stop button / Esc stop it.
5129
5270
  if (event.key === "Enter" && !event.shiftKey) {
5130
5271
  if (session.isStreaming()) {
5131
5272
  event.preventDefault();
@@ -5147,7 +5288,7 @@ export const createAgentExperience = (
5147
5288
  if (!session.isStreaming()) return;
5148
5289
  if (!event.composedPath().includes(container)) return;
5149
5290
  session.cancel();
5150
- // Cancelling emits no terminal/error SSE frame reset throughput so the
5291
+ // Cancelling emits no terminal/error SSE frame: reset throughput so the
5151
5292
  // Events row doesn't keep showing a live rate from the stopped stream.
5152
5293
  throughputTracker?.reset();
5153
5294
  eventStreamView?.update();
@@ -5364,7 +5505,10 @@ export const createAgentExperience = (
5364
5505
  (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
5365
5506
  typeof (window as any).SpeechRecognition !== 'undefined');
5366
5507
  const hasRuntypeProvider = voiceConfig?.provider?.type === 'runtype';
5367
- const hasVoiceInput = hasSpeechRecognition || hasRuntypeProvider;
5508
+ // Bring-your-own (`custom`) providers own their own input pipeline (cloud
5509
+ // STT, etc.), so the mic should render regardless of Web Speech support.
5510
+ const hasCustomProvider = voiceConfig?.provider?.type === 'custom';
5511
+ const hasVoiceInput = hasSpeechRecognition || hasRuntypeProvider || hasCustomProvider;
5368
5512
 
5369
5513
  if (!hasVoiceInput) return null;
5370
5514
 
@@ -5709,31 +5853,17 @@ export const createAgentExperience = (
5709
5853
  };
5710
5854
 
5711
5855
  // Plugin hook: renderLauncher - allow plugins to provide custom launcher
5712
- let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
5856
+ let launcherButtonInstance: LauncherButton | null = null;
5713
5857
  let customLauncherElement: HTMLElement | null = null;
5714
5858
 
5715
5859
  // Composer-bar mode is launcher-less by design: the persistent pill IS the
5716
5860
  // entry point, so skip creating any launcher button (default or plugin).
5717
5861
  if (launcherEnabled && !isComposerBar()) {
5718
- const launcherPlugin = plugins.find(p => p.renderLauncher);
5719
- if (launcherPlugin?.renderLauncher) {
5720
- const customLauncher = launcherPlugin.renderLauncher({
5721
- config,
5722
- defaultRenderer: () => {
5723
- const btn = createLauncherButton(config, toggleOpen);
5724
- return btn.element;
5725
- },
5726
- onToggle: toggleOpen
5727
- });
5728
- if (customLauncher) {
5729
- customLauncherElement = customLauncher;
5730
- }
5731
- }
5732
-
5733
- // Use custom launcher if provided, otherwise use default
5734
- if (!customLauncherElement) {
5735
- launcherButtonInstance = createLauncherButton(config, toggleOpen);
5736
- }
5862
+ const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
5863
+ launcherButtonInstance = instance;
5864
+ // A plugin-provided launcher returns no controller instance; track its
5865
+ // element separately so the update path can manage it.
5866
+ if (!instance) customLauncherElement = element;
5737
5867
  }
5738
5868
 
5739
5869
  if (launcherButtonInstance) {
@@ -5754,7 +5884,7 @@ export const createAgentExperience = (
5754
5884
 
5755
5885
  if (autoFocusInput) {
5756
5886
  // Composer-bar's pill exposes the textarea immediately, so focus it on
5757
- // init like the inline embed does even though the panel is collapsed.
5887
+ // init like the inline embed does: even though the panel is collapsed.
5758
5888
  if (!launcherEnabled || isComposerBar()) {
5759
5889
  setTimeout(() => maybeFocusInput(), 0);
5760
5890
  } else if (open) {
@@ -5763,7 +5893,7 @@ export const createAgentExperience = (
5763
5893
  }
5764
5894
 
5765
5895
  const recalcPanelHeight = () => {
5766
- // Composer-bar mode lets CSS own all sizing collapsed pill is auto-sized
5896
+ // Composer-bar mode lets CSS own all sizing: collapsed pill is auto-sized
5767
5897
  // by the footer; expanded fullscreen/modal are driven by CSS attribute
5768
5898
  // selectors plus inline maxWidth/maxHeight set in updateOpenState. JS
5769
5899
  // sizing here would fight the morph transitions.
@@ -5791,7 +5921,7 @@ export const createAgentExperience = (
5791
5921
  return;
5792
5922
  }
5793
5923
 
5794
- // Exiting mobile fullscreen (e.g., orientation change to landscape) reset all styles
5924
+ // Exiting mobile fullscreen (e.g., orientation change to landscape): reset all styles
5795
5925
  if (wasMobileFullscreen) {
5796
5926
  wasMobileFullscreen = false;
5797
5927
  applyFullHeightStyles();
@@ -5905,7 +6035,7 @@ export const createAgentExperience = (
5905
6035
  lastBottomOffset = currentBottomOffset;
5906
6036
 
5907
6037
  if (getScrollMode() !== "follow") {
5908
- // No follow state to manage just keep the scroll-to-bottom
6038
+ // No follow state to manage: just keep the scroll-to-bottom
5909
6039
  // affordance in sync with the user's position.
5910
6040
  lastScrollTop = scrollTop;
5911
6041
  syncScrollToBottomButton();
@@ -6039,7 +6169,7 @@ export const createAgentExperience = (
6039
6169
  messageCache.clear();
6040
6170
  resumeAutoScroll();
6041
6171
 
6042
- // Drop any open ask_user_question sheets their source messages are gone.
6172
+ // Drop any open ask_user_question sheets: their source messages are gone.
6043
6173
  removeAskUserQuestionSheet(panelElements.composerOverlay);
6044
6174
 
6045
6175
  // Always clear the default localStorage key
@@ -6168,7 +6298,7 @@ export const createAgentExperience = (
6168
6298
  // may expose `dataTransfer.types` as a DOMStringList or restrict access
6169
6299
  // during certain drag phases. The cost is minimal: we suppress the native
6170
6300
  // "open file" default for ALL drag-overs while the widget is alive and
6171
- // attachments are on text drags into the textarea still work because
6301
+ // attachments are on: text drags into the textarea still work because
6172
6302
  // element-level handlers are unaffected (we don't stopPropagation here).
6173
6303
  const ownerDoc = mount.ownerDocument;
6174
6304
  const handleDocDragOver = (e: DragEvent) => {
@@ -6338,26 +6468,11 @@ export const createAgentExperience = (
6338
6468
  }
6339
6469
 
6340
6470
  if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
6341
- // Check for launcher plugin when re-enabling
6342
- const launcherPlugin = plugins.find(p => p.renderLauncher);
6343
- if (launcherPlugin?.renderLauncher) {
6344
- const customLauncher = launcherPlugin.renderLauncher({
6345
- config,
6346
- defaultRenderer: () => {
6347
- const btn = createLauncherButton(config, toggleOpen);
6348
- return btn.element;
6349
- },
6350
- onToggle: toggleOpen
6351
- });
6352
- if (customLauncher) {
6353
- customLauncherElement = customLauncher;
6354
- mount.appendChild(customLauncherElement);
6355
- }
6356
- }
6357
- if (!customLauncherElement) {
6358
- launcherButtonInstance = createLauncherButton(config, toggleOpen);
6359
- mount.appendChild(launcherButtonInstance.element);
6360
- }
6471
+ // Resolve the launcher again when re-enabling (honors renderLauncher plugin).
6472
+ const { instance, element } = resolveLauncher({ config, plugins, onToggle: toggleOpen });
6473
+ launcherButtonInstance = instance;
6474
+ if (!instance) customLauncherElement = element;
6475
+ mount.appendChild(element);
6361
6476
  }
6362
6477
 
6363
6478
  if (launcherButtonInstance) {
@@ -6390,15 +6505,15 @@ export const createAgentExperience = (
6390
6505
  onClose: () => setOpenState(false, "user")
6391
6506
  });
6392
6507
 
6393
- // Replace the old header with the new one
6394
- header.replaceWith(newHeaderElements.header);
6508
+ // Replace the old header with the new one (keeps view.header in sync).
6509
+ view.replaceHeader(newHeaderElements);
6395
6510
 
6396
- // Update references
6397
- header = newHeaderElements.header;
6398
- iconHolder = newHeaderElements.iconHolder;
6399
- headerTitle = newHeaderElements.headerTitle;
6400
- headerSubtitle = newHeaderElements.headerSubtitle;
6401
- closeButton = newHeaderElements.closeButton;
6511
+ // Mirror the view's refreshed header refs into the local bindings.
6512
+ header = view.header.element;
6513
+ iconHolder = view.header.iconHolder;
6514
+ headerTitle = view.header.headerTitle;
6515
+ headerSubtitle = view.header.headerSubtitle;
6516
+ closeButton = view.header.closeButton;
6402
6517
 
6403
6518
  prevHeaderLayout = headerLayoutConfig?.layout;
6404
6519
  } else if (headerLayoutConfig) {
@@ -6532,9 +6647,13 @@ export const createAgentExperience = (
6532
6647
 
6533
6648
  // Update icon content based on priority: Lucide icon > iconUrl > agentIconText
6534
6649
  if (headerIconName) {
6535
- // Use Lucide icon
6650
+ // Use Lucide icon. Stroke `currentColor` (not a hardcoded white) so the
6651
+ // glyph inherits iconHolder's `color: var(--persona-header-icon-fg, …)`,
6652
+ // matching the initial render in header-builder.ts. Without this, any
6653
+ // controller.update() (e.g. a theme-editor change) re-rendered the icon
6654
+ // as white and the configured header icon color "wouldn't stick".
6536
6655
  const iconSize = parseFloat(headerIconSize) || 24;
6537
- const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
6656
+ const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "currentColor", 1);
6538
6657
  if (iconSvg) {
6539
6658
  iconHolder.replaceChildren(iconSvg);
6540
6659
  } else {
@@ -6851,7 +6970,7 @@ export const createAgentExperience = (
6851
6970
  }
6852
6971
 
6853
6972
  if (shouldShowClearChat) {
6854
- // Update size composer-bar mode owns its sizing (16px to match
6973
+ // Update size: composer-bar mode owns its sizing (16px to match
6855
6974
  // the close icon), so leave size alone there. Floating-launcher
6856
6975
  // and other modes still honor `launcher.clearChat.size`.
6857
6976
  if (!isComposerBar()) {
@@ -7830,6 +7949,28 @@ export const createAgentExperience = (
7830
7949
  isVoiceActive(): boolean {
7831
7950
  return voiceState.active;
7832
7951
  },
7952
+ /**
7953
+ * Toggle "Read aloud" for an assistant message: play → pause → resume (or
7954
+ * play → stop when the engine can't pause). Speaks via the configured
7955
+ * speech engine (browser Web Speech API by default).
7956
+ */
7957
+ toggleReadAloud(messageId: string): void {
7958
+ session.toggleReadAloud(messageId);
7959
+ },
7960
+ /** Stop any in-progress read-aloud / text-to-speech playback. */
7961
+ stopReadAloud(): void {
7962
+ session.stopSpeaking();
7963
+ },
7964
+ /** Current read-aloud playback state for a message (`idle` unless active). */
7965
+ getReadAloudState(messageId: string): ReadAloudState {
7966
+ return session.getReadAloudState(messageId);
7967
+ },
7968
+ /** Subscribe to read-aloud state changes. Returns an unsubscribe function. */
7969
+ onReadAloudChange(
7970
+ listener: (activeId: string | null, state: ReadAloudState) => void
7971
+ ): () => void {
7972
+ return session.onReadAloudChange(listener);
7973
+ },
7833
7974
  getState(): AgentWidgetStateSnapshot {
7834
7975
  return {
7835
7976
  open: isPanelToggleable() && open,
@@ -8118,6 +8259,26 @@ export const createAgentExperience = (
8118
8259
  // the panel.
8119
8260
  syncComposerBarPeek();
8120
8261
 
8262
+ // IIFE/CDN lazy path only: the parsers were not ready at mount, so any
8263
+ // messages rendered so far (restored history, eager intro/injected messages)
8264
+ // were escaped to plain text. Once the `markdown-parsers.js` chunk resolves,
8265
+ // bust the message cache and re-render so they pick up real markdown. Bumping
8266
+ // `configVersion` + clearing the cache is required because the message
8267
+ // content is unchanged, so the fingerprint cache would otherwise reuse the
8268
+ // stale escaped wrappers. No-op for the ESM build (parsers ready at init).
8269
+ if (!markdownReadyAtInit) {
8270
+ loadMarkdownParsers()
8271
+ .then(() => {
8272
+ if (!session) return;
8273
+ configVersion++;
8274
+ messageCache.clear();
8275
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
8276
+ })
8277
+ .catch(() => {
8278
+ /* chunk failed to load (e.g. ad blocker): keep the escaped fallback */
8279
+ });
8280
+ }
8281
+
8121
8282
  return controller;
8122
8283
  };
8123
8284