@runtypelabs/persona 1.47.0 → 2.0.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 (73) hide show
  1. package/README.md +140 -8
  2. package/dist/index.cjs +90 -39
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1093 -25
  5. package/dist/index.d.ts +1093 -25
  6. package/dist/index.global.js +111 -60
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +90 -39
  9. package/dist/index.js.map +1 -1
  10. package/dist/install.global.js +1 -1
  11. package/dist/install.global.js.map +1 -1
  12. package/dist/widget.css +852 -505
  13. package/package.json +1 -1
  14. package/src/artifacts-session.test.ts +80 -0
  15. package/src/client.test.ts +20 -21
  16. package/src/client.ts +153 -4
  17. package/src/components/approval-bubble.ts +45 -42
  18. package/src/components/artifact-card.ts +91 -0
  19. package/src/components/artifact-pane.ts +501 -0
  20. package/src/components/composer-builder.ts +32 -27
  21. package/src/components/event-stream-view.ts +40 -40
  22. package/src/components/feedback.ts +36 -36
  23. package/src/components/forms.ts +11 -11
  24. package/src/components/header-builder.test.ts +32 -0
  25. package/src/components/header-builder.ts +55 -36
  26. package/src/components/header-layouts.ts +58 -125
  27. package/src/components/launcher.ts +36 -21
  28. package/src/components/message-bubble.ts +92 -65
  29. package/src/components/messages.ts +2 -2
  30. package/src/components/panel.ts +42 -11
  31. package/src/components/reasoning-bubble.ts +23 -23
  32. package/src/components/registry.ts +4 -0
  33. package/src/components/suggestions.ts +1 -1
  34. package/src/components/tool-bubble.ts +32 -32
  35. package/src/defaults.ts +30 -4
  36. package/src/index.ts +80 -2
  37. package/src/install.ts +22 -0
  38. package/src/plugins/types.ts +23 -0
  39. package/src/postprocessors.ts +2 -2
  40. package/src/runtime/host-layout.ts +174 -0
  41. package/src/runtime/init.test.ts +236 -0
  42. package/src/runtime/init.ts +114 -55
  43. package/src/session.ts +173 -7
  44. package/src/styles/tailwind.css +1 -1
  45. package/src/styles/widget.css +852 -505
  46. package/src/types/theme.ts +354 -0
  47. package/src/types.ts +348 -16
  48. package/src/ui.docked.test.ts +104 -0
  49. package/src/ui.ts +1093 -244
  50. package/src/utils/artifact-gate.test.ts +255 -0
  51. package/src/utils/artifact-gate.ts +142 -0
  52. package/src/utils/artifact-resize.test.ts +64 -0
  53. package/src/utils/artifact-resize.ts +67 -0
  54. package/src/utils/attachment-manager.ts +10 -10
  55. package/src/utils/code-generators.test.ts +52 -0
  56. package/src/utils/code-generators.ts +40 -36
  57. package/src/utils/dock.ts +17 -0
  58. package/src/utils/dom-context.test.ts +504 -0
  59. package/src/utils/dom-context.ts +896 -0
  60. package/src/utils/dom.ts +12 -1
  61. package/src/utils/message-fingerprint.test.ts +187 -0
  62. package/src/utils/message-fingerprint.ts +105 -0
  63. package/src/utils/migration.ts +179 -0
  64. package/src/utils/morph.ts +1 -1
  65. package/src/utils/plugins.ts +175 -0
  66. package/src/utils/positioning.ts +4 -4
  67. package/src/utils/theme.test.ts +125 -0
  68. package/src/utils/theme.ts +216 -60
  69. package/src/utils/tokens.ts +682 -0
  70. package/src/voice/audio-playback-manager.ts +187 -0
  71. package/src/voice/runtype-voice-provider.ts +305 -69
  72. package/src/voice/voice-activity-detector.ts +90 -0
  73. package/src/voice/voice.test.ts +6 -5
package/src/ui.ts CHANGED
@@ -20,15 +20,19 @@ import {
20
20
  InjectSystemMessageOptions,
21
21
  LoadingIndicatorRenderContext,
22
22
  IdleIndicatorRenderContext,
23
- VoiceStatus
23
+ VoiceStatus,
24
+ PersonaArtifactRecord,
25
+ PersonaArtifactManualUpsert
24
26
  } from "./types";
25
27
  import { AttachmentManager } from "./utils/attachment-manager";
26
28
  import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
27
29
  import { applyThemeVariables, createThemeObserver } from "./utils/theme";
28
30
  import { renderLucideIcon } from "./utils/icons";
29
- import { createElement } from "./utils/dom";
31
+ import { createElement, createElementInDocument } from "./utils/dom";
30
32
  import { morphMessages } from "./utils/morph";
33
+ import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
31
34
  import { statusCopy } from "./utils/constants";
35
+ import { isDockedMountMode } from "./utils/dock";
32
36
  import { createLauncherButton } from "./components/launcher";
33
37
  import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
34
38
  import { buildHeaderWithLayout } from "./components/header-layouts";
@@ -43,6 +47,14 @@ import { createSuggestions } from "./components/suggestions";
43
47
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
44
48
  import { EventStreamStore } from "./utils/event-stream-store";
45
49
  import { createEventStreamView } from "./components/event-stream-view";
50
+ import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
51
+ import {
52
+ artifactsSidebarEnabled,
53
+ applyArtifactLayoutCssVars,
54
+ applyArtifactPaneAppearance,
55
+ shouldExpandLauncherForArtifacts
56
+ } from "./utils/artifact-gate";
57
+ import { readFlexGapPx, resolveArtifactPaneWidthPx } from "./utils/artifact-resize";
46
58
  import { enhanceWithForms } from "./components/forms";
47
59
  import { pluginRegistry } from "./plugins/registry";
48
60
  import { mergeWithDefaults } from "./defaults";
@@ -277,6 +289,14 @@ type Controller = {
277
289
  hideEventStream: () => void;
278
290
  /** Returns current visibility state of the event stream panel */
279
291
  isEventStreamVisible: () => boolean;
292
+ /** Show artifact sidebar (no-op if features.artifacts.enabled is false) */
293
+ showArtifacts: () => void;
294
+ /** Hide artifact sidebar */
295
+ hideArtifacts: () => void;
296
+ /** Upsert an artifact programmatically */
297
+ upsertArtifact: (manual: PersonaArtifactManualUpsert) => PersonaArtifactRecord | null;
298
+ selectArtifact: (id: string) => void;
299
+ clearArtifacts: () => void;
280
300
  /**
281
301
  * Focus the chat input. Returns true if focus succeeded, false if panel is closed
282
302
  * (launcher mode) or textarea is unavailable.
@@ -467,6 +487,7 @@ export const createAgentExperience = (
467
487
  let prevAutoExpand = autoExpand;
468
488
  let prevLauncherEnabled = launcherEnabled;
469
489
  let prevHeaderLayout = config.layout?.header?.layout;
490
+ let wasMobileFullscreen = false;
470
491
  let open = launcherEnabled ? autoExpand : true;
471
492
 
472
493
  // Track pending resubmit state for injection-triggered resubmit
@@ -591,20 +612,11 @@ export const createAgentExperience = (
591
612
  let attachmentInput: HTMLInputElement | null = panelElements.attachmentInput;
592
613
  let attachmentPreviewsContainer: HTMLElement | null = panelElements.attachmentPreviewsContainer;
593
614
 
594
- // Initialize attachment manager if attachments are enabled
615
+ // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
595
616
  let attachmentManager: AttachmentManager | null = null;
596
- if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
597
- attachmentManager = AttachmentManager.fromConfig(config.attachments);
598
- attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
599
617
 
600
- // Wire up file input change event
601
- attachmentInput.addEventListener("change", (e) => {
602
- const target = e.target as HTMLInputElement;
603
- attachmentManager?.handleFileSelect(target.files);
604
- // Reset input so same file can be selected again
605
- target.value = "";
606
- });
607
- }
618
+ /** Wired after `handleMicButtonClick` is defined; used by `renderComposer` `onVoiceToggle`. */
619
+ let composerVoiceBridge: (() => void) | null = null;
608
620
 
609
621
  // Plugin hook: renderHeader - allow plugins to provide custom header
610
622
  const headerPlugin = plugins.find(p => p.renderHeader);
@@ -620,7 +632,7 @@ export const createAgentExperience = (
620
632
  });
