@runtypelabs/persona 4.32.1 → 4.33.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 (165) hide show
  1. package/dist/activity-ui.d.cts +3 -3
  2. package/dist/activity-ui.d.ts +3 -3
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/approval-ui.d.cts +2 -2
  8. package/dist/approval-ui.d.ts +2 -2
  9. package/dist/artifacts-ui.d.cts +3 -3
  10. package/dist/artifacts-ui.d.ts +3 -3
  11. package/dist/background-runtime.d.cts +1 -1
  12. package/dist/background-runtime.d.ts +1 -1
  13. package/dist/{chunk-BbXc58OA.js → chunk-6CUrH_jm.js} +1 -1
  14. package/dist/{chunk-B_H0l3u2.js → chunk-BRM3bHcJ.js} +1 -1
  15. package/dist/chunk-CEPM3FOp.js +1 -0
  16. package/dist/{chunk-CumoXK3u.js → chunk-CXQJG7pb.js} +1 -1
  17. package/dist/{chunk-UWMfWW2a.js → chunk-D1G8tgYc.js} +1 -1
  18. package/dist/{chunk-BpjtKnGI.js → chunk-D6U0E82p.js} +1 -1
  19. package/dist/{chunk-2FnmZ9AA.js → chunk-DOZmcve8.js} +1 -1
  20. package/dist/chunk-DVgzoVCB.js +1 -0
  21. package/dist/{chunk-C9uOhfk5.js → chunk-DW7Vg_l2.js} +1 -1
  22. package/dist/{chunk-B3l7TOOv.js → chunk-DbGLjU2P.js} +1 -1
  23. package/dist/{chunk-DTGvDx8o.js → chunk-Dj2a9f9w.js} +1 -1
  24. package/dist/{chunk-DgZZqy14.js → chunk-d54BJdKd.js} +1 -1
  25. package/dist/chunk-dLJWKfOb.js +1 -0
  26. package/dist/{chunk-DNdeIFJ6.js → chunk-gu34lr-P.js} +1 -1
  27. package/dist/chunk-i4qGOJAL.js +1 -0
  28. package/dist/{chunk-DVZ8ri1z.js → chunk-ql5N2I_t.js} +1 -1
  29. package/dist/client-dispatch.js +1 -1
  30. package/dist/client-history.js +1 -1
  31. package/dist/codegen.cjs +1 -1
  32. package/dist/codegen.js +1 -1
  33. package/dist/composer-actions-runtime.d.cts +2 -2
  34. package/dist/composer-actions-runtime.d.ts +2 -2
  35. package/dist/{composer-input-dwg_o9Z-.d.cts → composer-input-DnqGCXZz.d.cts} +1 -1
  36. package/dist/{composer-input-dwg_o9Z-.d.ts → composer-input-DnqGCXZz.d.ts} +1 -1
  37. package/dist/{composer-store-Dw7Zbqxr.d.cts → composer-store-6Iwy6WLI.d.cts} +1 -1
  38. package/dist/{composer-store-Dw7Zbqxr.d.ts → composer-store-6Iwy6WLI.d.ts} +1 -1
  39. package/dist/{context-mention-manager-D2p0-JH-.d.cts → context-mention-manager-B-rHF98n.d.cts} +2 -2
  40. package/dist/{context-mention-manager-D2p0-JH-.d.ts → context-mention-manager-B-rHF98n.d.ts} +2 -2
  41. package/dist/context-mentions-inline.d.cts +2 -2
  42. package/dist/context-mentions-inline.d.ts +2 -2
  43. package/dist/context-mentions.d.cts +3 -3
  44. package/dist/context-mentions.d.ts +3 -3
  45. package/dist/{defaults-CjX0kKzb.d.ts → defaults-BuOjqUAG.d.cts} +2 -1
  46. package/dist/{defaults-CjX0kKzb.d.cts → defaults-BuOjqUAG.d.ts} +2 -1
  47. package/dist/dictation-runtime.d.cts +2 -2
  48. package/dist/dictation-runtime.d.ts +2 -2
  49. package/dist/event-stream-view.d.cts +1 -1
  50. package/dist/event-stream-view.d.ts +1 -1
  51. package/dist/{fallback-speech-engine-DmF7MczI.d.cts → fallback-speech-engine-SqEfkGda.d.cts} +1 -1
  52. package/dist/{fallback-speech-engine-DmF7MczI.d.ts → fallback-speech-engine-SqEfkGda.d.ts} +1 -1
  53. package/dist/{feature-preferences-CVFDt9Xf.d.cts → feature-preferences-DqAmje-2.d.cts} +1 -1
  54. package/dist/{feature-preferences-CVFDt9Xf.d.ts → feature-preferences-DqAmje-2.d.ts} +1 -1
  55. package/dist/forms-ui.d.cts +2 -2
  56. package/dist/forms-ui.d.ts +2 -2
  57. package/dist/{history-confirm-dialog-DJEXsTyE.d.cts → history-confirm-dialog-B3L7QNcO.d.cts} +1 -1
  58. package/dist/{history-confirm-dialog-DJEXsTyE.d.ts → history-confirm-dialog-B3L7QNcO.d.ts} +1 -1
  59. package/dist/history-shell.cjs +1 -1
  60. package/dist/history-shell.d.cts +6 -6
  61. package/dist/history-shell.d.ts +6 -6
  62. package/dist/history-shell.js +1 -1
  63. package/dist/history-view.d.cts +1 -1
  64. package/dist/history-view.d.ts +1 -1
  65. package/dist/index.cjs +38 -38
  66. package/dist/index.cjs.map +1 -1
  67. package/dist/index.d.cts +9 -9
  68. package/dist/index.d.ts +9 -9
  69. package/dist/index.global.js +1 -1
  70. package/dist/index.global.js.map +1 -1
  71. package/dist/index.js +52 -52
  72. package/dist/index.js.map +1 -1
  73. package/dist/install.global.js +1 -1
  74. package/dist/install.global.js.map +1 -1
  75. package/dist/launcher.global.js +2 -2
  76. package/dist/launcher.global.js.map +1 -1
  77. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.cts → loading-label-runtime-loader-JecjOfJS.d.cts} +1 -1
  78. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.ts → loading-label-runtime-loader-JecjOfJS.d.ts} +1 -1
  79. package/dist/loading-label-runtime.d.cts +1 -1
  80. package/dist/loading-label-runtime.d.ts +1 -1
  81. package/dist/{message-bubble-CkZGlCLC.d.ts → message-bubble-CBYtpDsT.d.cts} +2 -2
  82. package/dist/{message-bubble-CkZGlCLC.d.cts → message-bubble-CBYtpDsT.d.ts} +2 -2
  83. package/dist/preferences-runtime.d.cts +1 -1
  84. package/dist/preferences-runtime.d.ts +1 -1
  85. package/dist/{preview-DdUvoLb4.d.cts → preview-_UJwwt2L.d.cts} +2 -2
  86. package/dist/{preview-DdUvoLb4.d.ts → preview-_UJwwt2L.d.ts} +2 -2
  87. package/dist/{registry-Dz4iHNST.d.cts → registry-B5ZV3K-h.d.cts} +1 -1
  88. package/dist/{registry-Dz4iHNST.d.ts → registry-B5ZV3K-h.d.ts} +1 -1
  89. package/dist/{session-Baxp9iRK.d.cts → session-Bys8_XuB.d.cts} +10 -9
  90. package/dist/{session-Baxp9iRK.d.ts → session-Bys8_XuB.d.ts} +10 -9
  91. package/dist/session-reconnect.d.cts +2 -2
  92. package/dist/session-reconnect.d.ts +2 -2
  93. package/dist/smart-dom-reader.d.cts +1 -1
  94. package/dist/smart-dom-reader.d.ts +1 -1
  95. package/dist/theme-editor-preview.cjs +31 -31
  96. package/dist/theme-editor-preview.d.cts +1 -1
  97. package/dist/theme-editor-preview.d.ts +1 -1
  98. package/dist/theme-editor-preview.js +19 -19
  99. package/dist/theme-editor.cjs +1 -1
  100. package/dist/theme-editor.d.cts +2 -2
  101. package/dist/theme-editor.d.ts +2 -2
  102. package/dist/theme-editor.js +1 -1
  103. package/dist/theme-reference.cjs +1 -1
  104. package/dist/theme-reference.d.cts +4 -2
  105. package/dist/theme-reference.d.ts +4 -2
  106. package/dist/theme-reference.js +1 -1
  107. package/dist/{tool-loading-animation-un3kA5Ur.d.cts → tool-loading-animation-d5qAQcwv.d.cts} +1 -1
  108. package/dist/{tool-loading-animation-un3kA5Ur.d.ts → tool-loading-animation-d5qAQcwv.d.ts} +1 -1
  109. package/dist/{turn-activity-ui-BGNhtMcH.d.cts → turn-activity-ui-q6fHVnx-.d.cts} +2 -2
  110. package/dist/{turn-activity-ui-BGNhtMcH.d.ts → turn-activity-ui-q6fHVnx-.d.ts} +2 -2
  111. package/dist/turn-activity-ui.d.cts +1 -1
  112. package/dist/turn-activity-ui.d.ts +1 -1
  113. package/dist/{types-BOZHQIk0.d.cts → types-B_mj21jg.d.cts} +91 -7
  114. package/dist/{types-BOZHQIk0.d.ts → types-B_mj21jg.d.ts} +91 -7
  115. package/dist/voice-client-tools.d.cts +1 -1
  116. package/dist/voice-client-tools.d.ts +1 -1
  117. package/dist/voice-runtime.cjs +1 -1
  118. package/dist/voice-runtime.d.cts +2 -2
  119. package/dist/voice-runtime.d.ts +2 -2
  120. package/dist/voice-runtime.js +1 -1
  121. package/dist/voice-worklet-player.d.cts +2 -2
  122. package/dist/voice-worklet-player.d.ts +2 -2
  123. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.cts → webmcp-runtime-entry-ClEO3qdI.d.cts} +1 -1
  124. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.ts → webmcp-runtime-entry-ClEO3qdI.d.ts} +1 -1
  125. package/dist/webmcp-runtime.d.cts +1 -1
  126. package/dist/webmcp-runtime.d.ts +1 -1
  127. package/dist/widget.css +1 -1
  128. package/package.json +1 -1
  129. package/src/client-dispatch.ts +69 -16
  130. package/src/client.operator-role.test.ts +217 -0
  131. package/src/components/message-bubble.test.ts +169 -0
  132. package/src/components/message-bubble.ts +97 -28
  133. package/src/defaults-parity.test.ts +14 -0
  134. package/src/index-core.ts +7 -0
  135. package/src/index-global.ts +2 -1
  136. package/src/install.test.ts +6 -5
  137. package/src/install.ts +2 -2
  138. package/src/launcher-global.ts +2 -1
  139. package/src/session.resubmit-from.test.ts +27 -0
  140. package/src/session.test.ts +51 -0
  141. package/src/session.ts +22 -12
  142. package/src/styles/widget.css +23 -1
  143. package/src/theme-reference.ts +3 -1
  144. package/src/types/theme.ts +21 -0
  145. package/src/types.ts +71 -6
  146. package/src/ui.ask-user-question-plugin.test.ts +154 -0
  147. package/src/ui.operator-role.test.ts +336 -0
  148. package/src/ui.ts +37 -12
  149. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +236 -0
  150. package/src/utils/brand.test.ts +14 -0
  151. package/src/utils/brand.ts +8 -4
  152. package/src/utils/cdn-release.test.ts +20 -0
  153. package/src/utils/cdn-release.ts +14 -0
  154. package/src/utils/history-messages.test.ts +30 -0
  155. package/src/utils/history-messages.ts +26 -8
  156. package/src/utils/message-fingerprint.ts +4 -0
  157. package/src/utils/resubmit-turn.test.ts +13 -0
  158. package/src/utils/resubmit-turn.ts +12 -1
  159. package/src/utils/tokens.ts +31 -0
  160. package/src/welcome.test.ts +16 -0
  161. package/src/welcome.ts +10 -2
  162. package/dist/chunk-BkOYJ11B.js +0 -1
  163. package/dist/chunk-Bz9jAgba.js +0 -1
  164. package/dist/chunk-CdpVP6LZ.js +0 -1
  165. package/dist/chunk-DM-OJt5q.js +0 -1