621
633
  if (customHeader) {
622
634
  // Replace the default header with custom header
623
- const existingHeader = container.querySelector('.tvw-border-b-cw-divider');
635
+ const existingHeader = container.querySelector('.persona-border-b-persona-divider');
624
636
  if (existingHeader) {
625
637
  existingHeader.replaceWith(customHeader);
626
638
  header = customHeader;
@@ -647,9 +659,9 @@ export const createAgentExperience = (
647
659
  eventStreamView.update();
648
660
  }
649
661
  if (eventStreamToggleBtn) {
650
- eventStreamToggleBtn.classList.remove("tvw-text-cw-muted");
651
- eventStreamToggleBtn.classList.add("tvw-text-cw-accent");
652
- eventStreamToggleBtn.style.boxShadow = "inset 0 0 0 1.5px var(--cw-accent, #3b82f6)";
662
+ eventStreamToggleBtn.classList.remove("persona-text-persona-muted");
663
+ eventStreamToggleBtn.classList.add("persona-text-persona-accent");
664
+ eventStreamToggleBtn.style.boxShadow = "inset 0 0 0 1.5px var(--persona-accent, #3b82f6)";
653
665
  const activeClasses = config.features?.eventStream?.classNames?.toggleButtonActive;
654
666
  if (activeClasses) activeClasses.split(/\s+/).forEach(c => c && eventStreamToggleBtn!.classList.add(c));
655
667
  }
@@ -676,8 +688,8 @@ export const createAgentExperience = (
676
688
  }
677
689
  body.style.display = "";
678
690
  if (eventStreamToggleBtn) {
679
- eventStreamToggleBtn.classList.remove("tvw-text-cw-accent");
680
- eventStreamToggleBtn.classList.add("tvw-text-cw-muted");
691
+ eventStreamToggleBtn.classList.remove("persona-text-persona-accent");
692
+ eventStreamToggleBtn.classList.add("persona-text-persona-muted");
681
693
  eventStreamToggleBtn.style.boxShadow = "";
682
694
  const activeClasses = config.features?.eventStream?.classNames?.toggleButtonActive;
683
695
  if (activeClasses) activeClasses.split(/\s+/).forEach(c => c && eventStreamToggleBtn!.classList.remove(c));
@@ -694,7 +706,7 @@ export const createAgentExperience = (
694
706
  let eventStreamToggleBtn: HTMLButtonElement | null = null;
695
707
  if (showEventStreamToggle) {
696
708
  const esClassNames = config.features?.eventStream?.classNames;
697
- const toggleBtnClasses = "tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none tvw-bg-transparent tvw-p-1" + (esClassNames?.toggleButton ? " " + esClassNames.toggleButton : "");
709
+ const toggleBtnClasses = "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-1" + (esClassNames?.toggleButton ? " " + esClassNames.toggleButton : "");
698
710
  eventStreamToggleBtn = createElement("button", toggleBtnClasses) as HTMLButtonElement;
699
711
  eventStreamToggleBtn.style.width = "28px";
700
712
  eventStreamToggleBtn.style.height = "28px";
@@ -723,9 +735,38 @@ export const createAgentExperience = (
723
735
  });
724
736
  }
725
737
 
738
+ const ensureComposerAttachmentSurface = (rootFooter: HTMLElement) => {
739
+ const att = config.attachments;
740
+ if (!att?.enabled) return;
741
+ let previews = rootFooter.querySelector<HTMLElement>(".persona-attachment-previews");
742
+ if (!previews) {
743
+ previews = createElement(
744
+ "div",
745
+ "persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2"
746
+ );
747
+ previews.style.display = "none";
748
+ const form = rootFooter.querySelector("[data-persona-composer-form]");
749
+ if (form?.parentNode) {
750
+ form.parentNode.insertBefore(previews, form);
751
+ } else {
752
+ rootFooter.insertBefore(previews, rootFooter.firstChild);
753
+ }
754
+ }
755
+ if (!rootFooter.querySelector<HTMLInputElement>('input[type="file"]')) {
756
+ const fileIn = createElement("input") as HTMLInputElement;
757
+ fileIn.type = "file";
758
+ fileIn.accept = (att.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
759
+ fileIn.multiple = (att.maxFiles ?? 4) > 1;
760
+ fileIn.style.display = "none";
761
+ fileIn.setAttribute("aria-label", att.buttonTooltipText ?? "Attach files");
762
+ rootFooter.appendChild(fileIn);
763
+ }
764
+ };
765
+
726
766
  // Plugin hook: renderComposer - allow plugins to provide custom composer
727
767
  const composerPlugin = plugins.find(p => p.renderComposer);
728
768
  if (composerPlugin?.renderComposer) {
769
+ const composerCfg = config.composer;
729
770
  const customComposer = composerPlugin.renderComposer({
730
771
  config,
731
772
  defaultRenderer: () => {
@@ -737,17 +778,79 @@ export const createAgentExperience = (
737
778
  session.sendMessage(text);
738
779
  }
739
780
  },
740
- disabled: false
781
+ streaming: false,
782
+ disabled: false,
783
+ openAttachmentPicker: () => {
784
+ attachmentInput?.click();
785
+ },
786
+ models: composerCfg?.models,
787
+ selectedModelId: composerCfg?.selectedModelId,
788
+ onModelChange: (modelId: string) => {
789
+ config.composer = { ...config.composer, selectedModelId: modelId };
790
+ },
791
+ onVoiceToggle:
792
+ config.voiceRecognition?.enabled === true
793
+ ? () => {
794
+ composerVoiceBridge?.();
795
+ }
796
+ : undefined
741
797
  });
742
798
  if (customComposer) {
743
799
  // Replace the default footer with custom composer
744
800
  footer.replaceWith(customComposer);
745
801
  footer = customComposer;
746
- // Note: When using custom composer, textarea/sendButton/etc may not exist
747
- // The plugin is responsible for providing its own submit handling
748
802
  }
749
803
  }
750
804
 
805
+ const bindComposerRefsFromFooter = (rootFooter: HTMLElement) => {
806
+ const form = rootFooter.querySelector<HTMLFormElement>("[data-persona-composer-form]");
807
+ const ta = rootFooter.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]");
808
+ const sb = rootFooter.querySelector<HTMLButtonElement>("[data-persona-composer-submit]");
809
+ const mic = rootFooter.querySelector<HTMLButtonElement>("[data-persona-composer-mic]");
810
+ const st = rootFooter.querySelector<HTMLElement>("[data-persona-composer-status]");
811
+ if (form) composerForm = form;
812
+ if (ta) textarea = ta;
813
+ if (sb) sendButton = sb;
814
+ if (mic) {
815
+ micButton = mic;
816
+ micButtonWrapper = mic.parentElement as HTMLElement | null;
817
+ }
818
+ if (st) statusText = st;
819
+ const sug = rootFooter.querySelector<HTMLElement>(
820
+ ".persona-mb-3.persona-flex.persona-flex-wrap.persona-gap-2"
821
+ );
822
+ if (sug) suggestions = sug;
823
+ const attBtn = rootFooter.querySelector<HTMLButtonElement>(".persona-attachment-button");
824
+ if (attBtn) {
825
+ attachmentButton = attBtn;
826
+ attachmentButtonWrapper = attBtn.parentElement as HTMLElement | null;
827
+ }
828
+ attachmentInput = rootFooter.querySelector<HTMLInputElement>('input[type="file"]');
829
+ attachmentPreviewsContainer = rootFooter.querySelector<HTMLElement>(".persona-attachment-previews");
830
+ const ar = rootFooter.querySelector<HTMLElement>(".persona-widget-composer .persona-flex.persona-items-center.persona-justify-between");
831
+ if (ar) _actionsRow = ar;
832
+ };
833
+ ensureComposerAttachmentSurface(footer);
834
+ bindComposerRefsFromFooter(footer);
835
+
836
+ // Apply contentMaxWidth to composer form if configured
837
+ const contentMaxWidth = config.layout?.contentMaxWidth;
838
+ if (contentMaxWidth && composerForm) {
839
+ composerForm.style.maxWidth = contentMaxWidth;
840
+ composerForm.style.marginLeft = "auto";
841
+ composerForm.style.marginRight = "auto";
842
+ }
843
+
844
+ if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
845
+ attachmentManager = AttachmentManager.fromConfig(config.attachments);
846
+ attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
847
+ attachmentInput.addEventListener("change", (e) => {
848
+ const target = e.target as HTMLInputElement;
849
+ attachmentManager?.handleFileSelect(target.files);
850
+ target.value = "";
851
+ });
852
+ }
853
+
751
854
  // Slot system: allow custom content injection into specific regions
752
855
  const renderSlots = () => {
753
856
  const slots = config.layout?.slots ?? {};
@@ -757,7 +860,7 @@ export const createAgentExperience = (
757
860
  switch (slot) {
758
861
  case "body-top":
759
862
  // Default: the intro card
760
- return container.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6") as HTMLElement || null;
863
+ return container.querySelector(".persona-rounded-2xl.persona-bg-persona-surface.persona-p-6") as HTMLElement || null;
761
864
  case "messages":
762
865
  return messagesWrapper;
763
866
  case "footer-top":
@@ -784,7 +887,7 @@ export const createAgentExperience = (
784
887
  header.appendChild(element);
785
888
  } else {
786
889
  // header-center: insert after icon/title
787
- const titleSection = header.querySelector(".tvw-flex-col");
890
+ const titleSection = header.querySelector(".persona-flex-col");
788
891
  if (titleSection) {
789
892
  titleSection.parentNode?.insertBefore(element, titleSection.nextSibling);
790
893
  } else {
@@ -794,7 +897,7 @@ export const createAgentExperience = (
794
897
  break;
795
898
  case "body-top": {
796
899
  // Replace or prepend to body
797
- const introCard = body.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6");
900
+ const introCard = body.querySelector(".persona-rounded-2xl.persona-bg-persona-surface.persona-p-6");
798
901
  if (introCard) {
799
902
  introCard.replaceWith(element);
800
903
  } else {
@@ -904,7 +1007,7 @@ export const createAgentExperience = (
904
1007
 
905
1008
  messagesWrapper.addEventListener('click', (event) => {
906
1009
  const target = event.target as HTMLElement;
907
- const actionBtn = target.closest('.tvw-message-action-btn[data-action]') as HTMLElement;
1010
+ const actionBtn = target.closest('.persona-message-action-btn[data-action]') as HTMLElement;
908
1011
  if (!actionBtn) return;
909
1012
 
910
1013
  event.preventDefault();
@@ -926,14 +1029,14 @@ export const createAgentExperience = (
926
1029
  const textToCopy = message.content || "";
927
1030
  navigator.clipboard.writeText(textToCopy).then(() => {
928
1031
  // Show success feedback - swap icon temporarily
929
- actionBtn.classList.add("tvw-message-action-success");
1032
+ actionBtn.classList.add("persona-message-action-success");
930
1033
  const checkIcon = renderLucideIcon("check", 14, "currentColor", 2);
931
1034
  if (checkIcon) {
932
1035
  actionBtn.innerHTML = "";
933
1036
  actionBtn.appendChild(checkIcon);
934
1037
  }
935
1038
  setTimeout(() => {
936
- actionBtn.classList.remove("tvw-message-action-success");
1039
+ actionBtn.classList.remove("persona-message-action-success");
937
1040
  const originalIcon = renderLucideIcon("copy", 14, "currentColor", 2);
938
1041
  if (originalIcon) {
939
1042
  actionBtn.innerHTML = "";
@@ -955,17 +1058,17 @@ export const createAgentExperience = (
955
1058
  if (wasActive) {
956
1059
  // Toggle off
957
1060
  messageVoteState.delete(messageId);
958
- actionBtn.classList.remove("tvw-message-action-active");
1061
+ actionBtn.classList.remove("persona-message-action-active");
959
1062
  } else {
960
1063
  // Clear opposite vote button
961
1064
  const oppositeAction = action === 'upvote' ? 'downvote' : 'upvote';
962
1065
  const oppositeBtn = actionsContainer.querySelector(`[data-action="${oppositeAction}"]`);
963
1066
  if (oppositeBtn) {
964
- oppositeBtn.classList.remove("tvw-message-action-active");
1067
+ oppositeBtn.classList.remove("persona-message-action-active");
965
1068
  }
966
1069
 
967
1070
  messageVoteState.set(messageId, action);
968
- actionBtn.classList.add("tvw-message-action-active");
1071
+ actionBtn.classList.add("persona-message-action-active");
969
1072
 
970
1073
  // Trigger feedback
971
1074
  const messages = session.getMessages();
@@ -1021,32 +1124,334 @@ export const createAgentExperience = (
1021
1124
  session.resolveApproval(approvalMessage.approval, decision);
1022
1125
  });
1023
1126
 
1024
- panel.appendChild(container);
1127
+ let artifactPaneApi: ArtifactPaneApi | null = null;
1128
+ let artifactPanelResizeObs: ResizeObserver | null = null;
1129
+ let lastArtifactsState: {
1130
+ artifacts: PersonaArtifactRecord[];
1131
+ selectedId: string | null;
1132
+ } = { artifacts: [], selectedId: null };
1133
+ let artifactsPaneUserHidden = false;
1134
+ const sessionRef: { current: AgentWidgetSession | null } = { current: null };
1135
+
1136
+ // Click delegation for artifact download buttons
1137
+ messagesWrapper.addEventListener('click', (event) => {
1138
+ const target = event.target as HTMLElement;
1139
+ const dlBtn = target.closest('[data-download-artifact]') as HTMLElement;
1140
+ if (!dlBtn) return;
1141
+ event.preventDefault();
1142
+ event.stopPropagation();
1143
+ const artifactId = dlBtn.getAttribute('data-download-artifact');
1144
+ if (!artifactId) return;
1145
+ // Try session state first, fall back to content stored in the card's rawContent props
1146
+ const artifact = session.getArtifactById(artifactId);
1147
+ let markdown = artifact?.markdown;
1148
+ let title = artifact?.title || 'artifact';
1149
+ if (!markdown) {
1150
+ // After page refresh, session state is gone — read from the persisted card message
1151
+ const cardEl = dlBtn.closest('[data-open-artifact]');
1152
+ const msgEl = cardEl?.closest('[data-message-id]');
1153
+ const msgId = msgEl?.getAttribute('data-message-id');
1154
+ if (msgId) {
1155
+ const msgs = session.getMessages();
1156
+ const msg = msgs.find(m => m.id === msgId);
1157
+ if (msg?.rawContent) {
1158
+ try {
1159
+ const parsed = JSON.parse(msg.rawContent);
1160
+ markdown = parsed?.props?.markdown;
1161
+ title = parsed?.props?.title || title;
1162
+ } catch { /* ignore */ }
1163
+ }
1164
+ }
1165
+ }
1166
+ if (!markdown) return;
1167
+ const blob = new Blob([markdown], { type: 'text/markdown' });
1168
+ const url = URL.createObjectURL(blob);
1169
+ const a = document.createElement('a');
1170
+ a.href = url;
1171
+ a.download = `${title}.md`;
1172
+ a.click();
1173
+ URL.revokeObjectURL(url);
1174
+ });
1175
+
1176
+ // Click delegation for artifact reference cards
1177
+ messagesWrapper.addEventListener('click', (event) => {
1178
+ const target = event.target as HTMLElement;
1179
+ const card = target.closest('[data-open-artifact]') as HTMLElement;
1180
+ if (!card) return;
1181
+ const artifactId = card.getAttribute('data-open-artifact');
1182
+ if (!artifactId) return;
1183
+ event.preventDefault();
1184
+ event.stopPropagation();
1185
+ session.selectArtifact(artifactId);
1186
+ syncArtifactPane();
1187
+ });
1188
+
1189
+ // Keyboard support for artifact cards
1190
+ messagesWrapper.addEventListener('keydown', (event) => {
1191
+ if (event.key !== 'Enter' && event.key !== ' ') return;
1192
+ const target = event.target as HTMLElement;
1193
+ if (!target.hasAttribute('data-open-artifact')) return;
1194
+ event.preventDefault();
1195
+ target.click();
1196
+ });
1197
+
1198
+ let artifactSplitRoot: HTMLElement | null = null;
1199
+ let artifactResizeHandle: HTMLElement | null = null;
1200
+ let artifactResizeUnbind: (() => void) | null = null;
1201
+ let artifactResizeDocEnd: (() => void) | null = null;
1202
+ let reconcileArtifactResize: () => void = () => {};
1203
+
1204
+ function stopArtifactResizePointer() {
1205
+ artifactResizeDocEnd?.();
1206
+ artifactResizeDocEnd = null;
1207
+ }
1208
+
1209
+ /** Flush split: overlay handle on the seam so it does not consume flex gap (extension + resizable). */
1210
+ const positionExtensionArtifactResizeHandle = () => {
1211
+ if (!artifactSplitRoot || !artifactResizeHandle) return;
1212
+ const ext = mount.classList.contains("persona-artifact-appearance-seamless");
1213
+ const ownerWin = mount.ownerDocument.defaultView ?? window;
1214
+ const mobile = ownerWin.innerWidth <= 640;
1215
+ if (!ext || mount.classList.contains("persona-artifact-narrow-host") || mobile) {
1216
+ artifactResizeHandle.style.removeProperty("position");
1217
+ artifactResizeHandle.style.removeProperty("left");
1218
+ artifactResizeHandle.style.removeProperty("top");
1219
+ artifactResizeHandle.style.removeProperty("bottom");
1220
+ artifactResizeHandle.style.removeProperty("width");
1221
+ artifactResizeHandle.style.removeProperty("z-index");
1222
+ return;
1223
+ }
1224
+ const chat = artifactSplitRoot.firstElementChild as HTMLElement | null;
1225
+ if (!chat || chat === artifactResizeHandle) return;
1226
+ const hitW = 10;
1227
+ artifactResizeHandle.style.position = "absolute";
1228
+ artifactResizeHandle.style.top = "0";
1229
+ artifactResizeHandle.style.bottom = "0";
1230
+ artifactResizeHandle.style.width = `${hitW}px`;
1231
+ artifactResizeHandle.style.zIndex = "5";
1232
+ const left = chat.offsetWidth - hitW / 2;
1233
+ artifactResizeHandle.style.left = `${Math.max(0, left)}px`;
1234
+ };
1235
+
1236
+ /** No-op until artifact pane is created; replaced below when artifacts are enabled. */
1237
+ let applyLauncherArtifactPanelWidth: () => void = () => {};
1238
+
1239
+ const syncArtifactPane = () => {
1240
+ if (!artifactPaneApi || !artifactsSidebarEnabled(config)) return;
1241
+ applyArtifactLayoutCssVars(mount, config);
1242
+ applyArtifactPaneAppearance(mount, config);
1243
+ applyLauncherArtifactPanelWidth();
1244
+ const threshold = config.features?.artifacts?.layout?.narrowHostMaxWidth ?? 520;
1245
+ const w = panel.getBoundingClientRect().width || 0;
1246
+ mount.classList.toggle("persona-artifact-narrow-host", w > 0 && w <= threshold);
1247
+ artifactPaneApi.update(lastArtifactsState);
1248
+ if (artifactsPaneUserHidden) {
1249
+ artifactPaneApi.setMobileOpen(false);
1250
+ artifactPaneApi.element.classList.add("persona-hidden");
1251
+ artifactPaneApi.backdrop?.classList.add("persona-hidden");
1252
+ } else if (lastArtifactsState.artifacts.length > 0) {
1253
+ // User chose “show” again (e.g. programmatic showArtifacts): clear dismiss chrome
1254
+ // and force drawer open so narrow-host / mobile slide-out is not stuck off-screen.
1255
+ artifactPaneApi.element.classList.remove("persona-hidden");
1256
+ artifactPaneApi.setMobileOpen(true);
1257
+ }
1258
+ reconcileArtifactResize();
1259
+ };
1260
+
1261
+ if (artifactsSidebarEnabled(config)) {
1262
+ panel.style.position = "relative";
1263
+ const chatColumn = createElement(
1264
+ "div",
1265
+ "persona-flex persona-flex-1 persona-flex-col persona-min-w-0 persona-min-h-0"
1266
+ );
1267
+ const splitRoot = createElement(
1268
+ "div",
1269
+ "persona-flex persona-h-full persona-w-full persona-min-h-0 persona-artifact-split-root"
1270
+ );
1271
+ chatColumn.appendChild(container);
1272
+ artifactPaneApi = createArtifactPane(config, {
1273
+ onSelect: (id) => sessionRef.current?.selectArtifact(id),
1274
+ onDismiss: () => {
1275
+ artifactsPaneUserHidden = true;
1276
+ syncArtifactPane();
1277
+ }
1278
+ });
1279
+ artifactPaneApi.element.classList.add("persona-hidden");
1280
+ artifactSplitRoot = splitRoot;
1281
+ splitRoot.appendChild(chatColumn);
1282
+ splitRoot.appendChild(artifactPaneApi.element);
1283
+ if (artifactPaneApi.backdrop) {
1284
+ panel.appendChild(artifactPaneApi.backdrop);
1285
+ }
1286
+ panel.appendChild(splitRoot);
1287
+
1288
+ reconcileArtifactResize = () => {
1289
+ if (!artifactSplitRoot || !artifactPaneApi) return;
1290
+ const want = config.features?.artifacts?.layout?.resizable === true;
1291
+ if (!want) {
1292
+ artifactResizeUnbind?.();
1293
+ artifactResizeUnbind = null;
1294
+ stopArtifactResizePointer();
1295
+ if (artifactResizeHandle) {
1296
+ artifactResizeHandle.remove();
1297
+ artifactResizeHandle = null;
1298
+ }
1299
+ artifactPaneApi.element.style.removeProperty("width");
1300
+ artifactPaneApi.element.style.removeProperty("maxWidth");
1301
+ return;
1302
+ }
1303
+ if (!artifactResizeHandle) {
1304
+ const handle = createElement(
1305
+ "div",
1306
+ "persona-artifact-split-handle persona-shrink-0 persona-h-full"
1307
+ );
1308
+ handle.setAttribute("role", "separator");
1309
+ handle.setAttribute("aria-orientation", "vertical");
1310
+ handle.setAttribute("aria-label", "Resize artifacts panel");
1311
+ handle.tabIndex = 0;
1312
+
1313
+ const doc = mount.ownerDocument;
1314
+ const win = doc.defaultView ?? window;
1315
+
1316
+ const onPointerDown = (e: PointerEvent) => {
1317
+ if (!artifactPaneApi || e.button !== 0) return;
1318
+ if (mount.classList.contains("persona-artifact-narrow-host")) return;
1319
+ if (win.innerWidth <= 640) return;
1320
+ e.preventDefault();
1321
+ stopArtifactResizePointer();
1322
+ const startX = e.clientX;
1323
+ const startW = artifactPaneApi.element.getBoundingClientRect().width;
1324
+ const layout = config.features?.artifacts?.layout;
1325
+ const onMove = (ev: PointerEvent) => {
1326
+ const splitW = artifactSplitRoot!.getBoundingClientRect().width;
1327
+ const extensionChrome = mount.classList.contains("persona-artifact-appearance-seamless");
1328
+ const gapPx = extensionChrome ? 0 : readFlexGapPx(artifactSplitRoot!, win);
1329
+ const handleW = extensionChrome ? 0 : handle.getBoundingClientRect().width || 6;
1330
+ // Handle is left of the artifact: drag left widens artifact, drag right narrows it.
1331
+ const next = startW - (ev.clientX - startX);
1332
+ const clamped = resolveArtifactPaneWidthPx(
1333
+ next,
1334
+ splitW,
1335
+ gapPx,
1336
+ handleW,
1337
+ layout?.resizableMinWidth,
1338
+ layout?.resizableMaxWidth
1339
+ );
1340
+ artifactPaneApi!.element.style.width = `${clamped}px`;
1341
+ artifactPaneApi!.element.style.maxWidth = "none";
1342
+ positionExtensionArtifactResizeHandle();
1343
+ };
1344
+ const onUp = () => {
1345
+ doc.removeEventListener("pointermove", onMove);
1346
+ doc.removeEventListener("pointerup", onUp);
1347
+ doc.removeEventListener("pointercancel", onUp);
1348
+ artifactResizeDocEnd = null;
1349
+ try {
1350
+ handle.releasePointerCapture(e.pointerId);
1351
+ } catch {
1352
+ /* ignore */
1353
+ }
1354
+ };
1355
+ artifactResizeDocEnd = onUp;
1356
+ doc.addEventListener("pointermove", onMove);
1357
+ doc.addEventListener("pointerup", onUp);
1358
+ doc.addEventListener("pointercancel", onUp);
1359
+ try {
1360
+ handle.setPointerCapture(e.pointerId);
1361
+ } catch {
1362
+ /* ignore */
1363
+ }
1364
+ };
1365
+
1366
+ handle.addEventListener("pointerdown", onPointerDown);
1367
+ artifactResizeHandle = handle;
1368
+ artifactSplitRoot.insertBefore(handle, artifactPaneApi.element);
1369
+ artifactResizeUnbind = () => {
1370
+ handle.removeEventListener("pointerdown", onPointerDown);
1371
+ };
1372
+ }
1373
+ if (artifactResizeHandle) {
1374
+ const has =
1375
+ lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
1376
+ artifactResizeHandle.classList.toggle("persona-hidden", !has);
1377
+ positionExtensionArtifactResizeHandle();
1378
+ }
1379
+ };
1380
+
1381
+ applyLauncherArtifactPanelWidth = () => {
1382
+ if (!launcherEnabled || !artifactPaneApi) return;
1383
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
1384
+ if (sidebarMode) return;
1385
+ const ownerWindow = mount.ownerDocument.defaultView ?? window;
1386
+ const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
1387
+ const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
1388
+ if (mobileFullscreen && ownerWindow.innerWidth <= mobileBreakpoint) return;
1389
+ if (!shouldExpandLauncherForArtifacts(config, launcherEnabled)) return;
1390
+
1391
+ const base = config.launcher?.width ?? config.launcherWidth ?? "min(400px, calc(100vw - 24px))";
1392
+ const expanded =
1393
+ config.features?.artifacts?.layout?.expandedPanelWidth ??
1394
+ "min(720px, calc(100vw - 24px))";
1395
+ const hasVisible =
1396
+ lastArtifactsState.artifacts.length > 0 && !artifactsPaneUserHidden;
1397
+ if (hasVisible) {
1398
+ panel.style.width = expanded;
1399
+ panel.style.maxWidth = expanded;
1400
+ } else {
1401
+ panel.style.width = base;
1402
+ panel.style.maxWidth = base;
1403
+ }
1404
+ };
1405
+
1406
+ if (typeof ResizeObserver !== "undefined") {
1407
+ artifactPanelResizeObs = new ResizeObserver(() => {
1408
+ syncArtifactPane();
1409
+ });
1410
+ artifactPanelResizeObs.observe(panel);
1411
+ }
1412
+ } else {
1413
+ panel.appendChild(container);
1414
+ }
1025
1415
  mount.appendChild(wrapper);
1026
1416
 
1027
1417
  // Apply full-height and sidebar styles if enabled
1028
1418
  // This ensures the widget fills its container height with proper flex layout
1029
1419
  const applyFullHeightStyles = () => {
1420
+ const dockedMode = isDockedMountMode(config);
1030
1421
  const sidebarMode = config.launcher?.sidebarMode ?? false;
1031
- const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
1422
+ const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
1423
+ /** Script-tag / div embed: launcher off, host supplies a sized mount. */
1424
+ const isInlineEmbed = config.launcher?.enabled === false;
1032
1425
  const theme = config.theme ?? {};
1033
-
1426
+
1427
+ // Mobile fullscreen detection
1428
+ // Use mount's ownerDocument window to get correct viewport width when widget is inside an iframe
1429
+ const ownerWindow = mount.ownerDocument.defaultView ?? window;
1430
+ const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
1431
+ const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
1432
+ const isMobileViewport = ownerWindow.innerWidth <= mobileBreakpoint;
1433
+ const shouldGoFullscreen = mobileFullscreen && isMobileViewport && launcherEnabled;
1434
+
1034
1435
  // Determine panel styling based on mode, with theme overrides
1035
1436
  const position = config.launcher?.position ?? 'bottom-left';
1036
1437
  const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
1037
-
1438
+
1038
1439
  // Default values based on mode
1039
- const defaultPanelBorder = sidebarMode ? 'none' : '1px solid var(--tvw-cw-border)';
1040
- const defaultPanelShadow = sidebarMode
1041
- ? (isLeftSidebar ? '2px 0 12px rgba(0, 0, 0, 0.08)' : '-2px 0 12px rgba(0, 0, 0, 0.08)')
1042
- : '0 25px 50px -12px rgba(0, 0, 0, 0.25)';
1043
- const defaultPanelBorderRadius = sidebarMode ? '0' : '16px';
1044
-
1440
+ const defaultPanelBorder = (sidebarMode || shouldGoFullscreen) ? 'none' : '1px solid var(--persona-persona-border)';
1441
+ const defaultPanelShadow = shouldGoFullscreen
1442
+ ? 'none'
1443
+ : sidebarMode
1444
+ ? (isLeftSidebar ? 'var(--persona-palette-shadows-sidebar-left, 2px 0 12px rgba(0, 0, 0, 0.08))' : 'var(--persona-palette-shadows-sidebar-right, -2px 0 12px rgba(0, 0, 0, 0.08))')
1445
+ : 'var(--persona-palette-shadows-xl, 0 25px 50px -12px rgba(0, 0, 0, 0.25))';
1446
+ const defaultPanelBorderRadius = (sidebarMode || shouldGoFullscreen)
1447
+ ? '0'
1448
+ : 'var(--persona-panel-radius, var(--persona-radius-xl, 0.75rem))';
1449
+
1045
1450
  // Apply theme overrides or defaults
1046
1451
  const panelBorder = theme.panelBorder ?? defaultPanelBorder;
1047
1452
  const panelShadow = theme.panelShadow ?? defaultPanelShadow;
1048
1453
  const panelBorderRadius = theme.panelBorderRadius ?? defaultPanelBorderRadius;
1049
-
1454
+
1050
1455
  // Reset all inline styles first to handle mode toggling
1051
1456
  // This ensures styles don't persist when switching between modes
1052
1457
  mount.style.cssText = '';
@@ -1056,14 +1461,87 @@ export const createAgentExperience = (
1056
1461
  body.style.cssText = '';
1057
1462
  footer.style.cssText = '';
1058
1463
 
1464
+ // Mobile fullscreen: fill entire viewport with no radius/shadow/margins
1465
+ if (shouldGoFullscreen) {
1466
+ // Remove position offset classes
1467
+ wrapper.classList.remove(
1468
+ 'persona-bottom-6', 'persona-right-6', 'persona-left-6', 'persona-top-6',
1469
+ 'persona-bottom-4', 'persona-right-4', 'persona-left-4', 'persona-top-4'
1470
+ );
1471
+
1472
+ // Wrapper — fill entire viewport
1473
+ wrapper.style.cssText = `
1474
+ position: fixed !important;
1475
+ inset: 0 !important;
1476
+ width: 100% !important;
1477
+ height: 100% !important;
1478
+ max-height: 100% !important;
1479
+ margin: 0 !important;
1480
+ padding: 0 !important;
1481
+ display: flex !important;
1482
+ flex-direction: column !important;
1483
+ z-index: inherit !important;
1484
+ `;
1485
+
1486
+ // Panel — fill wrapper, no radius/shadow
1487
+ panel.style.cssText = `
1488
+ position: relative !important;
1489
+ display: flex !important;
1490
+ flex-direction: column !important;
1491
+ flex: 1 1 0% !important;
1492
+ width: 100% !important;
1493
+ max-width: 100% !important;
1494
+ height: 100% !important;
1495
+ min-height: 0 !important;
1496
+ margin: 0 !important;
1497
+ padding: 0 !important;
1498
+ box-shadow: none !important;
1499
+ border-radius: 0 !important;
1500
+ `;
1501
+
1502
+ // Container — fill panel, no radius/border
1503
+ container.style.cssText = `
1504
+ display: flex !important;
1505
+ flex-direction: column !important;
1506
+ flex: 1 1 0% !important;
1507
+ width: 100% !important;
1508
+ height: 100% !important;
1509
+ min-height: 0 !important;
1510
+ max-height: 100% !important;
1511
+ overflow: hidden !important;
1512
+ border-radius: 0 !important;
1513
+ border: none !important;
1514
+ `;
1515
+
1516
+ // Body — scrollable messages
1517
+ body.style.flex = '1 1 0%';
1518
+ body.style.minHeight = '0';
1519
+ body.style.overflowY = 'auto';
1520
+
1521
+ // Footer — pinned at bottom
1522
+ footer.style.flexShrink = '0';
1523
+
1524
+ wasMobileFullscreen = true;
1525
+ return; // Skip remaining mode logic
1526
+ }
1527
+
1059
1528
  // Re-apply panel width/maxWidth from initial setup
1060
1529
  const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
1061
1530
  const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
1062
- if (!sidebarMode) {
1063
- panel.style.width = width;
1064
- panel.style.maxWidth = width;
1531
+ if (!sidebarMode && !dockedMode) {
1532
+ if (isInlineEmbed && fullHeight) {
1533
+ panel.style.width = "100%";
1534
+ panel.style.maxWidth = "100%";
1535
+ } else {
1536
+ panel.style.width = width;
1537
+ panel.style.maxWidth = width;
1538
+ }
1539
+ } else if (dockedMode) {
1540
+ panel.style.width = "100%";
1541
+ panel.style.maxWidth = "100%";
1065
1542
  }
1066
-
1543
+ applyLauncherArtifactPanelWidth();
1544
+
1067
1545
  // Apply panel styling
1068
1546
  // Box-shadow is applied to panel (parent) instead of container to avoid
1069
1547
  // rendering artifacts when container has overflow:hidden + border-radius
@@ -1072,16 +1550,16 @@ export const createAgentExperience = (
1072
1550
  panel.style.borderRadius = panelBorderRadius;
1073
1551
  container.style.border = panelBorder;
1074
1552
  container.style.borderRadius = panelBorderRadius;
1075
-
1076
- // Check if this is inline embed mode (launcher disabled) vs launcher mode
1077
- const isInlineEmbed = config.launcher?.enabled === false;
1078
-
1553
+
1079
1554
  if (fullHeight) {
1080
1555
  // Mount container
1081
1556
  mount.style.display = 'flex';
1082
1557
  mount.style.flexDirection = 'column';
1083
1558
  mount.style.height = '100%';
1084
1559
  mount.style.minHeight = '0';
1560
+ if (isInlineEmbed) {
1561
+ mount.style.width = '100%';
1562
+ }
1085
1563
 
1086
1564
  // Wrapper
1087
1565
  // - Inline embed: needs overflow:hidden to contain the flex layout
@@ -1125,11 +1603,11 @@ export const createAgentExperience = (
1125
1603
  // Handle positioning classes based on mode
1126
1604
  // First remove all position classes to reset state
1127
1605
  wrapper.classList.remove(
1128
- 'tvw-bottom-6', 'tvw-right-6', 'tvw-left-6', 'tvw-top-6',
1129
- 'tvw-bottom-4', 'tvw-right-4', 'tvw-left-4', 'tvw-top-4'
1606
+ 'persona-bottom-6', 'persona-right-6', 'persona-left-6', 'persona-top-6',
1607
+ 'persona-bottom-4', 'persona-right-4', 'persona-left-4', 'persona-top-4'
1130
1608
  );
1131
1609
 
1132
- if (!sidebarMode && !isInlineEmbed) {
1610
+ if (!sidebarMode && !isInlineEmbed && !dockedMode) {
1133
1611
  // Restore positioning classes when not in sidebar mode (launcher mode only)
1134
1612
  const positionClasses = positionMap[position as keyof typeof positionMap] ?? positionMap['bottom-right'];
1135
1613
  positionClasses.split(' ').forEach(cls => wrapper.classList.add(cls));
@@ -1202,7 +1680,7 @@ export const createAgentExperience = (
1202
1680
  // Use both -moz-available (Firefox) and stretch (standard) for cross-browser support
1203
1681
  // Append to cssText to allow multiple fallback values for the same property
1204
1682
  // Only apply to launcher mode (not sidebar or inline embed)
1205
- if (!isInlineEmbed) {
1683
+ if (!isInlineEmbed && !dockedMode) {
1206
1684
  const maxHeightStyles = 'max-height: -moz-available !important; max-height: stretch !important;';
1207
1685
  const paddingStyles = sidebarMode ? '' : 'padding-top: 1.25em !important;';
1208
1686
  wrapper.style.cssText += maxHeightStyles + paddingStyles;
@@ -1211,9 +1689,30 @@ export const createAgentExperience = (
1211
1689
  applyFullHeightStyles();
1212
1690
  // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
1213
1691
  applyThemeVariables(mount, config);
1692
+ applyArtifactLayoutCssVars(mount, config);
1693
+ applyArtifactPaneAppearance(mount, config);
1214
1694
 
1215
1695
  const destroyCallbacks: Array<() => void> = [];
1216
1696
 
1697
+ if (artifactPanelResizeObs) {
1698
+ destroyCallbacks.push(() => {
1699
+ artifactPanelResizeObs?.disconnect();
1700
+ artifactPanelResizeObs = null;
1701
+ });
1702
+ }
1703
+
1704
+ destroyCallbacks.push(() => {
1705
+ artifactResizeUnbind?.();
1706
+ artifactResizeUnbind = null;
1707
+ stopArtifactResizePointer();
1708
+ if (artifactResizeHandle) {
1709
+ artifactResizeHandle.remove();
1710
+ artifactResizeHandle = null;
1711
+ }
1712
+ artifactPaneApi?.element.style.removeProperty("width");
1713
+ artifactPaneApi?.element.style.removeProperty("maxWidth");
1714
+ });
1715
+
1217
1716
  // Event stream cleanup
1218
1717
  if (showEventStreamToggle) {
1219
1718
  destroyCallbacks.push(() => {
@@ -1257,6 +1756,8 @@ export const createAgentExperience = (
1257
1756
  let closeHandler: (() => void) | null = null;
1258
1757
  let session: AgentWidgetSession;
1259
1758
  let isStreaming = false;
1759
+ const messageCache = createMessageCache();
1760
+ let configVersion = 0;
1260
1761
  let shouldAutoScroll = true;
1261
1762
  let lastScrollTop = 0;
1262
1763
  let lastAutoScrollTime = 0;
@@ -1530,7 +2031,20 @@ export const createAgentExperience = (
1530
2031
 
1531
2032
  const inlineLoadingRenderer = getInlineLoadingIndicatorRenderer();
1532
2033
 
2034
+ // Track active message IDs for cache pruning
2035
+ const activeMessageIds = new Set<string>();
2036
+
1533
2037
  messages.forEach((message) => {
2038
+ activeMessageIds.add(message.id);
2039
+
2040
+ // Fingerprint cache: skip re-rendering unchanged messages
2041
+ const fingerprint = computeMessageFingerprint(message, configVersion);
2042
+ const cachedWrapper = getCachedWrapper(messageCache, message.id, fingerprint);
2043
+ if (cachedWrapper) {
2044
+ tempContainer.appendChild(cachedWrapper.cloneNode(true));
2045
+ return;
2046
+ }
2047
+
1534
2048
  let bubble: HTMLElement | null = null;
1535
2049
 
1536
2050
  // Try plugins first
@@ -1612,36 +2126,59 @@ export const createAgentExperience = (
1612
2126
  transform
1613
2127
  });
1614
2128
  if (componentBubble) {
1615
- // Wrap component in standard bubble styling
1616
- const componentWrapper = document.createElement("div");
1617
- componentWrapper.className = [
1618
- "vanilla-message-bubble",
1619
- "tvw-max-w-[85%]",
1620
- "tvw-rounded-2xl",
1621
- "tvw-bg-cw-surface",
1622
- "tvw-border",
1623
- "tvw-border-cw-message-border",
1624
- "tvw-p-4"
1625
- ].join(" ");
1626
- // Set id for idiomorph matching
1627
- componentWrapper.id = `bubble-${message.id}`;
1628
- componentWrapper.setAttribute("data-message-id", message.id);
1629
-
1630
- // Add text content above component if present (combined text+component response)
1631
- if (message.content && message.content.trim()) {
1632
- const textDiv = document.createElement("div");
1633
- textDiv.className = "tvw-mb-3 tvw-text-sm tvw-leading-relaxed";
1634
- textDiv.innerHTML = transform({
1635
- text: message.content,
1636
- message,
1637
- streaming: Boolean(message.streaming),
1638
- raw: message.rawContent
1639
- });
1640
- componentWrapper.appendChild(textDiv);
1641
- }
2129
+ const wrapChrome = config.wrapComponentDirectiveInBubble !== false;
2130
+ if (wrapChrome) {
2131
+ const componentWrapper = document.createElement("div");
2132
+ componentWrapper.className = [
2133
+ "vanilla-message-bubble",
2134
+ "persona-max-w-[85%]",
2135
+ "persona-rounded-2xl",
2136
+ "persona-bg-persona-surface",
2137
+ "persona-border",
2138
+ "persona-border-persona-message-border",
2139
+ "persona-p-4"
2140
+ ].join(" ");
2141
+ componentWrapper.id = `bubble-${message.id}`;
2142
+ componentWrapper.setAttribute("data-message-id", message.id);
2143
+
2144
+ if (message.content && message.content.trim()) {
2145
+ const textDiv = document.createElement("div");
2146
+ textDiv.className = "persona-mb-3 persona-text-sm persona-leading-relaxed";
2147
+ textDiv.innerHTML = transform({
2148
+ text: message.content,
2149
+ message,
2150
+ streaming: Boolean(message.streaming),
2151
+ raw: message.rawContent
2152
+ });
2153
+ componentWrapper.appendChild(textDiv);
2154
+ }
1642
2155
 
1643
- componentWrapper.appendChild(componentBubble);
1644
- bubble = componentWrapper;
2156
+ componentWrapper.appendChild(componentBubble);
2157
+ bubble = componentWrapper;
2158
+ } else {
2159
+ const stack = document.createElement("div");
2160
+ stack.className =
2161
+ "persona-flex persona-flex-col persona-w-full persona-max-w-full persona-gap-3 persona-items-stretch";
2162
+ stack.id = `bubble-${message.id}`;
2163
+ stack.setAttribute("data-message-id", message.id);
2164
+ stack.setAttribute("data-persona-component-directive", "true");
2165
+
2166
+ if (message.content && message.content.trim()) {
2167
+ const textDiv = document.createElement("div");
2168
+ textDiv.className =
2169
+ "persona-text-sm persona-leading-relaxed persona-text-persona-primary persona-w-full";
2170
+ textDiv.innerHTML = transform({
2171
+ text: message.content,
2172
+ message,
2173
+ streaming: Boolean(message.streaming),
2174
+ raw: message.rawContent
2175
+ });
2176
+ stack.appendChild(textDiv);
2177
+ }
2178
+
2179
+ stack.appendChild(componentBubble);
2180
+ bubble = stack;
2181
+ }
1645
2182
  }
1646
2183
  }
1647
2184
  }
@@ -1693,17 +2230,24 @@ export const createAgentExperience = (
1693
2230
  }
1694
2231
 
1695
2232
  const wrapper = document.createElement("div");
1696
- wrapper.className = "tvw-flex";
2233
+ wrapper.className = "persona-flex";
1697
2234
  // Set id for idiomorph matching
1698
2235
  wrapper.id = `wrapper-${message.id}`;
1699
2236
  wrapper.setAttribute("data-wrapper-id", message.id);
1700
2237
  if (message.role === "user") {
1701
- wrapper.classList.add("tvw-justify-end");
2238
+ wrapper.classList.add("persona-justify-end");
2239
+ }
2240
+ if (bubble?.getAttribute("data-persona-component-directive") === "true") {
2241
+ wrapper.classList.add("persona-w-full");
1702
2242
  }
1703
2243
  wrapper.appendChild(bubble);
2244
+ setCachedWrapper(messageCache, message.id, fingerprint, wrapper);
1704
2245
  tempContainer.appendChild(wrapper);
1705
2246
  });
1706
2247
 
2248
+ // Remove cache entries for messages that no longer exist
2249
+ pruneCache(messageCache, activeMessageIds);
2250
+
1707
2251
  // Add standalone typing indicator only if streaming but no assistant message is streaming yet
1708
2252
  // (This shows while waiting for the stream to start)
1709
2253
  // Check for ANY streaming assistant message, even if empty (to avoid duplicate bubbles)
@@ -1752,30 +2296,30 @@ export const createAgentExperience = (
1752
2296
  const showBubble = config.loadingIndicator?.showBubble !== false; // default true
1753
2297
  typingBubble.className = showBubble
1754
2298
  ? [
1755
- "tvw-max-w-[85%]",
1756
- "tvw-rounded-2xl",
1757
- "tvw-text-sm",
1758
- "tvw-leading-relaxed",
1759
- "tvw-shadow-sm",
1760
- "tvw-bg-cw-surface",
1761
- "tvw-border",
1762
- "tvw-border-cw-message-border",
1763
- "tvw-text-cw-primary",
1764
- "tvw-px-5",
1765
- "tvw-py-3"
2299
+ "persona-max-w-[85%]",
2300
+ "persona-rounded-2xl",
2301
+ "persona-text-sm",
2302
+ "persona-leading-relaxed",
2303
+ "persona-shadow-sm",
2304
+ "persona-bg-persona-surface",
2305
+ "persona-border",
2306
+ "persona-border-persona-message-border",
2307
+ "persona-text-persona-primary",
2308
+ "persona-px-5",
2309
+ "persona-py-3"
1766
2310
  ].join(" ")
1767
2311
  : [
1768
- "tvw-max-w-[85%]",
1769
- "tvw-text-sm",
1770
- "tvw-leading-relaxed",
1771
- "tvw-text-cw-primary"
2312
+ "persona-max-w-[85%]",
2313
+ "persona-text-sm",
2314
+ "persona-leading-relaxed",
2315
+ "persona-text-persona-primary"
1772
2316
  ].join(" ");
1773
2317
  typingBubble.setAttribute("data-typing-indicator", "true");
1774
2318
 
1775
2319
  typingBubble.appendChild(typingIndicator);
1776
2320
 
1777
2321
  const typingWrapper = document.createElement("div");
1778
- typingWrapper.className = "tvw-flex";
2322
+ typingWrapper.className = "persona-flex";
1779
2323
  // Set id for idiomorph matching
1780
2324
  typingWrapper.id = "wrapper-typing-indicator";
1781
2325
  typingWrapper.setAttribute("data-wrapper-id", "typing-indicator");
@@ -1816,30 +2360,30 @@ export const createAgentExperience = (
1816
2360
  const showBubble = config.loadingIndicator?.showBubble !== false; // default true
1817
2361
  idleBubble.className = showBubble
1818
2362
  ? [
1819
- "tvw-max-w-[85%]",
1820
- "tvw-rounded-2xl",
1821
- "tvw-text-sm",
1822
- "tvw-leading-relaxed",
1823
- "tvw-shadow-sm",
1824
- "tvw-bg-cw-surface",
1825
- "tvw-border",
1826
- "tvw-border-cw-message-border",
1827
- "tvw-text-cw-primary",
1828
- "tvw-px-5",
1829
- "tvw-py-3"
2363
+ "persona-max-w-[85%]",
2364
+ "persona-rounded-2xl",
2365
+ "persona-text-sm",
2366
+ "persona-leading-relaxed",
2367
+ "persona-shadow-sm",
2368
+ "persona-bg-persona-surface",
2369
+ "persona-border",
2370
+ "persona-border-persona-message-border",
2371
+ "persona-text-persona-primary",
2372
+ "persona-px-5",
2373
+ "persona-py-3"
1830
2374
  ].join(" ")
1831
2375
  : [
1832
- "tvw-max-w-[85%]",
1833
- "tvw-text-sm",
1834
- "tvw-leading-relaxed",
1835
- "tvw-text-cw-primary"
2376
+ "persona-max-w-[85%]",
2377
+ "persona-text-sm",
2378
+ "persona-leading-relaxed",
2379
+ "persona-text-persona-primary"
1836
2380
  ].join(" ");
1837
2381
  idleBubble.setAttribute("data-idle-indicator", "true");
1838
2382
 
1839
2383
  idleBubble.appendChild(idleIndicator);
1840
2384
 
1841
2385
  const idleWrapper = document.createElement("div");
1842
- idleWrapper.className = "tvw-flex";
2386
+ idleWrapper.className = "persona-flex";
1843
2387
  // Set id for idiomorph matching
1844
2388
  idleWrapper.id = "wrapper-idle-indicator";
1845
2389
  idleWrapper.setAttribute("data-wrapper-id", "idle-indicator");
@@ -1867,10 +2411,12 @@ export const createAgentExperience = (
1867
2411
 
1868
2412
  const updateOpenState = () => {
1869
2413
  if (!launcherEnabled) return;
2414
+ const dockedMode = isDockedMountMode(config);
1870
2415
  if (open) {
1871
- wrapper.classList.remove("tvw-pointer-events-none", "tvw-opacity-0");
1872
- panel.classList.remove("tvw-scale-95", "tvw-opacity-0");
1873
- panel.classList.add("tvw-scale-100", "tvw-opacity-100");
2416
+ wrapper.style.display = dockedMode ? "flex" : "";
2417
+ wrapper.classList.remove("persona-pointer-events-none", "persona-opacity-0");
2418
+ panel.classList.remove("persona-scale-95", "persona-opacity-0");
2419
+ panel.classList.add("persona-scale-100", "persona-opacity-100");
1874
2420
  // Hide launcher button when widget is open
1875
2421
  if (launcherButtonInstance) {
1876
2422
  launcherButtonInstance.element.style.display = "none";
@@ -1878,9 +2424,16 @@ export const createAgentExperience = (
1878
2424
  customLauncherElement.style.display = "none";
1879
2425
  }
1880
2426
  } else {
1881
- wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
1882
- panel.classList.remove("tvw-scale-100", "tvw-opacity-100");
1883
- panel.classList.add("tvw-scale-95", "tvw-opacity-0");
2427
+ if (dockedMode) {
2428
+ wrapper.style.display = "none";
2429
+ wrapper.classList.remove("persona-pointer-events-none", "persona-opacity-0");
2430
+ panel.classList.remove("persona-scale-100", "persona-opacity-100", "persona-scale-95", "persona-opacity-0");
2431
+ } else {
2432
+ wrapper.style.display = "";
2433
+ wrapper.classList.add("persona-pointer-events-none", "persona-opacity-0");
2434
+ panel.classList.remove("persona-scale-100", "persona-opacity-100");
2435
+ panel.classList.add("persona-scale-95", "persona-opacity-0");
2436
+ }
1884
2437
  // Show launcher button when widget is closed
1885
2438
  if (launcherButtonInstance) {
1886
2439
  launcherButtonInstance.element.style.display = "";
@@ -1935,6 +2488,17 @@ export const createAgentExperience = (
1935
2488
  suggestionsManager.buttons.forEach((btn) => {
1936
2489
  btn.disabled = disabled;
1937
2490
  });
2491
+ footer.dataset.personaComposerStreaming = disabled ? "true" : "false";
2492
+ footer.querySelectorAll<HTMLElement>("[data-persona-composer-disable-when-streaming]").forEach((el) => {
2493
+ if (
2494
+ el instanceof HTMLButtonElement ||
2495
+ el instanceof HTMLInputElement ||
2496
+ el instanceof HTMLTextAreaElement ||
2497
+ el instanceof HTMLSelectElement
2498
+ ) {
2499
+ el.disabled = disabled;
2500
+ }
2501
+ });
1938
2502
  };
1939
2503
 
1940
2504
  const maybeFocusInput = () => {
@@ -2053,16 +2617,44 @@ export const createAgentExperience = (
2053
2617
  }
2054
2618
  },
2055
2619
  onVoiceStatusChanged(status: VoiceStatus) {
2056
- // When Runtype provider auto-stops (e.g. silence detection), update mic button
2057
- if (config.voiceRecognition?.provider?.type === 'runtype' && status !== 'listening') {
2058
- voiceState.active = false;
2059
- removeRuntypeMicRecordingStyles();
2060
- emitVoiceState("system");
2061
- persistVoiceMetadata();
2620
+ if (config.voiceRecognition?.provider?.type !== 'runtype') return;
2621
+
2622
+ switch (status) {
2623
+ case 'listening':
2624
+ // Recording styles are applied by toggleVoice() / startVoiceRecognition() flows
2625
+ break;
2626
+ case 'processing':
2627
+ removeRuntypeMicStateStyles();
2628
+ applyRuntypeMicProcessingStyles();
2629
+ break;
2630
+ case 'speaking':
2631
+ removeRuntypeMicStateStyles();
2632
+ applyRuntypeMicSpeakingStyles();
2633
+ break;
2634
+ default:
2635
+ // idle, connected, disconnected, error
2636
+ if (status === 'idle' && session.isBargeInActive()) {
2637
+ // Barge-in mic is still hot between turns — show it as active
2638
+ removeRuntypeMicStateStyles();
2639
+ applyRuntypeMicRecordingStyles();
2640
+ micButton?.setAttribute("aria-label", "End voice session");
2641
+ } else {
2642
+ voiceState.active = false;
2643
+ removeRuntypeMicStateStyles();
2644
+ emitVoiceState("system");
2645
+ persistVoiceMetadata();
2646
+ }
2647
+ break;
2062
2648
  }
2649
+ },
2650
+ onArtifactsState(state) {
2651
+ lastArtifactsState = state;
2652
+ syncArtifactPane();
2063
2653
  }
2064
2654
  });
2065
2655
 
2656
+ sessionRef.current = session;
2657
+
2066
2658
  // Setup Runtype voice provider when configured (connects WebSocket for server-side STT)
2067
2659
  if (config.voiceRecognition?.provider?.type === 'runtype') {
2068
2660
  try {
@@ -2176,6 +2768,8 @@ export const createAgentExperience = (
2176
2768
  backgroundColor: string;
2177
2769
  color: string;
2178
2770
  borderColor: string;
2771
+ iconName: string;
2772
+ iconSize: number;
2179
2773
  } | null = null;
2180
2774
 
2181
2775
  const getSpeechRecognitionClass = (): any => {
@@ -2273,20 +2867,22 @@ export const createAgentExperience = (
2273
2867
  emitVoiceState(source);
2274
2868
  persistVoiceMetadata();
2275
2869
  if (micButton) {
2276
- // Store original styles
2870
+ // Store original styles (including icon info for restoration)
2871
+ const voiceConfig = config.voiceRecognition ?? {};
2277
2872
  originalMicStyles = {
2278
2873
  backgroundColor: micButton.style.backgroundColor,
2279
2874
  color: micButton.style.color,
2280
- borderColor: micButton.style.borderColor
2875
+ borderColor: micButton.style.borderColor,
2876
+ iconName: voiceConfig.iconName ?? "mic",
2877
+ iconSize: parseFloat(voiceConfig.iconSize ?? config.sendButton?.size ?? "40") || 24,
2281
2878
  };
2282
-
2879
+
2283
2880
  // Apply recording state styles from config
2284
- const voiceConfig = config.voiceRecognition ?? {};
2285
2881
  const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
2286
2882
  const recordingIconColor = voiceConfig.recordingIconColor;
2287
2883
  const recordingBorderColor = voiceConfig.recordingBorderColor;
2288
2884
 
2289
- micButton.classList.add("tvw-voice-recording");
2885
+ micButton.classList.add("persona-voice-recording");
2290
2886
  micButton.style.backgroundColor = recordingBackgroundColor;
2291
2887
 
2292
2888
  if (recordingIconColor) {
@@ -2334,7 +2930,7 @@ export const createAgentExperience = (
2334
2930
  persistVoiceMetadata();
2335
2931
 
2336
2932
  if (micButton) {
2337
- micButton.classList.remove("tvw-voice-recording");
2933
+ micButton.classList.remove("persona-voice-recording");
2338
2934
 
2339
2935
  // Restore original styles
2340
2936
  if (originalMicStyles) {
@@ -2366,10 +2962,10 @@ export const createAgentExperience = (
2366
2962
 
2367
2963
  if (!hasVoiceInput) return null;
2368
2964
 
2369
- const micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
2965
+ const micButtonWrapper = createElement("div", "persona-send-button-wrapper");
2370
2966
  const micButton = createElement(
2371
2967
  "button",
2372
- "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
2968
+ "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
2373
2969
  ) as HTMLButtonElement;
2374
2970
 
2375
2971
  micButton.type = "button";
@@ -2407,14 +3003,14 @@ export const createAgentExperience = (
2407
3003
  if (backgroundColor) {
2408
3004
  micButton.style.backgroundColor = backgroundColor;
2409
3005
  } else {
2410
- micButton.classList.add("tvw-bg-cw-primary");
3006
+ micButton.classList.add("persona-bg-persona-primary");
2411
3007
  }
2412
3008
 
2413
3009
  // Apply icon/text color
2414
3010
  if (iconColor) {
2415
3011
  micButton.style.color = iconColor;
2416
3012
  } else if (!iconColor && !sendButtonConfig?.textColor) {
2417
- micButton.classList.add("tvw-text-white");
3013
+ micButton.classList.add("persona-text-white");
2418
3014
  }
2419
3015
 
2420
3016
  // Apply border styling
@@ -2442,7 +3038,7 @@ export const createAgentExperience = (
2442
3038
  const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
2443
3039
  const showTooltip = voiceConfig?.showTooltip ?? false;
2444
3040
  if (showTooltip && tooltipText) {
2445
- const tooltip = createElement("div", "tvw-send-button-tooltip");
3041
+ const tooltip = createElement("div", "persona-send-button-tooltip");
2446
3042
  tooltip.textContent = tooltipText;
2447
3043
  micButtonWrapper.appendChild(tooltip);
2448
3044
  }
@@ -2450,19 +3046,47 @@ export const createAgentExperience = (
2450
3046
  return { micButton, micButtonWrapper };
2451
3047
  };
2452
3048
 
2453
- // Helpers to apply/remove Runtype mic recording styles (mirrors start/stopVoiceRecognition)
2454
- const applyRuntypeMicRecordingStyles = () => {
2455
- if (!micButton) return;
3049
+ // --- Helpers to store/restore original mic button state ---
3050
+
3051
+ const storeOriginalMicStyles = () => {
3052
+ if (!micButton || originalMicStyles) return; // Already stored
3053
+ const voiceConfig = config.voiceRecognition ?? {};
2456
3054
  originalMicStyles = {
2457
3055
  backgroundColor: micButton.style.backgroundColor,
2458
3056
  color: micButton.style.color,
2459
- borderColor: micButton.style.borderColor
3057
+ borderColor: micButton.style.borderColor,
3058
+ iconName: voiceConfig.iconName ?? "mic",
3059
+ iconSize: parseFloat(voiceConfig.iconSize ?? config.sendButton?.size ?? "40") || 24,
2460
3060
  };
3061
+ };
3062
+
3063
+ /** Swap the mic button's SVG icon */
3064
+ const swapMicIcon = (iconName: string, color: string) => {
3065
+ if (!micButton) return;
3066
+ const existingSvg = micButton.querySelector("svg");
3067
+ if (existingSvg) existingSvg.remove();
3068
+ const size = originalMicStyles?.iconSize ?? (parseFloat(config.voiceRecognition?.iconSize ?? config.sendButton?.size ?? "40") || 24);
3069
+ const newSvg = renderLucideIcon(iconName, size, color, 1.5);
3070
+ if (newSvg) micButton.appendChild(newSvg);
3071
+ };
3072
+
3073
+ /** Remove all voice state CSS classes */
3074
+ const removeAllVoiceStateClasses = () => {
3075
+ if (!micButton) return;
3076
+ micButton.classList.remove("persona-voice-recording", "persona-voice-processing", "persona-voice-speaking");
3077
+ };
3078
+
3079
+ // --- Per-state style application ---
3080
+
3081
+ const applyRuntypeMicRecordingStyles = () => {
3082
+ if (!micButton) return;
3083
+ storeOriginalMicStyles();
2461
3084
  const voiceConfig = config.voiceRecognition ?? {};
2462
3085
  const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
2463
3086
  const recordingIconColor = voiceConfig.recordingIconColor;
2464
3087
  const recordingBorderColor = voiceConfig.recordingBorderColor;
2465
- micButton.classList.add("tvw-voice-recording");
3088
+ removeAllVoiceStateClasses();
3089
+ micButton.classList.add("persona-voice-recording");
2466
3090
  micButton.style.backgroundColor = recordingBackgroundColor;
2467
3091
  if (recordingIconColor) {
2468
3092
  micButton.style.color = recordingIconColor;
@@ -2472,17 +3096,86 @@ export const createAgentExperience = (
2472
3096
  if (recordingBorderColor) micButton.style.borderColor = recordingBorderColor;
2473
3097
  micButton.setAttribute("aria-label", "Stop voice recognition");
2474
3098
  };
2475
- const removeRuntypeMicRecordingStyles = () => {
3099
+
3100
+ const applyRuntypeMicProcessingStyles = () => {
3101
+ if (!micButton) return;
3102
+ storeOriginalMicStyles();
3103
+ const voiceConfig = config.voiceRecognition ?? {};
3104
+ const interruptionMode = session.getVoiceInterruptionMode();
3105
+ const iconName = voiceConfig.processingIconName ?? "loader";
3106
+ const iconColor = voiceConfig.processingIconColor ?? originalMicStyles?.color ?? "";
3107
+ const bgColor = voiceConfig.processingBackgroundColor ?? originalMicStyles?.backgroundColor ?? "";
3108
+ const borderColor = voiceConfig.processingBorderColor ?? originalMicStyles?.borderColor ?? "";
3109
+
3110
+ removeAllVoiceStateClasses();
3111
+ micButton.classList.add("persona-voice-processing");
3112
+ micButton.style.backgroundColor = bgColor;
3113
+ micButton.style.borderColor = borderColor;
3114
+ const resolvedColor = iconColor || "currentColor";
3115
+ micButton.style.color = resolvedColor;
3116
+ swapMicIcon(iconName, resolvedColor);
3117
+ micButton.setAttribute("aria-label", "Processing voice input");
3118
+ // In "none" mode the button is not actionable during processing
3119
+ if (interruptionMode === "none") {
3120
+ micButton.style.cursor = "default";
3121
+ }
3122
+ };
3123
+
3124
+ const applyRuntypeMicSpeakingStyles = () => {
3125
+ if (!micButton) return;
3126
+ storeOriginalMicStyles();
3127
+ const voiceConfig = config.voiceRecognition ?? {};
3128
+ const interruptionMode = session.getVoiceInterruptionMode();
3129
+ // Default icon depends on interruption mode:
3130
+ // "square" for cancel, "mic" for barge-in (hot mic), "volume-2" otherwise
3131
+ const defaultSpeakingIcon = interruptionMode === "cancel" ? "square"
3132
+ : interruptionMode === "barge-in" ? "mic"
3133
+ : "volume-2";
3134
+ const iconName = voiceConfig.speakingIconName ?? defaultSpeakingIcon;
3135
+ const iconColor = voiceConfig.speakingIconColor
3136
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingIconColor ?? originalMicStyles?.color ?? "") : (originalMicStyles?.color ?? ""));
3137
+ const bgColor = voiceConfig.speakingBackgroundColor
3138
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBackgroundColor ?? "#ef4444") : (originalMicStyles?.backgroundColor ?? ""));
3139
+ const borderColor = voiceConfig.speakingBorderColor
3140
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBorderColor ?? "") : (originalMicStyles?.borderColor ?? ""));
3141
+
3142
+ removeAllVoiceStateClasses();
3143
+ micButton.classList.add("persona-voice-speaking");
3144
+ micButton.style.backgroundColor = bgColor;
3145
+ micButton.style.borderColor = borderColor;
3146
+ const resolvedColor = iconColor || "currentColor";
3147
+ micButton.style.color = resolvedColor;
3148
+ swapMicIcon(iconName, resolvedColor);
3149
+
3150
+ // aria-label varies by interruption mode
3151
+ const ariaLabel = interruptionMode === "cancel"
3152
+ ? "Stop playback and re-record"
3153
+ : interruptionMode === "barge-in"
3154
+ ? "Speak to interrupt"
3155
+ : "Agent is speaking";
3156
+ micButton.setAttribute("aria-label", ariaLabel);
3157
+ // In "none" mode the button is not actionable during speaking
3158
+ if (interruptionMode === "none") {
3159
+ micButton.style.cursor = "default";
3160
+ }
3161
+ // In "barge-in" mode, add recording class to show mic is hot
3162
+ if (interruptionMode === "barge-in") {
3163
+ micButton.classList.add("persona-voice-recording");
3164
+ }
3165
+ };
3166
+
3167
+ /** Restore mic button to idle state (icon, colors, aria-label, cursor) */
3168
+ const removeRuntypeMicStateStyles = () => {
2476
3169
  if (!micButton) return;
2477
- micButton.classList.remove("tvw-voice-recording");
3170
+ removeAllVoiceStateClasses();
2478
3171
  if (originalMicStyles) {
2479
3172
  micButton.style.backgroundColor = originalMicStyles.backgroundColor ?? "";
2480
3173
  micButton.style.color = originalMicStyles.color ?? "";
2481
3174
  micButton.style.borderColor = originalMicStyles.borderColor ?? "";
2482
- const svg = micButton.querySelector("svg");
2483
- if (svg) svg.setAttribute("stroke", originalMicStyles.color || "currentColor");
3175
+ swapMicIcon(originalMicStyles.iconName, originalMicStyles.color || "currentColor");
2484
3176
  originalMicStyles = null;
2485
3177
  }
3178
+ micButton.style.cursor = "";
2486
3179
  micButton.setAttribute("aria-label", "Start voice recognition");
2487
3180
  };
2488
3181
 
@@ -2490,6 +3183,36 @@ export const createAgentExperience = (
2490
3183
  const handleMicButtonClick = () => {
2491
3184
  // Runtype provider: use session.toggleVoice() (WebSocket-based STT)
2492
3185
  if (config.voiceRecognition?.provider?.type === 'runtype') {
3186
+ const voiceStatus = session.getVoiceStatus();
3187
+ const interruptionMode = session.getVoiceInterruptionMode();
3188
+
3189
+ // In "none" mode, ignore clicks while processing or speaking
3190
+ if (interruptionMode === "none" &&
3191
+ (voiceStatus === "processing" || voiceStatus === "speaking")) {
3192
+ return;
3193
+ }
3194
+
3195
+ // In "cancel" mode during processing/speaking: stop playback only
3196
+ if (interruptionMode === "cancel" &&
3197
+ (voiceStatus === "processing" || voiceStatus === "speaking")) {
3198
+ session.stopVoicePlayback();
3199
+ return;
3200
+ }
3201
+
3202
+ // In barge-in mode, clicking mic = "hang up" (any state: speaking, idle, etc.)
3203
+ // Stops playback if active, tears down the always-on mic.
3204
+ if (session.isBargeInActive()) {
3205
+ session.stopVoicePlayback();
3206
+ session.deactivateBargeIn().then(() => {
3207
+ voiceState.active = false;
3208
+ voiceState.manuallyDeactivated = true;
3209
+ persistVoiceMetadata();
3210
+ emitVoiceState("user");
3211
+ removeRuntypeMicStateStyles();
3212
+ });
3213
+ return;
3214
+ }
3215
+
2493
3216
  session.toggleVoice().then(() => {
2494
3217
  voiceState.active = session.isVoiceActive();
2495
3218
  voiceState.manuallyDeactivated = !session.isVoiceActive();
@@ -2498,7 +3221,7 @@ export const createAgentExperience = (
2498
3221
  if (session.isVoiceActive()) {
2499
3222
  applyRuntypeMicRecordingStyles();
2500
3223
  } else {
2501
- removeRuntypeMicRecordingStyles();
3224
+ removeRuntypeMicStateStyles();
2502
3225
  }
2503
3226
  });
2504
3227
  return;
@@ -2524,13 +3247,15 @@ export const createAgentExperience = (
2524
3247
  }
2525
3248
  };
2526
3249
 
3250
+ composerVoiceBridge = handleMicButtonClick;
3251
+
2527
3252
  if (micButton) {
2528
3253
  micButton.addEventListener("click", handleMicButtonClick);
2529
3254
 
2530
3255
  destroyCallbacks.push(() => {
2531
3256
  if (config.voiceRecognition?.provider?.type === 'runtype') {
2532
3257
  if (session.isVoiceActive()) session.toggleVoice();
2533
- removeRuntypeMicRecordingStyles();
3258
+ removeRuntypeMicStateStyles();
2534
3259
  } else {
2535
3260
  stopVoiceRecognition("system");
2536
3261
  }
@@ -2627,26 +3352,48 @@ export const createAgentExperience = (
2627
3352
  }
2628
3353
 
2629
3354
  const recalcPanelHeight = () => {
3355
+ const dockedMode = isDockedMountMode(config);
2630
3356
  const sidebarMode = config.launcher?.sidebarMode ?? false;
2631
- const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
2632
-
2633
- if (!launcherEnabled) {
3357
+ const fullHeight = dockedMode || sidebarMode || (config.launcher?.fullHeight ?? false);
3358
+
3359
+ // Mobile fullscreen: re-apply fullscreen styles on resize (handles orientation changes)
3360
+ const ownerWindow = mount.ownerDocument.defaultView ?? window;
3361
+ const mobileFullscreen = config.launcher?.mobileFullscreen ?? true;
3362
+ const mobileBreakpoint = config.launcher?.mobileBreakpoint ?? 640;
3363
+ const isMobileViewport = ownerWindow.innerWidth <= mobileBreakpoint;
3364
+ const shouldGoFullscreen = mobileFullscreen && isMobileViewport && launcherEnabled;
3365
+
3366
+ if (shouldGoFullscreen) {
3367
+ applyFullHeightStyles();
3368
+ applyThemeVariables(mount, config);
3369
+ return;
3370
+ }
3371
+
3372
+ // Exiting mobile fullscreen (e.g., orientation change to landscape) — reset all styles
3373
+ if (wasMobileFullscreen) {
3374
+ wasMobileFullscreen = false;
3375
+ applyFullHeightStyles();
3376
+ applyThemeVariables(mount, config);
3377
+ }
3378
+
3379
+ if (!launcherEnabled && !dockedMode) {
2634
3380
  panel.style.height = "";
2635
3381
  panel.style.width = "";
2636
3382
  return;
2637
3383
  }
2638
-
3384
+
2639
3385
  // In sidebar/fullHeight mode, don't override the width - it's handled by applyFullHeightStyles
2640
- if (!sidebarMode) {
3386
+ if (!sidebarMode && !dockedMode) {
2641
3387
  const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
2642
3388
  const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
2643
3389
  panel.style.width = width;
2644
3390
  panel.style.maxWidth = width;
2645
3391
  }
2646
-
3392
+ applyLauncherArtifactPanelWidth();
3393
+
2647
3394
  // In fullHeight mode, don't set a fixed height
2648
3395
  if (!fullHeight) {
2649
- const viewportHeight = window.innerHeight;
3396
+ const viewportHeight = ownerWindow.innerHeight;
2650
3397
  const verticalMargin = 64; // leave space for launcher's offset
2651
3398
  const heightOffset = config.launcher?.heightOffset ?? 0;
2652
3399
  const available = Math.max(200, viewportHeight - verticalMargin);
@@ -2657,8 +3404,9 @@ export const createAgentExperience = (
2657
3404
  };
2658
3405
 
2659
3406
  recalcPanelHeight();
2660
- window.addEventListener("resize", recalcPanelHeight);
2661
- destroyCallbacks.push(() => window.removeEventListener("resize", recalcPanelHeight));
3407
+ const ownerWindow = mount.ownerDocument.defaultView ?? window;
3408
+ ownerWindow.addEventListener("resize", recalcPanelHeight);
3409
+ destroyCallbacks.push(() => ownerWindow.removeEventListener("resize", recalcPanelHeight));
2662
3410
 
2663
3411
  lastScrollTop = body.scrollTop;
2664
3412
 
@@ -2701,8 +3449,7 @@ export const createAgentExperience = (
2701
3449
  if (launcherEnabled) {
2702
3450
  closeButton.style.display = "";
2703
3451
  closeHandler = () => {
2704
- open = false;
2705
- updateOpenState();
3452
+ setOpenState(false, "user");
2706
3453
  };
2707
3454
  closeButton.addEventListener("click", closeHandler);
2708
3455
  } else {
@@ -2720,6 +3467,7 @@ export const createAgentExperience = (
2720
3467
  clearChatButton.addEventListener("click", () => {
2721
3468
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
2722
3469
  session.clearMessages();
3470
+ messageCache.clear();
2723
3471
 
2724
3472
  // Always clear the default localStorage key
2725
3473
  try {
@@ -2778,14 +3526,18 @@ export const createAgentExperience = (
2778
3526
 
2779
3527
  setupClearChatButton();
2780
3528
 
2781
- composerForm.addEventListener("submit", handleSubmit);
2782
- textarea.addEventListener("keydown", handleInputEnter);
2783
- textarea.addEventListener("paste", handleInputPaste);
3529
+ if (composerForm) {
3530
+ composerForm.addEventListener("submit", handleSubmit);
3531
+ }
3532
+ textarea?.addEventListener("keydown", handleInputEnter);
3533
+ textarea?.addEventListener("paste", handleInputPaste);
2784
3534
 
2785
3535
  destroyCallbacks.push(() => {
2786
- composerForm.removeEventListener("submit", handleSubmit);
2787
- textarea.removeEventListener("keydown", handleInputEnter);
2788
- textarea.removeEventListener("paste", handleInputPaste);
3536
+ if (composerForm) {
3537
+ composerForm.removeEventListener("submit", handleSubmit);
3538
+ }
3539
+ textarea?.removeEventListener("keydown", handleInputEnter);
3540
+ textarea?.removeEventListener("paste", handleInputPaste);
2789
3541
  });
2790
3542
 
2791
3543
  destroyCallbacks.push(() => {
@@ -2805,11 +3557,16 @@ export const createAgentExperience = (
2805
3557
  const controller: Controller = {
2806
3558
  update(nextConfig: AgentWidgetConfig) {
2807
3559
  const previousToolCallConfig = config.toolCall;
3560
+ const previousMessageActions = config.messageActions;
3561
+ const previousLayoutMessages = config.layout?.messages;
2808
3562
  const previousColorScheme = config.colorScheme;
2809
3563
  config = { ...config, ...nextConfig };
2810
3564
  // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
2811
3565
  applyFullHeightStyles();
2812
3566
  applyThemeVariables(mount, config);
3567
+ applyArtifactLayoutCssVars(mount, config);
3568
+ applyArtifactPaneAppearance(mount, config);
3569
+ syncArtifactPane();
2813
3570
 
2814
3571
  // Re-setup theme observer if colorScheme changed
2815
3572
  if (config.colorScheme !== previousColorScheme) {
@@ -2848,7 +3605,7 @@ export const createAgentExperience = (
2848
3605
  // Add header toggle button if not present
2849
3606
  if (!eventStreamToggleBtn && header) {
2850
3607
  const dynEsClassNames = config.features?.eventStream?.classNames;
2851
- const dynToggleBtnClasses = "tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none tvw-bg-transparent tvw-p-1" + (dynEsClassNames?.toggleButton ? " " + dynEsClassNames.toggleButton : "");
3608
+ const dynToggleBtnClasses = "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full persona-text-persona-muted hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none persona-bg-transparent persona-p-1" + (dynEsClassNames?.toggleButton ? " " + dynEsClassNames.toggleButton : "");
2852
3609
  eventStreamToggleBtn = createElement("button", dynToggleBtnClasses) as HTMLButtonElement;
2853
3610
  eventStreamToggleBtn.style.width = "28px";
2854
3611
  eventStreamToggleBtn.style.height = "28px";
@@ -2980,11 +3737,11 @@ export const createAgentExperience = (
2980
3737
  panelElements.clearChatButtonWrapper.style.display = showClearChat ? "" : "none";
2981
3738
  // When clear chat is hidden, close button needs ml-auto to stay right-aligned
2982
3739
  const { closeButtonWrapper } = panelElements;
2983
- if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
3740
+ if (closeButtonWrapper && !closeButtonWrapper.classList.contains("persona-absolute")) {
2984
3741
  if (showClearChat) {
2985
- closeButtonWrapper.classList.remove("tvw-ml-auto");
3742
+ closeButtonWrapper.classList.remove("persona-ml-auto");
2986
3743
  } else {
2987
- closeButtonWrapper.classList.add("tvw-ml-auto");
3744
+ closeButtonWrapper.classList.add("persona-ml-auto");
2988
3745
  }
2989
3746
  }
2990
3747
  }
@@ -3029,9 +3786,13 @@ export const createAgentExperience = (
3029
3786
  recalcPanelHeight();
3030
3787
  refreshCloseButton();
3031
3788
 
3032
- // Re-render messages if toolCall config changed (to apply new styles)
3789
+ // Re-render messages if config affecting message rendering changed
3033
3790
  const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
3034
- if (toolCallConfigChanged && session) {
3791
+ const messageActionsChanged = JSON.stringify(config.messageActions) !== JSON.stringify(previousMessageActions);
3792
+ const layoutMessagesChanged = JSON.stringify(config.layout?.messages) !== JSON.stringify(previousLayoutMessages);
3793
+ const messagesConfigChanged = toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged;
3794
+ if (messagesConfigChanged && session) {
3795
+ configVersion++;
3035
3796
  renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
3036
3797
  }
3037
3798
 
@@ -3045,8 +3806,8 @@ export const createAgentExperience = (
3045
3806
  const headerIconSize = launcher.headerIconSize ?? "48px";
3046
3807
 
3047
3808
  if (iconHolder) {
3048
- const headerEl = container.querySelector(".tvw-border-b-cw-divider");
3049
- const headerCopy = headerEl?.querySelector(".tvw-flex-col");
3809
+ const headerEl = container.querySelector(".persona-border-b-persona-divider");
3810
+ const headerCopy = headerEl?.querySelector(".persona-flex-col");
3050
3811
 
3051
3812
  // Handle hide/show
3052
3813
  if (shouldHideIcon) {
@@ -3096,7 +3857,7 @@ export const createAgentExperience = (
3096
3857
  const newImg = document.createElement("img");
3097
3858
  newImg.src = launcher.iconUrl;
3098
3859
  newImg.alt = "";
3099
- newImg.className = "tvw-rounded-xl tvw-object-cover";
3860
+ newImg.className = "persona-rounded-xl persona-object-cover";
3100
3861
  newImg.style.height = headerIconSize;
3101
3862
  newImg.style.width = headerIconSize;
3102
3863
  iconHolder.replaceChildren(newImg);
@@ -3147,7 +3908,7 @@ export const createAgentExperience = (
3147
3908
  // Update placement if changed - move the wrapper (not just the button) to preserve tooltip
3148
3909
  const { closeButtonWrapper } = panelElements;
3149
3910
  const isTopRight = closeButtonPlacement === "top-right";
3150
- const currentlyTopRight = closeButtonWrapper?.classList.contains("tvw-absolute");
3911
+ const currentlyTopRight = closeButtonWrapper?.classList.contains("persona-absolute");
3151
3912
 
3152
3913
  if (closeButtonWrapper && isTopRight !== currentlyTopRight) {
3153
3914
  // Placement changed - need to move wrapper and update classes
@@ -3155,16 +3916,16 @@ export const createAgentExperience = (
3155
3916
 
3156
3917
  // Update wrapper classes
3157
3918
  if (isTopRight) {
3158
- closeButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50";
3919
+ closeButtonWrapper.className = "persona-absolute persona-top-4 persona-right-4 persona-z-50";
3159
3920
  container.style.position = "relative";
3160
3921
  container.appendChild(closeButtonWrapper);
3161
3922
  } else {
3162
3923
  // Check if clear chat is inline to determine if we need ml-auto
3163
3924
  const clearChatPlacement = launcher.clearChat?.placement ?? "inline";
3164
3925
  const clearChatEnabled = launcher.clearChat?.enabled ?? true;
3165
- closeButtonWrapper.className = (clearChatEnabled && clearChatPlacement === "inline") ? "" : "tvw-ml-auto";
3926
+ closeButtonWrapper.className = (clearChatEnabled && clearChatPlacement === "inline") ? "" : "persona-ml-auto";
3166
3927
  // Find header element
3167
- const header = container.querySelector(".tvw-border-b-cw-divider");
3928
+ const header = container.querySelector(".persona-border-b-persona-divider");
3168
3929
  if (header) {
3169
3930
  header.appendChild(closeButtonWrapper);
3170
3931
  }
@@ -3174,18 +3935,18 @@ export const createAgentExperience = (
3174
3935
  // Apply close button styling from config
3175
3936
  if (launcher.closeButtonColor) {
3176
3937
  closeButton.style.color = launcher.closeButtonColor;
3177
- closeButton.classList.remove("tvw-text-cw-muted");
3938
+ closeButton.classList.remove("persona-text-persona-muted");
3178
3939
  } else {
3179
3940
  closeButton.style.color = "";
3180
- closeButton.classList.add("tvw-text-cw-muted");
3941
+ closeButton.classList.add("persona-text-persona-muted");
3181
3942
  }
3182
3943
 
3183
3944
  if (launcher.closeButtonBackgroundColor) {
3184
3945
  closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
3185
- closeButton.classList.remove("hover:tvw-bg-gray-100");
3946
+ closeButton.classList.remove("hover:persona-bg-gray-100");
3186
3947
  } else {
3187
3948
  closeButton.style.backgroundColor = "";
3188
- closeButton.classList.add("hover:tvw-bg-gray-100");
3949
+ closeButton.classList.add("hover:persona-bg-gray-100");
3189
3950
  }
3190
3951
 
3191
3952
  // Apply border if width and/or color are provided
@@ -3193,18 +3954,18 @@ export const createAgentExperience = (
3193
3954
  const borderWidth = launcher.closeButtonBorderWidth || "0px";
3194
3955
  const borderColor = launcher.closeButtonBorderColor || "transparent";
3195
3956
  closeButton.style.border = `${borderWidth} solid ${borderColor}`;
3196
- closeButton.classList.remove("tvw-border-none");
3957
+ closeButton.classList.remove("persona-border-none");
3197
3958
  } else {
3198
3959
  closeButton.style.border = "";
3199
- closeButton.classList.add("tvw-border-none");
3960
+ closeButton.classList.add("persona-border-none");
3200
3961
  }
3201
3962
 
3202
3963
  if (launcher.closeButtonBorderRadius) {
3203
3964
  closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
3204
- closeButton.classList.remove("tvw-rounded-full");
3965
+ closeButton.classList.remove("persona-rounded-full");
3205
3966
  } else {
3206
3967
  closeButton.style.borderRadius = "";
3207
- closeButton.classList.add("tvw-rounded-full");
3968
+ closeButton.classList.add("persona-rounded-full");
3208
3969
  }
3209
3970
 
3210
3971
  // Update padding
@@ -3256,13 +4017,21 @@ export const createAgentExperience = (
3256
4017
  const showTooltip = () => {
3257
4018
  if (portaledTooltip || !closeButton) return; // Already showing or button doesn't exist
3258
4019
 
4020
+ const tooltipDocument = closeButton.ownerDocument;
4021
+ const tooltipContainer = tooltipDocument.body;
4022
+ if (!tooltipContainer) return;
4023
+
3259
4024
  // Create tooltip element
3260
- portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
4025
+ portaledTooltip = createElementInDocument(
4026
+ tooltipDocument,
4027
+ "div",
4028
+ "persona-clear-chat-tooltip"
4029
+ );
3261
4030
  portaledTooltip.textContent = closeButtonTooltipText;
3262
4031
 
3263
4032
  // Add arrow
3264
- const arrow = createElement("div");
3265
- arrow.className = "tvw-clear-chat-tooltip-arrow";
4033
+ const arrow = createElementInDocument(tooltipDocument, "div");
4034
+ arrow.className = "persona-clear-chat-tooltip-arrow";
3266
4035
  portaledTooltip.appendChild(arrow);
3267
4036
 
3268
4037
  // Get button position
@@ -3275,7 +4044,7 @@ export const createAgentExperience = (
3275
4044
  portaledTooltip.style.transform = "translate(-50%, -100%)";
3276
4045
 
3277
4046
  // Append to body
3278
- document.body.appendChild(portaledTooltip);
4047
+ tooltipContainer.appendChild(portaledTooltip);
3279
4048
  };
3280
4049
 
3281
4050
  const hideTooltip = () => {
@@ -3326,36 +4095,36 @@ export const createAgentExperience = (
3326
4095
 
3327
4096
  // When clear chat is hidden, close button needs ml-auto to stay right-aligned
3328
4097
  const { closeButtonWrapper } = panelElements;
3329
- if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
4098
+ if (closeButtonWrapper && !closeButtonWrapper.classList.contains("persona-absolute")) {
3330
4099
  if (shouldShowClearChat) {
3331
- closeButtonWrapper.classList.remove("tvw-ml-auto");
4100
+ closeButtonWrapper.classList.remove("persona-ml-auto");
3332
4101
  } else {
3333
- closeButtonWrapper.classList.add("tvw-ml-auto");
4102
+ closeButtonWrapper.classList.add("persona-ml-auto");
3334
4103
  }
3335
4104
  }
3336
4105
 
3337
4106
  // Update placement if changed
3338
4107
  const isTopRight = clearChatPlacement === "top-right";
3339
- const currentlyTopRight = clearChatButtonWrapper.classList.contains("tvw-absolute");
4108
+ const currentlyTopRight = clearChatButtonWrapper.classList.contains("persona-absolute");
3340
4109
 
3341
4110
  if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
3342
4111
  clearChatButtonWrapper.remove();
3343
4112
 
3344
4113
  if (isTopRight) {
3345
- // Don't use tvw-clear-chat-button-wrapper class for top-right mode as its
4114
+ // Don't use persona-clear-chat-button-wrapper class for top-right mode as its
3346
4115
  // display: inline-flex causes alignment issues with the close button
3347
- clearChatButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-z-50";
4116
+ clearChatButtonWrapper.className = "persona-absolute persona-top-4 persona-z-50";
3348
4117
  // Position to the left of the close button (which is at right: 1rem/16px)
3349
4118
  // Close button is ~32px wide, plus small gap = 48px from right
3350
4119
  clearChatButtonWrapper.style.right = "48px";
3351
4120
  container.style.position = "relative";
3352
4121
  container.appendChild(clearChatButtonWrapper);
3353
4122
  } else {
3354
- clearChatButtonWrapper.className = "tvw-relative tvw-ml-auto tvw-clear-chat-button-wrapper";
4123
+ clearChatButtonWrapper.className = "persona-relative persona-ml-auto persona-clear-chat-button-wrapper";
3355
4124
  // Clear the inline right style when switching back to inline mode
3356
4125
  clearChatButtonWrapper.style.right = "";
3357
4126
  // Find header and insert before close button
3358
- const header = container.querySelector(".tvw-border-b-cw-divider");
4127
+ const header = container.querySelector(".persona-border-b-persona-divider");
3359
4128
  const closeButtonWrapperEl = panelElements.closeButtonWrapper;
3360
4129
  if (header && closeButtonWrapperEl && closeButtonWrapperEl.parentElement === header) {
3361
4130
  header.insertBefore(clearChatButtonWrapper, closeButtonWrapperEl);
@@ -3366,13 +4135,13 @@ export const createAgentExperience = (
3366
4135
 
3367
4136
  // Also update close button's ml-auto class based on clear chat position
3368
4137
  const closeButtonWrapperEl = panelElements.closeButtonWrapper;
3369
- if (closeButtonWrapperEl && !closeButtonWrapperEl.classList.contains("tvw-absolute")) {
4138
+ if (closeButtonWrapperEl && !closeButtonWrapperEl.classList.contains("persona-absolute")) {
3370
4139
  if (isTopRight) {
3371
4140
  // Clear chat moved to top-right, close needs ml-auto
3372
- closeButtonWrapperEl.classList.add("tvw-ml-auto");
4141
+ closeButtonWrapperEl.classList.add("persona-ml-auto");
3373
4142
  } else {
3374
4143
  // Clear chat is inline, close doesn't need ml-auto
3375
- closeButtonWrapperEl.classList.remove("tvw-ml-auto");
4144
+ closeButtonWrapperEl.classList.remove("persona-ml-auto");
3376
4145
  }
3377
4146
  }
3378
4147
  }
@@ -3398,19 +4167,19 @@ export const createAgentExperience = (
3398
4167
  // Update icon color
3399
4168
  if (clearChatIconColor) {
3400
4169
  clearChatButton.style.color = clearChatIconColor;
3401
- clearChatButton.classList.remove("tvw-text-cw-muted");
4170
+ clearChatButton.classList.remove("persona-text-persona-muted");
3402
4171
  } else {
3403
4172
  clearChatButton.style.color = "";
3404
- clearChatButton.classList.add("tvw-text-cw-muted");
4173
+ clearChatButton.classList.add("persona-text-persona-muted");
3405
4174
  }
3406
4175
 
3407
4176
  // Update background color
3408
4177
  if (clearChatConfig.backgroundColor) {
3409
4178
  clearChatButton.style.backgroundColor = clearChatConfig.backgroundColor;
3410
- clearChatButton.classList.remove("hover:tvw-bg-gray-100");
4179
+ clearChatButton.classList.remove("hover:persona-bg-gray-100");
3411
4180
  } else {
3412
4181
  clearChatButton.style.backgroundColor = "";
3413
- clearChatButton.classList.add("hover:tvw-bg-gray-100");
4182
+ clearChatButton.classList.add("hover:persona-bg-gray-100");
3414
4183
  }
3415
4184
 
3416
4185
  // Update border
@@ -3418,19 +4187,19 @@ export const createAgentExperience = (
3418
4187
  const borderWidth = clearChatConfig.borderWidth || "0px";
3419
4188
  const borderColor = clearChatConfig.borderColor || "transparent";
3420
4189
  clearChatButton.style.border = `${borderWidth} solid ${borderColor}`;
3421
- clearChatButton.classList.remove("tvw-border-none");
4190
+ clearChatButton.classList.remove("persona-border-none");
3422
4191
  } else {
3423
4192
  clearChatButton.style.border = "";
3424
- clearChatButton.classList.add("tvw-border-none");
4193
+ clearChatButton.classList.add("persona-border-none");
3425
4194
  }
3426
4195
 
3427
4196
  // Update border radius
3428
4197
  if (clearChatConfig.borderRadius) {
3429
4198
  clearChatButton.style.borderRadius = clearChatConfig.borderRadius;
3430
- clearChatButton.classList.remove("tvw-rounded-full");
4199
+ clearChatButton.classList.remove("persona-rounded-full");
3431
4200
  } else {
3432
4201
  clearChatButton.style.borderRadius = "";
3433
- clearChatButton.classList.add("tvw-rounded-full");
4202
+ clearChatButton.classList.add("persona-rounded-full");
3434
4203
  }
3435
4204
 
3436
4205
  // Update padding
@@ -3468,13 +4237,21 @@ export const createAgentExperience = (
3468
4237
  const showTooltip = () => {
3469
4238
  if (portaledTooltip || !clearChatButton) return; // Already showing or button doesn't exist
3470
4239
 
4240
+ const tooltipDocument = clearChatButton.ownerDocument;
4241
+ const tooltipContainer = tooltipDocument.body;
4242
+ if (!tooltipContainer) return;
4243
+
3471
4244
  // Create tooltip element
3472
- portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
4245
+ portaledTooltip = createElementInDocument(
4246
+ tooltipDocument,
4247
+ "div",
4248
+ "persona-clear-chat-tooltip"
4249
+ );
3473
4250
  portaledTooltip.textContent = clearChatTooltipText;
3474
4251
 
3475
4252
  // Add arrow
3476
- const arrow = createElement("div");
3477
- arrow.className = "tvw-clear-chat-tooltip-arrow";
4253
+ const arrow = createElementInDocument(tooltipDocument, "div");
4254
+ arrow.className = "persona-clear-chat-tooltip-arrow";
3478
4255
  portaledTooltip.appendChild(arrow);
3479
4256
 
3480
4257
  // Get button position
@@ -3487,7 +4264,7 @@ export const createAgentExperience = (
3487
4264
  portaledTooltip.style.transform = "translate(-50%, -100%)";
3488
4265
 
3489
4266
  // Append to body
3490
- document.body.appendChild(portaledTooltip);
4267
+ tooltipContainer.appendChild(portaledTooltip);
3491
4268
  };
3492
4269
 
3493
4270
  const hideTooltip = () => {
@@ -3608,18 +4385,18 @@ export const createAgentExperience = (
3608
4385
  const backgroundColor = voiceConfig.backgroundColor ?? sendButtonConfig.backgroundColor;
3609
4386
  if (backgroundColor) {
3610
4387
  micButton.style.backgroundColor = backgroundColor;
3611
- micButton.classList.remove("tvw-bg-cw-primary");
4388
+ micButton.classList.remove("persona-bg-persona-primary");
3612
4389
  } else {
3613
4390
  micButton.style.backgroundColor = "";
3614
- micButton.classList.add("tvw-bg-cw-primary");
4391
+ micButton.classList.add("persona-bg-persona-primary");
3615
4392
  }
3616
4393
 
3617
4394
  if (iconColor) {
3618
4395
  micButton.style.color = iconColor;
3619
- micButton.classList.remove("tvw-text-white");
4396
+ micButton.classList.remove("persona-text-white");
3620
4397
  } else if (!iconColor && !sendButtonConfig.textColor) {
3621
4398
  micButton.style.color = "";
3622
- micButton.classList.add("tvw-text-white");
4399
+ micButton.classList.add("persona-text-white");
3623
4400
  }
3624
4401
 
3625
4402
  // Update border styling
@@ -3653,14 +4430,14 @@ export const createAgentExperience = (
3653
4430
  }
3654
4431
 
3655
4432
  // Update tooltip
3656
- const tooltip = micButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
4433
+ const tooltip = micButtonWrapper?.querySelector(".persona-send-button-tooltip") as HTMLElement | null;
3657
4434
  const tooltipText = voiceConfig.tooltipText ?? "Start voice recognition";
3658
4435
  const showTooltip = voiceConfig.showTooltip ?? false;
3659
4436
  if (showTooltip && tooltipText) {
3660
4437
  if (!tooltip) {
3661
4438
  // Create tooltip if it doesn't exist
3662
4439
  const newTooltip = document.createElement("div");
3663
- newTooltip.className = "tvw-send-button-tooltip";
4440
+ newTooltip.className = "persona-send-button-tooltip";
3664
4441
  newTooltip.textContent = tooltipText;
3665
4442
  micButtonWrapper?.insertBefore(newTooltip, micButton);
3666
4443
  } else {
@@ -3701,7 +4478,7 @@ export const createAgentExperience = (
3701
4478
 
3702
4479
  // Create previews container if not exists
3703
4480
  if (!attachmentPreviewsContainer) {
3704
- attachmentPreviewsContainer = createElement("div", "tvw-attachment-previews tvw-flex tvw-flex-wrap tvw-gap-2 tvw-mb-2");
4481
+ attachmentPreviewsContainer = createElement("div", "persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2");
3705
4482
  attachmentPreviewsContainer.style.display = "none";
3706
4483
  composerForm.insertBefore(attachmentPreviewsContainer, textarea);
3707
4484
  }
@@ -3718,12 +4495,12 @@ export const createAgentExperience = (
3718
4495
  }
3719
4496
 
3720
4497
  // Create attachment button wrapper
3721
- attachmentButtonWrapper = createElement("div", "tvw-send-button-wrapper");
4498
+ attachmentButtonWrapper = createElement("div", "persona-send-button-wrapper");
3722
4499
 
3723
4500
  // Create attachment button
3724
4501
  attachmentButton = createElement(
3725
4502
  "button",
3726
- "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer tvw-attachment-button"
4503
+ "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-attachment-button"
3727
4504
  ) as HTMLButtonElement;
3728
4505
  attachmentButton.type = "button";
3729
4506
  attachmentButton.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
@@ -3742,14 +4519,14 @@ export const createAgentExperience = (
3742
4519
  attachmentButton.style.fontSize = "18px";
3743
4520
  attachmentButton.style.lineHeight = "1";
3744
4521
  attachmentButton.style.backgroundColor = "transparent";
3745
- attachmentButton.style.color = "var(--cw-primary, #111827)";
4522
+ attachmentButton.style.color = "var(--persona-primary, #111827)";
3746
4523
  attachmentButton.style.border = "none";
3747
4524
  attachmentButton.style.borderRadius = "6px";
3748
4525
  attachmentButton.style.transition = "background-color 0.15s ease";
3749
4526
 
3750
4527
  // Add hover effect via mouseenter/mouseleave
3751
4528
  attachmentButton.addEventListener("mouseenter", () => {
3752
- attachmentButton!.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
4529
+ attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
3753
4530
  });
3754
4531
  attachmentButton.addEventListener("mouseleave", () => {
3755
4532
  attachmentButton!.style.backgroundColor = "transparent";
@@ -3771,7 +4548,7 @@ export const createAgentExperience = (
3771
4548
 
3772
4549
  // Add tooltip
3773
4550
  const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
3774
- const tooltip = createElement("div", "tvw-send-button-tooltip");
4551
+ const tooltip = createElement("div", "persona-send-button-tooltip");
3775
4552
  tooltip.textContent = attachTooltipText;
3776
4553
  attachmentButtonWrapper.appendChild(tooltip);
3777
4554
 
@@ -3859,7 +4636,7 @@ export const createAgentExperience = (
3859
4636
  if (textColor) {
3860
4637
  sendButton.style.color = textColor;
3861
4638
  } else {
3862
- sendButton.classList.add("tvw-text-white");
4639
+ sendButton.classList.add("persona-text-white");
3863
4640
  }
3864
4641
  }
3865
4642
  } else {
@@ -3867,18 +4644,18 @@ export const createAgentExperience = (
3867
4644
  if (textColor) {
3868
4645
  sendButton.style.color = textColor;
3869
4646
  } else {
3870
- sendButton.classList.add("tvw-text-white");
4647
+ sendButton.classList.add("persona-text-white");
3871
4648
  }
3872
4649
  }
3873
4650
 
3874
4651
  // Update classes
3875
- sendButton.className = "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer";
4652
+ sendButton.className = "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer";
3876
4653
 
3877
4654
  if (backgroundColor) {
3878
4655
  sendButton.style.backgroundColor = backgroundColor;
3879
- sendButton.classList.remove("tvw-bg-cw-primary");
4656
+ sendButton.classList.remove("persona-bg-persona-primary");
3880
4657
  } else {
3881
- sendButton.classList.add("tvw-bg-cw-primary");
4658
+ sendButton.classList.add("persona-bg-persona-primary");
3882
4659
  }
3883
4660
  } else {
3884
4661
  // Text mode: existing behavior
@@ -3891,19 +4668,19 @@ export const createAgentExperience = (
3891
4668
  sendButton.style.lineHeight = "";
3892
4669
 
3893
4670
  // Update classes
3894
- sendButton.className = "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold tvw-text-white disabled:tvw-opacity-50 tvw-cursor-pointer";
4671
+ sendButton.className = "persona-rounded-button persona-bg-persona-accent persona-px-4 persona-py-2 persona-text-sm persona-font-semibold persona-text-white disabled:persona-opacity-50 persona-cursor-pointer";
3895
4672
 
3896
4673
  if (backgroundColor) {
3897
4674
  sendButton.style.backgroundColor = backgroundColor;
3898
- sendButton.classList.remove("tvw-bg-cw-accent");
4675
+ sendButton.classList.remove("persona-bg-persona-accent");
3899
4676
  } else {
3900
- sendButton.classList.add("tvw-bg-cw-accent");
4677
+ sendButton.classList.add("persona-bg-persona-accent");
3901
4678
  }
3902
4679
 
3903
4680
  if (textColor) {
3904
4681
  sendButton.style.color = textColor;
3905
4682
  } else {
3906
- sendButton.classList.add("tvw-text-white");
4683
+ sendButton.classList.add("persona-text-white");
3907
4684
  }
3908
4685
  }
3909
4686
 
@@ -3938,12 +4715,12 @@ export const createAgentExperience = (
3938
4715
  }
3939
4716
 
3940
4717
  // Update tooltip
3941
- const tooltip = sendButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
4718
+ const tooltip = sendButtonWrapper?.querySelector(".persona-send-button-tooltip") as HTMLElement | null;
3942
4719
  if (showTooltip && tooltipText) {
3943
4720
  if (!tooltip) {
3944
4721
  // Create tooltip if it doesn't exist
3945
4722
  const newTooltip = document.createElement("div");
3946
- newTooltip.className = "tvw-send-button-tooltip";
4723
+ newTooltip.className = "persona-send-button-tooltip";
3947
4724
  newTooltip.textContent = tooltipText;
3948
4725
  sendButtonWrapper?.insertBefore(newTooltip, sendButton);
3949
4726
  } else {
@@ -3986,7 +4763,9 @@ export const createAgentExperience = (
3986
4763
  },
3987
4764
  clearChat() {
3988
4765
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
4766
+ artifactsPaneUserHidden = false;
3989
4767
  session.clearMessages();
4768
+ messageCache.clear();
3990
4769
 
3991
4770
  // Always clear the default localStorage key
3992
4771
  try {
@@ -4102,7 +4881,7 @@ export const createAgentExperience = (
4102
4881
  voiceState.manuallyDeactivated = true;
4103
4882
  persistVoiceMetadata();
4104
4883
  emitVoiceState("user");
4105
- removeRuntypeMicRecordingStyles();
4884
+ removeRuntypeMicStateStyles();
4106
4885
  });
4107
4886
  return true;
4108
4887
  }
@@ -4202,6 +4981,31 @@ export const createAgentExperience = (
4202
4981
  isEventStreamVisible(): boolean {
4203
4982
  return eventStreamVisible;
4204
4983
  },
4984
+ showArtifacts(): void {
4985
+ if (!artifactsSidebarEnabled(config)) return;
4986
+ artifactsPaneUserHidden = false;
4987
+ syncArtifactPane();
4988
+ artifactPaneApi?.setMobileOpen(true);
4989
+ },
4990
+ hideArtifacts(): void {
4991
+ if (!artifactsSidebarEnabled(config)) return;
4992
+ artifactsPaneUserHidden = true;
4993
+ syncArtifactPane();
4994
+ },
4995
+ upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord | null {
4996
+ if (!artifactsSidebarEnabled(config)) return null;
4997
+ // Programmatic adds should surface the pane even if the user previously hit Close.
4998
+ artifactsPaneUserHidden = false;
4999
+ return session.upsertArtifact(manual);
5000
+ },
5001
+ selectArtifact(id: string): void {
5002
+ if (!artifactsSidebarEnabled(config)) return;
5003
+ session.selectArtifact(id);
5004
+ },
5005
+ clearArtifacts(): void {
5006
+ if (!artifactsSidebarEnabled(config)) return;
5007
+ session.clearArtifacts();
5008
+ },
4205
5009
  focusInput(): boolean {
4206
5010
  if (launcherEnabled && !open) return false;
4207
5011
  if (!textarea) return false;
@@ -4261,7 +5065,7 @@ export const createAgentExperience = (
4261
5065
  }
4262
5066
 
4263
5067
  // Remove any existing feedback forms
4264
- const existingFeedback = messagesWrapper.querySelector('.tvw-feedback-container');
5068
+ const existingFeedback = messagesWrapper.querySelector('.persona-feedback-container');
4265
5069
  if (existingFeedback) {
4266
5070
  existingFeedback.remove();
4267
5071
  }
@@ -4288,7 +5092,7 @@ export const createAgentExperience = (
4288
5092
  }
4289
5093
 
4290
5094
  // Remove any existing feedback forms
4291
- const existingFeedback = messagesWrapper.querySelector('.tvw-feedback-container');
5095
+ const existingFeedback = messagesWrapper.querySelector('.persona-feedback-container');
4292
5096
  if (existingFeedback) {
4293
5097
  existingFeedback.remove();
4294
5098
  }
@@ -4387,6 +5191,51 @@ export const createAgentExperience = (
4387
5191
  window.removeEventListener("persona:hideEventStream", handleHideEvent);
4388
5192
  });
4389
5193
  }
5194
+
5195
+ const handleShowArtifacts = (e: Event) => {
5196
+ const detail = (e as CustomEvent).detail;
5197
+ if (!detail?.instanceId || detail.instanceId === instanceId) {
5198
+ controller.showArtifacts();
5199
+ }
5200
+ };
5201
+ const handleHideArtifacts = (e: Event) => {
5202
+ const detail = (e as CustomEvent).detail;
5203
+ if (!detail?.instanceId || detail.instanceId === instanceId) {
5204
+ controller.hideArtifacts();
5205
+ }
5206
+ };
5207
+ const handleUpsertArtifact = (e: Event) => {
5208
+ const detail = (e as CustomEvent).detail;
5209
+ if (detail?.instanceId && detail.instanceId !== instanceId) return;
5210
+ if (detail?.artifact) {
5211
+ controller.upsertArtifact(detail.artifact as PersonaArtifactManualUpsert);
5212
+ }
5213
+ };
5214
+ const handleSelectArtifact = (e: Event) => {
5215
+ const detail = (e as CustomEvent).detail;
5216
+ if (detail?.instanceId && detail.instanceId !== instanceId) return;
5217
+ if (typeof detail?.id === "string") {
5218
+ controller.selectArtifact(detail.id);
5219
+ }
5220
+ };
5221
+ const handleClearArtifacts = (e: Event) => {
5222
+ const detail = (e as CustomEvent).detail;
5223
+ if (!detail?.instanceId || detail.instanceId === instanceId) {
5224
+ controller.clearArtifacts();
5225
+ }
5226
+ };
5227
+ window.addEventListener("persona:showArtifacts", handleShowArtifacts);
5228
+ window.addEventListener("persona:hideArtifacts", handleHideArtifacts);
5229
+ window.addEventListener("persona:upsertArtifact", handleUpsertArtifact);
5230
+ window.addEventListener("persona:selectArtifact", handleSelectArtifact);
5231
+ window.addEventListener("persona:clearArtifacts", handleClearArtifacts);
5232
+ destroyCallbacks.push(() => {
5233
+ window.removeEventListener("persona:showArtifacts", handleShowArtifacts);
5234
+ window.removeEventListener("persona:hideArtifacts", handleHideArtifacts);
5235
+ window.removeEventListener("persona:upsertArtifact", handleUpsertArtifact);
5236
+ window.removeEventListener("persona:selectArtifact", handleSelectArtifact);
5237
+ window.removeEventListener("persona:clearArtifacts", handleClearArtifacts);
5238
+ });
4390
5239
  }
4391
5240
 
4392
5241
  // ============================================================================