@@ -2,6 +2,7 @@ import { getLoadingLabelRuntimeSync } from "../loading-label-runtime-loader";
2
2
  import { createElement, createNode } from "../utils/dom";
3
3
  import {
4
4
  AgentWidgetMessage,
5
+ AgentWidgetMessageRole,
5
6
  AgentWidgetMessageLayoutConfig,
6
7
  AgentWidgetAvatarConfig,
7
8
  AgentWidgetTimestampConfig,
@@ -577,40 +578,47 @@ export const renderLoadingIndicatorWithFallback = (
577
578
  */
578
579
  const createAvatar = (
579
580
  avatarConfig: AgentWidgetAvatarConfig,
580
- role: "user" | "assistant"
581
+ role: AgentWidgetMessageRole
581
582
  ): HTMLElement => {
582
583
  const avatar = createElement(
583
584
  "div",
584
585
  "persona-flex-shrink-0 persona-w-8 persona-h-8 persona-rounded-full persona-flex persona-items-center persona-justify-center persona-text-sm"
585
586
  );
586
587
 
587
- const avatarContent = role === "user"
588
- ? avatarConfig.userAvatar
589
- : avatarConfig.assistantAvatar;
588
+ const isOperator = role === "operator";
589
+ const avatarContent = role === "user"
590
+ ? avatarConfig.userAvatar
591
+ : isOperator
592
+ ? avatarConfig.operatorAvatar
593
+ : avatarConfig.assistantAvatar;
594
+
595
+ // The operator avatar is a neutral outlined chip, apart from the solid
596
+ // user and assistant fills.
597
+ const applyFill = () => {
598
+ avatar.classList.add(
599
+ ...(isOperator
600
+ ? ["persona-bg-persona-container", "persona-text-persona-text", "persona-border"]
601
+ : [role === "user" ? "persona-bg-persona-accent" : "persona-bg-persona-primary", "persona-text-white"])
602
+ );
603
+ };
590
604
 
591
605
  if (avatarContent) {
592
606
  // Check if it's a URL or emoji/text
593
607
  if (avatarContent.startsWith("http") || avatarContent.startsWith("/") || avatarContent.startsWith("data:")) {
594
608
  const img = createElement("img") as HTMLImageElement;
595
609
  img.src = avatarContent;
596
- img.alt = role === "user" ? "User" : "Assistant";
610
+ img.alt = role === "user" ? "User" : isOperator ? "Operator" : "Assistant";
597
611
  img.className = "persona-w-full persona-h-full persona-rounded-full persona-object-cover";
598
612
  avatar.appendChild(img);
599
613
  } else {
600
614
  // Emoji or text
601
615
  avatar.textContent = avatarContent;
602
- avatar.classList.add(
603
- role === "user" ? "persona-bg-persona-accent" : "persona-bg-persona-primary",
604
- "persona-text-white"
605
- );
616
+ applyFill();
606
617
  }
607
618
  } else {
608
619
  // Default avatar
609
- avatar.textContent = role === "user" ? "U" : "A";
610
- avatar.classList.add(
611
- role === "user" ? "persona-bg-persona-accent" : "persona-bg-persona-primary",
612
- "persona-text-white"
613
- );
620
+ avatar.textContent = role === "user" ? "U" : isOperator ? "O" : "A";
621
+ applyFill();
614
622
  }
615
623
 
616
624
  return avatar;
@@ -644,13 +652,32 @@ const createTimestamp = (
644
652
  return timestamp;
645
653
  };
646
654
 
655
+ /** Assistant color utilities an operator bubble replaces with its own tokens. */
656
+ const OPERATOR_DROPPED_CLASSES = new Set([
657
+ "persona-bg-persona-container",
658
+ "persona-text-persona-text",
659
+ "persona-border-persona-message-border",
660
+ ]);
661
+
647
662
  /**
648
663
  * Get bubble classes based on layout preset
649
664
  */
650
665
  export const getBubbleClasses = (
651
- role: "user" | "assistant" | "system",
666
+ role: AgentWidgetMessageRole,
652
667
  layout: AgentWidgetMessageLayoutConfig["layout"] = "bubble"
653
668
  ): string[] => {
669
+ // Operator bubbles share the assistant's geometry per layout; only the role
670
+ // class changes, and the colors come from the operator tokens instead.
671
+ if (role === "operator") {
672
+ const classes = getBubbleClasses("assistant", layout).flatMap((name) =>
673
+ name === "persona-message-assistant-bubble"
674
+ ? ["persona-message-operator-bubble"]
675
+ : OPERATOR_DROPPED_CLASSES.has(name) ? [] : [name]
676
+ );
677
+ // Flat drops the assistant's fill, not the operator's: the operator keeps
678
+ // its background, so it needs the minimal layout's side padding.
679
+ return layout === "flat" ? [...classes, "persona-px-3"] : classes;
680
+ }
654
681
  const baseClasses = ["persona-message-bubble"];
655
682
 
656
683
  switch (layout) {
@@ -763,7 +790,7 @@ export const applyBubbleGeometry = (
763
790
  tokens?: MessageGeometryTokens
764
791
  ): void => {
765
792
  if (!tokens) return;
766
- const key = role === "user" ? "user" : "assistant";
793
+ const key = role === "user" || role === "operator" ? role : "assistant";
767
794
  const preset = BUBBLE_GEOMETRY_DEFAULTS[layout ?? "bubble"] ?? BUBBLE_GEOMETRY_DEFAULTS.bubble;
768
795
  if (tokens.padding) {
769
796
  bubble.style.padding = `var(--persona-message-${key}-padding, ${preset.padding})`;
@@ -787,13 +814,14 @@ export const customMessageActionDataAction = (id: string): string =>
787
814
 
788
815
  /**
789
816
  * Host-contributed actions this message's role qualifies for. Assistant-only by
790
- * default, matching every built-in except edit and quote.
817
+ * default, matching every built-in except edit and quote; an action reaches
818
+ * operator messages only when its `roles` lists `"operator"`.
791
819
  */
792
820
  export const customMessageActionsForRole = (
793
821
  actionsConfig: AgentWidgetMessageActionsConfig | undefined,
794
822
  role: AgentWidgetMessage["role"]
795
823
  ): AgentWidgetCustomMessageAction[] => {
796
- if (role !== "user" && role !== "assistant") return [];
824
+ if (role !== "user" && role !== "assistant" && role !== "operator") return [];
797
825
  return (actionsConfig?.custom ?? []).filter((action) =>
798
826
  (action.roles ?? ["assistant"]).includes(role)
799
827
  );
@@ -1000,21 +1028,33 @@ export const createStandardBubble = (
1000
1028
  bubble.id = `bubble-${message.id}`;
1001
1029
  bubble.setAttribute("data-message-id", message.id);
1002
1030
 
1003
- bubble.setAttribute("data-persona-theme-zone", message.role === "user" ? "user-message" : "assistant-message");
1031
+ const isOperator = message.role === "operator";
1032
+ // The V5 operator is outlined, not filled, so every layout needs the border
1033
+ // style that only the bubble layout's classes carry.
1034
+ if (isOperator && options?.widgetConfig?.future?.v5Defaults === true) bubble.classList.add("persona-border");
1035
+ bubble.setAttribute(
1036
+ "data-persona-theme-zone",
1037
+ message.role === "user" ? "user-message" : isOperator ? "operator-message" : "assistant-message"
1038
+ );
1004
1039
 
1005
1040
  const defaultMessageTokens = resolveThemeDefaults(options?.widgetConfig?.future).components.message;
1006
1041
  const messageTokens = options?.widgetConfig?.theme?.components?.message;
1042
+ const darkMessageTokens = options?.widgetConfig?.darkTheme?.components?.message;
1007
1043
  applyBubbleGeometry(
1008
1044
  bubble,
1009
1045
  message.role,
1010
1046
  layout,
1011
1047
  message.role === "user"
1012
- ? { ...defaultMessageTokens.user, ...messageTokens?.user, ...options?.widgetConfig?.darkTheme?.components?.message?.user }
1013
- : { ...defaultMessageTokens.assistant, ...messageTokens?.assistant, ...options?.widgetConfig?.darkTheme?.components?.message?.assistant }
1048
+ ? { ...defaultMessageTokens.user, ...messageTokens?.user, ...darkMessageTokens?.user }
1049
+ : isOperator
1050
+ ? { ...defaultMessageTokens.operator, ...messageTokens?.operator, ...darkMessageTokens?.operator }
1051
+ : { ...defaultMessageTokens.assistant, ...messageTokens?.assistant, ...darkMessageTokens?.assistant }
1014
1052
  );
1015
1053
 
1016
- // Apply component-level color overrides via CSS variables
1017
- if (layout === "flat") {
1054
+ // Apply component-level color overrides via CSS variables. Flat clears the
1055
+ // user and assistant fills; an operator keeps its own background token so it
1056
+ // stays apart from the assistant prose on the same side.
1057
+ if (layout === "flat" && !isOperator) {
1018
1058
  bubble.style.backgroundColor = "transparent";
1019
1059
  bubble.style.color =
1020
1060
  message.role === "user"
@@ -1026,6 +1066,23 @@ export const createStandardBubble = (
1026
1066
  } else if (message.role === "assistant") {
1027
1067
  bubble.style.backgroundColor = 'var(--persona-message-assistant-bg, var(--persona-container))';
1028
1068
  bubble.style.color = 'var(--persona-message-assistant-text, var(--persona-text))';
1069
+ } else if (isOperator) {
1070
+ bubble.style.backgroundColor = 'var(--persona-components-message-operator-background, var(--persona-container))';
1071
+ bubble.style.color = 'var(--persona-components-message-operator-text, var(--persona-text))';
1072
+ }
1073
+
1074
+ if (isOperator) {
1075
+ const authorLabel = createElement("div", "persona-message-author persona-text-xs persona-font-medium persona-mb-1");
1076
+ const configuredLabel = options?.widgetConfig?.copy?.operatorLabel;
1077
+ const operatorLabel = typeof configuredLabel === "string" && configuredLabel.trim()
1078
+ ? configuredLabel.trim() : "Human operator";
1079
+ const displayName = typeof message.author?.displayName === "string"
1080
+ ? message.author.displayName.trim() : "";
1081
+ authorLabel.textContent = displayName ? `${displayName} · ${operatorLabel}` : operatorLabel;
1082
+ // A visible name is also the accessible name for the human-authored group.
1083
+ bubble.setAttribute("role", "group");
1084
+ bubble.setAttribute("aria-label", authorLabel.textContent);
1085
+ bubble.appendChild(authorLabel);
1029
1086
  }
1030
1087
 
1031
1088
  const imageParts = getMessageImageParts(message);
@@ -1293,14 +1350,15 @@ export const createStandardBubble = (
1293
1350
  bubble.appendChild(createStopReasonNotice(message.stopReason, stopReasonNoticeText));
1294
1351
  }
1295
1352
 
1296
- // Add message actions for assistant messages (only when not streaming and has content)
1353
+ // Add message actions for assistant and operator messages (only when not
1354
+ // streaming and has content). Operator rows get copy and opted-in custom
1355
+ // actions only: feedback, read-aloud and regenerate are about model output.
1297
1356
  const shouldShowActions =
1298
- message.role === "assistant" &&
1357
+ (message.role === "assistant" || isOperator) &&
1299
1358
  !message.streaming &&
1300
1359
  // A voice caption (the voice model's own speech) is display-only.
1301
1360
  !message.voiceCaption &&
1302
- message.content &&
1303
- message.content.trim() &&
1361
+ (messageContentText.length > 0 || (isOperator && customMessageActionsForRole(actionsConfig, "operator").length > 0)) &&
1304
1362
  actionsConfig?.enabled !== false;
1305
1363
 
1306
1364
  // User bubbles carry actions only when this message is edit- or quote-eligible;
@@ -1321,7 +1379,18 @@ export const createStandardBubble = (
1321
1379
  shouldShowUserActions
1322
1380
  ? // A user bubble never hosts the assistant-only controls.
1323
1381
  { ...actionsConfig, showCopy: false, showUpvote: false, showDownvote: false, showReadAloud: false }
1324
- : actionsConfig,
1382
+ : isOperator
1383
+ ? {
1384
+ ...actionsConfig,
1385
+ showCopy: messageContentText.length > 0 && actionsConfig.showCopy !== false,
1386
+ showUpvote: false,
1387
+ showDownvote: false,
1388
+ showReadAloud: false,
1389
+ showRegenerate: false,
1390
+ showEdit: false,
1391
+ showQuote: false,
1392
+ }
1393
+ : actionsConfig,
1325
1394
  actionCallbacks,
1326
1395
  eligibility,
1327
1396
  options?.widgetConfig?.future?.v5Defaults === true && actionsConfig.visibility === undefined
@@ -160,6 +160,20 @@ describe("4.22.0 defaults parity (flag off)", () => {
160
160
  expect(actual.themeCssVariables[key], key).toBe(value);
161
161
  delete resolvedActual.themeCssVariables[key];
162
162
  }
163
+ // Operator bubbles only render for messages that use the operator role,
164
+ // so every token they emit is additive and none may exist in the baseline.
165
+ const additivePrefixes = [
166
+ "--persona-components-message-operator-",
167
+ "--persona-message-operator-",
168
+ ];
169
+ expect(actual.themeCssVariables["--persona-components-message-operator-background"]).toBe(
170
+ "color-mix(in srgb, var(--persona-primary) 6%, var(--persona-container))"
171
+ );
172
+ for (const key of Object.keys(resolvedActual.themeCssVariables)) {
173
+ if (!additivePrefixes.some((prefix) => key.startsWith(prefix))) continue;
174
+ expect(baseline.themeCssVariables).not.toHaveProperty(key);
175
+ delete resolvedActual.themeCssVariables[key];
176
+ }
163
177
  // Deliberately lengthen hover timing in both V4 and V5 without rewriting
164
178
  // the historical fixture. Keyboard focus and the scan window are unchanged.
165
179
  expect(baseline.mergedDefaultConfig.tooltip.delayMs).toBe(200);
package/src/index-core.ts CHANGED
@@ -5,6 +5,9 @@ import {
5
5
  } from "./runtime/init";
6
6
 
7
7
  export type {
8
+ AgentWidgetMessageAuthor,
9
+ AgentWidgetModelMessageTransform,
10
+ AgentWidgetModelMessageProjection,
8
11
  AgentWidgetDictationConfig,
9
12
  DictationProviderConfig,
10
13
  DictationPhase,
@@ -46,6 +49,8 @@ export type {
46
49
  ArtifactConfigPayload,
47
50
  AgentWidgetInitOptions,
48
51
  AgentWidgetMessage,
52
+ AgentWidgetMessageRole,
53
+ AgentWidgetModelMessageRole,
49
54
  AgentWidgetTurnActivity,
50
55
  AgentWidgetTurnActivityConfig,
51
56
  AgentWidgetLauncherConfig,
@@ -594,6 +599,8 @@ export type {
594
599
  SemanticSpacing,
595
600
  SemanticTypography,
596
601
  ComponentTokens,
602
+ MessageTokens,
603
+ MessageGeometryTokens,
597
604
  ComposerOverflowMenuTokens,
598
605
  TextStyleTokens,
599
606
  AttachmentTokens,
@@ -37,12 +37,13 @@ export type { StreamAnimationPlugin, StreamAnimationContext } from "./types";
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
39
  import { setWebMcpPolyfillLoader } from "./webmcp-bridge";
40
+ import { pinCdnRelease } from "./utils/cdn-release";
40
41
 
41
42
  // Capture at module-evaluation time: `document.currentScript` is null once
42
43
  // execution leaves the script's initial synchronous run.
43
44
  const widgetScriptSrc: string | null =
44
45
  typeof document !== "undefined"
45
- ? ((document.currentScript as HTMLScriptElement | null)?.src ?? null)
46
+ ? pinCdnRelease((document.currentScript as HTMLScriptElement | null)?.src ?? "") || null
46
47
  : null;
47
48
 
48
49
  /**
@@ -10,6 +10,7 @@
10
10
  // `vi.runAllTimersAsync()` to drive it forward deterministically.
11
11
 
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { version } from "../package.json";
13
14
 
14
15
  const PERSONA_EVENTS = [
15
16
  "persona:script-load",
@@ -464,11 +465,11 @@ describe("install.ts: CDN base derivation", () => {
464
465
  return { cssHref: css?.href ?? null, scripts };
465
466
  }
466
467
 
467
- it("derives asset URLs from the installer's own script directory (first-party CDN)", async () => {
468
+ it("pins first-party latest installer assets to the embedded release", async () => {
468
469
  setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
469
470
  const { cssHref, scripts } = await installedUrls({});
470
- expect(cssHref).toBe("https://cdn.runtype.com/persona/latest/widget.css");
471
- expect(scripts).toContain("https://cdn.runtype.com/persona/latest/launcher.global.js");
471
+ expect(cssHref).toBe(`https://cdn.runtype.com/persona/${version}/widget.css`);
472
+ expect(scripts).toContain(`https://cdn.runtype.com/persona/${version}/launcher.global.js`);
472
473
  });
473
474
 
474
475
  it("keeps npm-CDN siblings on the installer's own pinned range (no @latest skew)", async () => {
@@ -478,13 +479,13 @@ describe("install.ts: CDN base derivation", () => {
478
479
  expect(scripts).toContain("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/launcher.global.js");
479
480
  });
480
481
 
481
- it("the full bundle URL follows the installer directory on the eager path", async () => {
482
+ it("pins the full bundle on the eager first-party latest path", async () => {
482
483
  setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
483
484
  markCssLoaded();
484
485
  await install({ config: { apiUrl: "/api", launcher: { enabled: false } } });
485
486
  await flush();
486
487
  const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
487
- expect(scripts).toContain("https://cdn.runtype.com/persona/latest/index.global.js");
488
+ expect(scripts).toContain(`https://cdn.runtype.com/persona/${version}/index.global.js`);
488
489
  });
489
490
 
490
491
  it("falls back to jsDelivr@latest when currentScript is unavailable", async () => {
package/src/install.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * if configuration is provided via window.siteAgentConfig
5
5
  */
6
6
 
7
- export {};
7
+ import { pinCdnRelease } from "./utils/cdn-release";
8
8
 
9
9
  import type { BrandConfig } from "./types/theme";
10
10
  import { delayInstallLoading } from "./utils/install-loading";
@@ -201,7 +201,7 @@ declare global {
201
201
  const getInstallerBase = (): string | null => {
202
202
  if (!installerSrc) return null;
203
203
  try {
204
- const url = new URL(installerSrc, document.baseURI);
204
+ const url = new URL(pinCdnRelease(installerSrc), document.baseURI);
205
205
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
206
206
  return url.origin + url.pathname.slice(0, url.pathname.lastIndexOf("/"));
207
207
  } catch {
@@ -24,6 +24,7 @@ import { createLauncherSurface } from "./components/launcher";
24
24
  import { applyLauncherThemeVariables } from "./utils/theme";
25
25
  import { DEFAULT_LAUNCHER_CONFIG, DEFAULT_LAUNCHER_V5 } from "./defaults";
26
26
  import { setIconsExtraLoader } from "./icons-extra-loader";
27
+ import { pinCdnRelease } from "./utils/cdn-release";
27
28
  import type { AgentWidgetConfig } from "./types";
28
29
 
29
30
  // ---------------------------------------------------------------------------
@@ -33,7 +34,7 @@ import type { AgentWidgetConfig } from "./types";
33
34
  // ---------------------------------------------------------------------------
34
35
  const launcherScriptSrc: string | null =
35
36
  typeof document !== "undefined"
36
- ? ((document.currentScript as HTMLScriptElement | null)?.src ?? null)
37
+ ? pinCdnRelease((document.currentScript as HTMLScriptElement | null)?.src ?? "") || null
37
38
  : null;
38
39
 
39
40
  setIconsExtraLoader(() => {
@@ -327,3 +327,30 @@ describe("resubmitFrom retry payload fidelity per transport", () => {
327
327
  expect(bodies[0].submitMode).toBeUndefined();
328
328
  });
329
329
  });
330
+
331
+ describe("human operator resubmission boundary", () => {
332
+ beforeEach(() => {
333
+ vi.restoreAllMocks();
334
+ global.fetch = vi.fn(async () => streamResponse()) as unknown as typeof fetch;
335
+ });
336
+ it.each(["retry", "edit"] as const)("refuses %s before a human contribution without cancelling or truncating", reason => {
337
+ const { session, emits } = makeSession();
338
+ seedTurn(session);
339
+ session.injectMessage({ id: "human", role: "operator", content: "Refund confirmed", author: { id: "sam", displayName: "Sam", kind: "human" } });
340
+ const snapshot = session.getMessages();
341
+ const cancel = vi.spyOn(session, "cancel");
342
+ emits.length = 0;
343
+ expect(session.resubmitFrom("u1", { reason, ...(reason === "edit" && { replacement: { text: "Changed", mentionRefs: [], options: {} } }) })).toBe(false);
344
+ expect(session.getMessages()).toEqual(snapshot);
345
+ expect(cancel).not.toHaveBeenCalled();
346
+ expect(emits).toHaveLength(0);
347
+ });
348
+
349
+ it("allows a fresh user turn after the human contribution", () => {
350
+ const { session } = makeSession();
351
+ session.injectMessage({ id: "human", role: "operator", content: "Refund confirmed" });
352
+ session.injectUserMessage({ id: "after", content: "When will it arrive?" });
353
+ expect(session.resubmitFrom("after", { reason: "retry" })).toBe(true);
354
+ expect(session.getMessages().some(message => message.id === "human")).toBe(true);
355
+ });
356
+ });
@@ -182,6 +182,16 @@ describe('AgentWidgetSession - Message Injection', () => {
182
182
  });
183
183
  });
184
184
 
185
+ describe('operator role', () => {
186
+ it('injects an operator message with its own id prefix', () => {
187
+ const result = session.injectMessage({ role: 'operator', content: 'Sam from support here.' });
188
+
189
+ expect(result.role).toBe('operator');
190
+ expect(result.id).toMatch(/^operator-/);
191
+ expect(messages.map((message) => message.role)).toEqual(['operator']);
192
+ });
193
+ });
194
+
185
195
  describe('message ordering', () => {
186
196
  it('should maintain chronological order', () => {
187
197
  session.injectMessage({
@@ -1070,6 +1080,47 @@ describe('AgentWidgetSession - dispatch error fallback', () => {
1070
1080
  expect(session.getStatus()).toBe('idle');
1071
1081
  });
1072
1082
 
1083
+ it('does not blame the backend for an internal stream TypeError after a successful response', async () => {
1084
+ const response = new Response('data: {}\n\n', { headers: { 'Content-Type': 'text/event-stream' } });
1085
+ const fault = new TypeError('t.streamGeneration is not a function');
1086
+ vi.spyOn(response.body!, 'getReader').mockImplementation(() => { throw fault; });
1087
+ global.fetch = vi.fn().mockResolvedValue(response);
1088
+ const errors: Error[] = [];
1089
+ const session = new AgentWidgetSession({ apiUrl: 'http://example.invalid/chat' }, {
1090
+ onMessagesChanged: () => {}, onStatusChanged: () => {}, onStreamingChanged: () => {},
1091
+ onError: error => errors.push(error),
1092
+ });
1093
+ await session.sendMessage('Hello');
1094
+ const content = lastAssistantMessage(session)?.content;
1095
+ expect(content).toContain('the chat encountered an unexpected error');
1096
+ expect(content).toContain('t.streamGeneration is not a function');
1097
+ expect(content).not.toContain('backend');
1098
+ expect(content).not.toContain("couldn't reach");
1099
+ expect(errors).toContain(fault);
1100
+ expect(session.isStreaming()).toBe(false);
1101
+ });
1102
+
1103
+ it.each(['Failed to fetch', 'Load failed', 'NetworkError when attempting to fetch resource.', 'fetch failed'])(
1104
+ 'recognizes the browser fetch TypeError: %s', async (message) => {
1105
+ failFetchWith(new TypeError(message));
1106
+ const session = new AgentWidgetSession({ apiUrl: 'http://example.invalid/chat' }, {
1107
+ onMessagesChanged: () => {}, onStatusChanged: () => {}, onStreamingChanged: () => {},
1108
+ });
1109
+ await session.sendMessage('Hello');
1110
+ expect(lastAssistantMessage(session)?.content).toContain("I couldn't reach the assistant");
1111
+ },
1112
+ );
1113
+
1114
+ it('describes a server rejection without claiming the server was unreachable', async () => {
1115
+ global.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }));
1116
+ const session = new AgentWidgetSession({ apiUrl: 'http://example.invalid/chat' }, {
1117
+ onMessagesChanged: () => {}, onStatusChanged: () => {}, onStreamingChanged: () => {},
1118
+ });
1119
+ await session.sendMessage('Hello');
1120
+ expect(lastAssistantMessage(session)?.content).toContain("chat service couldn't complete the request");
1121
+ expect(lastAssistantMessage(session)?.content).toContain('_Details: Chat backend request failed: 503_');
1122
+ });
1123
+
1073
1124
  it('uses a static errorMessage override verbatim', async () => {
1074
1125
  failFetchWith(new Error('boom'));
1075
1126
  const session = new AgentWidgetSession(
package/src/session.ts CHANGED
@@ -68,7 +68,7 @@ import type {
68
68
  ComposerResubmitOptions
69
69
  } from "./types";
70
70
  import { applyQuoteToContent } from "./utils/composer-quote";
71
- import { findUserTurnBoundary } from "./utils/resubmit-turn";
71
+ import { canResubmitUserMessage, findUserTurnBoundary } from "./utils/resubmit-turn";
72
72
  import { extractComponentDirectiveFromMessage } from "./utils/component-middleware";
73
73
  import type { MentionSubmitBundle } from "./utils/context-mention-manager";
74
74
  import type { VisitorStore, VisitorStoreChange } from "./utils/visitor-store";
@@ -156,6 +156,7 @@ const CONNECTION_CONFIG_KEYS = [
156
156
  "parserType",
157
157
  "contextProviders",
158
158
  "requestMiddleware",
159
+ "modelMessageTransform",
159
160
  "customFetch",
160
161
  "backgroundConversations",
161
162
  "parseSSEEvent",
@@ -265,10 +266,9 @@ export class SessionHistoryError extends Error {
265
266
  }
266
267
 
267
268
  /**
268
- * Build the user-facing detail shown when a dispatch fails. This fires on real network/server errors
269
- * (connection refused, CORS, 4xx/5xx, malformed stream): not just an
270
- * un-wired proxy, so the copy stays honest about the failure and surfaces the
271
- * underlying reason to help with debugging.
269
+ * Dispatch can fail in transport, stream parsing, lazy modules or application
270
+ * callbacks. Only recognized fetch failures should suggest connectivity;
271
+ * unknown/internal failures must not send users to debug a healthy backend.
272
272
  *
273
273
  * Callers can override the copy via `config.errorMessage` (a static string or
274
274
  * a function of the error). An override that returns an empty string yields ""
@@ -283,9 +283,17 @@ function buildDispatchErrorContent(
283
283
  if (typeof override === "string") return override;
284
284
  if (typeof override === "function") return override(err);
285
285
 
286
- const base =
287
- "Sorry: I couldn't reach the assistant. The chat service didn't respond. Please check that your proxy or backend is running and reachable, then try again.";
288
- return err.message ? `${base}\n\n_Details: ${err.message}_` : base;
286
+ // Fetch rejects with TypeError too, so the error class cannot distinguish
287
+ // an offline browser from an internal fault such as a missing function.
288
+ const detail = err.message.trim();
289
+ const networkFailure = /^(Failed to fetch|fetch failed|Load failed|NetworkError when attempting to fetch resource\.?)$/i.test(detail);
290
+ const backendFailure = /^(Chat backend|Agent execution) request failed: \d{3}\b/.test(detail);
291
+ const base = networkFailure
292
+ ? "Sorry: I couldn't reach the assistant. Please check your connection and try again."
293
+ : backendFailure
294
+ ? "Sorry: the chat service couldn't complete the request. Please try again."
295
+ : "Sorry: the chat encountered an unexpected error. Please reload the page and try again.";
296
+ return detail ? `${base}\n\n_Details: ${detail}_` : base;
289
297
  }
290
298
 
291
299
  /**
@@ -2493,7 +2501,7 @@ export class AgentWidgetSession {
2493
2501
  ? generateUserMessageId()
2494
2502
  : role === "assistant"
2495
2503
  ? generateAssistantMessageId()
2496
- : `system-${Date.now()}-${Math.random().toString(16).slice(2)}`);
2504
+ : `${role === "operator" ? "operator" : "system"}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
2497
2505
 
2498
2506
  return {
2499
2507
  id: messageId,
@@ -2508,7 +2516,8 @@ export class AgentWidgetSession {
2508
2516
  ...(voiceProcessing !== undefined && { voiceProcessing }),
2509
2517
  ...(voiceCaption && { voiceCaption }),
2510
2518
  ...(rawContent !== undefined && { rawContent }),
2511
- ...(options.dispatchError !== undefined && { dispatchError: options.dispatchError })
2519
+ ...(options.dispatchError !== undefined && { dispatchError: options.dispatchError }),
2520
+ ...(options.author !== undefined && { author: { ...options.author } })
2512
2521
  };
2513
2522
 
2514
2523
  }
@@ -3008,14 +3017,15 @@ export class AgentWidgetSession {
3008
3017
  * the new attempt gets fresh message ids.
3009
3018
  *
3010
3019
  * Returns false when the id is unknown, is not a user message, or the rebuilt
3011
- * turn would carry no content; nothing is truncated in that case.
3020
+ * turn would carry no content, or truncation would remove an operator reply;
3021
+ * nothing is truncated in those cases.
3012
3022
  */
3013
3023
  public resubmitFrom(
3014
3024
  messageId: string,
3015
3025
  options: ComposerResubmitOptions
3016
3026
  ): boolean {
3017
3027
  const boundary = findUserTurnBoundary(this.messages, messageId);
3018
- if (!boundary) return false;
3028
+ if (!boundary || !canResubmitUserMessage(this.messages, messageId)) return false;
3019
3029
 
3020
3030
  // Operate on the STORED message, never a caller-held object: appendMessage
3021
3031
  // keeps a copy (see `ensureSequence`), so the array entry is the only ref
@@ -2171,6 +2171,13 @@
2171
2171
  );
2172
2172
  }
2173
2173
 
2174
+ .persona-message-row[data-message-role="operator"] > * {
2175
+ max-width: var(
2176
+ --persona-message-row-max-width,
2177
+ var(--persona-message-operator-max-width, var(--persona-message-assistant-max-width, 85%))
2178
+ );
2179
+ }
2180
+
2174
2181
  .persona-message-row[data-message-width="content"] > * {
2175
2182
  flex: 0 1 auto;
2176
2183
  }
@@ -3086,6 +3093,18 @@
3086
3093
  box-shadow: var(--persona-message-assistant-shadow, 0 1px 2px 0 rgb(0 0 0 / 0.05));
3087
3094
  }
3088
3095
 
3096
+ /* Operator bubble: a human on the agent's side (role "operator"). Renders on
3097
+ the assistant's side; V4 tints the container with the primary, V5 outlines it. */
3098
+ .persona-message-operator-bubble {
3099
+ border-radius: var(--persona-components-message-operator-borderRadius, var(--persona-radius-lg, 0.5rem));
3100
+ border-color: var(--persona-components-message-operator-border, var(--persona-border, #e5e7eb));
3101
+ border-width: var(--persona-components-message-operator-borderWidth, 1px);
3102
+ }
3103
+
3104
+ [data-persona-root] .persona-message-operator-bubble.persona-shadow-sm {
3105
+ box-shadow: var(--persona-components-message-operator-shadow, 0 1px 2px 0 rgb(0 0 0 / 0.05));
3106
+ }
3107
+
3089
3108
  [data-persona-root] .persona-message-user-bubble.persona-shadow-sm {
3090
3109
  box-shadow: var(--persona-message-user-shadow);
3091
3110
  }
@@ -3244,7 +3263,8 @@
3244
3263
  text-decoration: underline;
3245
3264
  }
3246
3265
 
3247
- [data-persona-root] .persona-message-assistant-bubble a {
3266
+ [data-persona-root] .persona-message-assistant-bubble a,
3267
+ [data-persona-root] .persona-message-operator-bubble a {
3248
3268
  color: var(--persona-md-link-color, var(--persona-accent, #0f0f0f));
3249
3269
  }
3250
3270
 
@@ -3329,6 +3349,8 @@
3329
3349
 
3330
3350
  .persona-message-assistant-bubble p,
3331
3351
  .persona-message-assistant-bubble li,
3352
+ .persona-message-operator-bubble p,
3353
+ .persona-message-operator-bubble li,
3332
3354
  .persona-error-detail p,
3333
3355
  .persona-error-detail li {
3334
3356
  color: inherit;
@@ -144,6 +144,8 @@ export const THEME_TOKEN_DOCS = {
144
144
  user: 'background, text, borderRadius, shadow.',
145
145
  assistant:
146
146
  'background, text, borderRadius, border (optional), shadow (optional).',
147
+ operator:
148
+ 'Human operator bubble (role "operator", e.g. a support takeover), on the assistant side. background (default: container tinted 6% with the primary), text (semantic.colors.text), borderRadius, border (container tinted 20% with the primary), borderWidth (1px), shadow, plus padding, maxWidth, fontSize, fontFamily, lineHeight. V5 outlines it instead: transparent background, semantic.colors.border border.',
147
149
  },
148
150
  markdown: {
149
151
  description: 'Markdown rendering in messages and artifact pane.',
@@ -325,7 +327,7 @@ export const THEME_TOKEN_DOCS = {
325
327
  header:
326
328
  '"default" | "minimal". Options: showIcon, showTitle, showSubtitle, showCloseButton, showClearChat.',
327
329
  messages:
328
- '"bubble" | "flat" | "minimal". Options: groupConsecutive, avatar (show, position, userAvatar, assistantAvatar), timestamp (show, position).',
330
+ '"bubble" | "flat" | "minimal". Options: groupConsecutive, avatar (show, position, userAvatar, assistantAvatar, operatorAvatar), timestamp (show, position, format).',
329
331
  },
330
332
  statusIndicator: {
331
333
  description: 'Status text shown below the composer.',