@runtypelabs/persona 4.32.2 → 4.34.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 (215) hide show
  1. package/dist/activity-ui.d.cts +3 -15
  2. package/dist/activity-ui.d.ts +3 -15
  3. package/dist/animations/glyph-cycle.d.cts +1 -3
  4. package/dist/animations/glyph-cycle.d.ts +1 -3
  5. package/dist/animations/wipe.d.cts +1 -3
  6. package/dist/animations/wipe.d.ts +1 -3
  7. package/dist/approval-ui.cjs +2 -2
  8. package/dist/approval-ui.d.cts +3 -15
  9. package/dist/approval-ui.d.ts +3 -15
  10. package/dist/approval-ui.js +1 -1
  11. package/dist/artifacts-ui.cjs +1 -1
  12. package/dist/artifacts-ui.d.cts +3 -21
  13. package/dist/artifacts-ui.d.ts +3 -21
  14. package/dist/background-runtime.d.cts +2 -3
  15. package/dist/background-runtime.d.ts +2 -3
  16. package/dist/{chunk-BbXc58OA.js → chunk-6CUrH_jm.js} +1 -1
  17. package/dist/{chunk-B_H0l3u2.js → chunk-BRM3bHcJ.js} +1 -1
  18. package/dist/{chunk-DNdeIFJ6.js → chunk-Brdx4NCz.js} +1 -1
  19. package/dist/chunk-C-rux8Q9.js +1 -0
  20. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  21. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  22. package/dist/chunk-CEPM3FOp.js +1 -0
  23. package/dist/{chunk-BpjtKnGI.js → chunk-CV8EPIhT.js} +1 -1
  24. package/dist/{chunk-DVZ8ri1z.js → chunk-CWzHNuVK.js} +1 -1
  25. package/dist/{chunk-B3l7TOOv.js → chunk-CYpt7PsO.js} +1 -1
  26. package/dist/{chunk-2FnmZ9AA.js → chunk-Csr59G5V.js} +1 -1
  27. package/dist/{chunk-B9Zu-wrk.js → chunk-CxNPSY-b.js} +1 -1
  28. package/dist/{chunk-UWMfWW2a.js → chunk-D1G8tgYc.js} +1 -1
  29. package/dist/{chunk-DTGvDx8o.js → chunk-Dj2a9f9w.js} +1 -1
  30. package/dist/chunk-Dqa_GOcj.js +1 -0
  31. package/dist/chunk-DsWv3EGF.js +1 -0
  32. package/dist/{chunk-C7-m0EmI.js → chunk-NUNdcnAZ.js} +1 -1
  33. package/dist/{chunk-D0tcIZJM.js → chunk-YMkMvAsa.js} +2 -2
  34. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  35. package/dist/chunk-i4qGOJAL.js +1 -0
  36. package/dist/chunk-kizhspe5.js +1 -0
  37. package/dist/client-dispatch.js +1 -1
  38. package/dist/client-history.js +1 -1
  39. package/dist/client-stream.js +1 -1
  40. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  41. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  42. package/dist/codegen.cjs +1 -1
  43. package/dist/codegen.d.cts +1 -1
  44. package/dist/codegen.d.ts +1 -1
  45. package/dist/codegen.js +1 -1
  46. package/dist/composer-actions-runtime.cjs +1 -1
  47. package/dist/composer-actions-runtime.d.cts +4 -20
  48. package/dist/composer-actions-runtime.d.ts +4 -20
  49. package/dist/composer-actions-runtime.js +1 -1
  50. package/dist/{composer-input-dwg_o9Z-.d.ts → composer-input-i_FiUp6J.d.cts} +1 -7
  51. package/dist/{composer-input-dwg_o9Z-.d.cts → composer-input-i_FiUp6J.d.ts} +1 -7
  52. package/dist/{composer-store-Dw7Zbqxr.d.ts → composer-store-DhGNgG7Z.d.cts} +1 -3
  53. package/dist/{composer-store-Dw7Zbqxr.d.cts → composer-store-DhGNgG7Z.d.ts} +1 -3
  54. package/dist/config-diagnostics.d.cts +0 -2
  55. package/dist/config-diagnostics.d.ts +0 -2
  56. package/dist/{context-mention-manager-D2p0-JH-.d.ts → context-mention-manager-DL-xwGfL.d.cts} +2 -6
  57. package/dist/{context-mention-manager-D2p0-JH-.d.cts → context-mention-manager-DL-xwGfL.d.ts} +2 -6
  58. package/dist/context-mentions-inline.cjs +1 -1
  59. package/dist/context-mentions-inline.d.cts +2 -4
  60. package/dist/context-mentions-inline.d.ts +2 -4
  61. package/dist/context-mentions-inline.js +1 -1
  62. package/dist/context-mentions.cjs +1 -1
  63. package/dist/context-mentions.d.cts +3 -9
  64. package/dist/context-mentions.d.ts +3 -9
  65. package/dist/{defaults-CjX0kKzb.d.ts → defaults-Bga2Xrsp.d.cts} +2 -5
  66. package/dist/{defaults-CjX0kKzb.d.cts → defaults-Bga2Xrsp.d.ts} +2 -5
  67. package/dist/dictation-runtime.cjs +1 -1
  68. package/dist/dictation-runtime.d.cts +4 -13
  69. package/dist/dictation-runtime.d.ts +4 -13
  70. package/dist/dictation-runtime.js +1 -1
  71. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.cts} +0 -2
  72. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.ts} +0 -2
  73. package/dist/event-stream-view.cjs +1 -1
  74. package/dist/event-stream-view.d.cts +3 -16
  75. package/dist/event-stream-view.d.ts +3 -16
  76. package/dist/{fallback-speech-engine-DmF7MczI.d.ts → fallback-speech-engine-kLdyPcmk.d.cts} +1 -3
  77. package/dist/{fallback-speech-engine-DmF7MczI.d.cts → fallback-speech-engine-kLdyPcmk.d.ts} +1 -3
  78. package/dist/{feature-preferences-CVFDt9Xf.d.ts → feature-preferences-CmwZGKrf.d.cts} +1 -5
  79. package/dist/{feature-preferences-CVFDt9Xf.d.cts → feature-preferences-CmwZGKrf.d.ts} +1 -5
  80. package/dist/forms-ui.d.cts +2 -4
  81. package/dist/forms-ui.d.ts +2 -4
  82. package/dist/{history-confirm-dialog-DJEXsTyE.d.cts → history-confirm-dialog-i46-lPN_.d.cts} +2 -14
  83. package/dist/{history-confirm-dialog-DJEXsTyE.d.ts → history-confirm-dialog-i46-lPN_.d.ts} +2 -14
  84. package/dist/history-shell.cjs +1 -1
  85. package/dist/history-shell.d.cts +8 -27
  86. package/dist/history-shell.d.ts +8 -27
  87. package/dist/history-shell.js +1 -1
  88. package/dist/history-view.d.cts +1 -1
  89. package/dist/history-view.d.ts +1 -1
  90. package/dist/icons-extra.cjs +1 -1
  91. package/dist/icons-extra.d.cts +0 -2
  92. package/dist/icons-extra.d.ts +0 -2
  93. package/dist/index.cjs +44 -44
  94. package/dist/index.cjs.map +1 -1
  95. package/dist/index.d.cts +11 -11
  96. package/dist/index.d.ts +11 -11
  97. package/dist/index.global.js +1 -1
  98. package/dist/index.global.js.map +1 -1
  99. package/dist/index.js +46 -46
  100. package/dist/index.js.map +1 -1
  101. package/dist/install.global.js +1 -1
  102. package/dist/install.global.js.map +1 -1
  103. package/dist/launcher.global.js +2 -2
  104. package/dist/launcher.global.js.map +1 -1
  105. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.ts → loading-label-runtime-loader-Ccr3ETd3.d.cts} +1 -3
  106. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.cts → loading-label-runtime-loader-Ccr3ETd3.d.ts} +1 -3
  107. package/dist/loading-label-runtime.cjs +1 -1
  108. package/dist/loading-label-runtime.d.cts +1 -1
  109. package/dist/loading-label-runtime.d.ts +1 -1
  110. package/dist/loading-label-runtime.js +1 -1
  111. package/dist/markdown-parsers.js +1 -1
  112. package/dist/{message-bubble-CkZGlCLC.d.ts → message-bubble-C6AVaH7f.d.cts} +21 -4
  113. package/dist/{message-bubble-CkZGlCLC.d.cts → message-bubble-C6AVaH7f.d.ts} +21 -4
  114. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-BVggqE3C.d.cts} +0 -2
  115. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-BVggqE3C.d.ts} +0 -2
  116. package/dist/plugin-kit.d.cts +1 -1
  117. package/dist/plugin-kit.d.ts +1 -1
  118. package/dist/preferences-runtime.cjs +1 -1
  119. package/dist/preferences-runtime.d.cts +1 -1
  120. package/dist/preferences-runtime.d.ts +1 -1
  121. package/dist/preferences-runtime.js +1 -1
  122. package/dist/{preview-DdUvoLb4.d.cts → preview-BMdRffo4.d.cts} +2 -6
  123. package/dist/{preview-DdUvoLb4.d.ts → preview-BMdRffo4.d.ts} +2 -6
  124. package/dist/{registry-Dz4iHNST.d.ts → registry-B16l8s1b.d.cts} +1 -8
  125. package/dist/{registry-Dz4iHNST.d.cts → registry-B16l8s1b.d.ts} +1 -8
  126. package/dist/{session-Baxp9iRK.d.cts → session-CtZHe7fc.d.cts} +10 -93
  127. package/dist/{session-Baxp9iRK.d.ts → session-CtZHe7fc.d.ts} +10 -93
  128. package/dist/session-reconnect.d.cts +2 -4
  129. package/dist/session-reconnect.d.ts +2 -4
  130. package/dist/smart-dom-reader.d.cts +2 -8
  131. package/dist/smart-dom-reader.d.ts +2 -8
  132. package/dist/testing.d.cts +0 -2
  133. package/dist/testing.d.ts +0 -2
  134. package/dist/theme-editor-preview.cjs +42 -42
  135. package/dist/theme-editor-preview.d.cts +1 -1
  136. package/dist/theme-editor-preview.d.ts +1 -1
  137. package/dist/theme-editor-preview.js +25 -25
  138. package/dist/theme-editor.cjs +25 -5
  139. package/dist/theme-editor.d.cts +2 -22
  140. package/dist/theme-editor.d.ts +2 -22
  141. package/dist/theme-editor.js +1 -1
  142. package/dist/theme-reference.cjs +1 -1
  143. package/dist/theme-reference.d.cts +4 -4
  144. package/dist/theme-reference.d.ts +4 -4
  145. package/dist/theme-reference.js +1 -1
  146. package/dist/{tool-loading-animation-un3kA5Ur.d.ts → tool-loading-animation-D2cyFvmM.d.cts} +1 -7
  147. package/dist/{tool-loading-animation-un3kA5Ur.d.cts → tool-loading-animation-D2cyFvmM.d.ts} +1 -7
  148. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  149. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  150. package/dist/{turn-activity-ui-BGNhtMcH.d.cts → turn-activity-ui-Do1Nd-Ow.d.cts} +2 -12
  151. package/dist/{turn-activity-ui-BGNhtMcH.d.ts → turn-activity-ui-Do1Nd-Ow.d.ts} +2 -12
  152. package/dist/turn-activity-ui.d.cts +1 -1
  153. package/dist/turn-activity-ui.d.ts +1 -1
  154. package/dist/{types-BOZHQIk0.d.cts → types-BC7MRxO5.d.cts} +167 -36
  155. package/dist/{types-BOZHQIk0.d.ts → types-BC7MRxO5.d.ts} +167 -36
  156. package/dist/ui-extras.js +3 -3
  157. package/dist/voice-client-tools.d.cts +1 -3
  158. package/dist/voice-client-tools.d.ts +1 -3
  159. package/dist/voice-runtime.cjs +1 -1
  160. package/dist/voice-runtime.d.cts +2 -12
  161. package/dist/voice-runtime.d.ts +2 -12
  162. package/dist/voice-runtime.js +2 -2
  163. package/dist/voice-worklet-player.d.cts +2 -6
  164. package/dist/voice-worklet-player.d.ts +2 -6
  165. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.ts → webmcp-runtime-entry-B8g46vS0.d.cts} +1 -5
  166. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.cts → webmcp-runtime-entry-B8g46vS0.d.ts} +1 -5
  167. package/dist/webmcp-runtime.d.cts +1 -1
  168. package/dist/webmcp-runtime.d.ts +1 -1
  169. package/dist/widget.css +1 -1
  170. package/package.json +4 -4
  171. package/src/client-dispatch.ts +69 -16
  172. package/src/client.operator-role.test.ts +217 -0
  173. package/src/components/event-stream-view.test.ts +5 -0
  174. package/src/components/message-bubble.test.ts +169 -0
  175. package/src/components/message-bubble.ts +123 -34
  176. package/src/components/scroll-to-bottom.test.ts +135 -0
  177. package/src/components/scroll-to-bottom.ts +50 -13
  178. package/src/components/user-message-collapse.ts +117 -0
  179. package/src/defaults-parity.test.ts +14 -0
  180. package/src/defaults.ts +6 -0
  181. package/src/index-core.ts +7 -0
  182. package/src/session.resubmit-from.test.ts +27 -0
  183. package/src/session.test.ts +10 -0
  184. package/src/session.ts +8 -5
  185. package/src/styles/widget.css +79 -1
  186. package/src/theme-reference.ts +5 -3
  187. package/src/types/theme.ts +50 -0
  188. package/src/types.ts +120 -11
  189. package/src/ui.ask-user-question-plugin.test.ts +154 -0
  190. package/src/ui.operator-role.test.ts +336 -0
  191. package/src/ui.scroll-additive.test.ts +219 -117
  192. package/src/ui.scroll.test.ts +176 -13
  193. package/src/ui.ts +267 -119
  194. package/src/ui.turn-activity.test.ts +7 -7
  195. package/src/ui.user-message-collapse.test.ts +540 -0
  196. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +236 -0
  197. package/src/utils/auto-follow.test.ts +0 -38
  198. package/src/utils/auto-follow.ts +0 -35
  199. package/src/utils/brand.test.ts +14 -0
  200. package/src/utils/brand.ts +8 -4
  201. package/src/utils/history-messages.test.ts +30 -0
  202. package/src/utils/history-messages.ts +26 -8
  203. package/src/utils/message-fingerprint.ts +4 -0
  204. package/src/utils/resubmit-turn.test.ts +13 -0
  205. package/src/utils/resubmit-turn.ts +12 -1
  206. package/src/utils/theme.test.ts +23 -0
  207. package/src/utils/tokens.ts +47 -0
  208. package/src/welcome.test.ts +16 -0
  209. package/src/welcome.ts +10 -2
  210. package/dist/chunk-BNE40cEG.js +0 -1
  211. package/dist/chunk-Bz9jAgba.js +0 -1
  212. package/dist/chunk-CVMIncEY.js +0 -1
  213. package/dist/chunk-CdpVP6LZ.js +0 -1
  214. package/dist/chunk-CjkechxR.js +0 -1
  215. package/dist/chunk-DgZZqy14.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,
@@ -24,6 +25,11 @@ import { createMentionTokenElement } from "../utils/mention-token";
24
25
  import { createIconButton } from "../utils/buttons";
25
26
  import { renderLucideIcon } from "../utils/icons";
26
27
  import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
28
+ import {
29
+ applyUserCollapseState,
30
+ createUserCollapseRegion,
31
+ type UserMessageCollapseRender,
32
+ } from "./user-message-collapse";
27
33
  import {
28
34
  applyStreamBuffer,
29
35
  createSkeletonPlaceholder,
@@ -577,40 +583,47 @@ export const renderLoadingIndicatorWithFallback = (
577
583
  */
578
584
  const createAvatar = (
579
585
  avatarConfig: AgentWidgetAvatarConfig,
580
- role: "user" | "assistant"
586
+ role: AgentWidgetMessageRole
581
587
  ): HTMLElement => {
582
588
  const avatar = createElement(
583
589
  "div",
584
590
  "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
591
  );
586
592
 
587
- const avatarContent = role === "user"
588
- ? avatarConfig.userAvatar
589
- : avatarConfig.assistantAvatar;
593
+ const isOperator = role === "operator";
594
+ const avatarContent = role === "user"
595
+ ? avatarConfig.userAvatar
596
+ : isOperator
597
+ ? avatarConfig.operatorAvatar
598
+ : avatarConfig.assistantAvatar;
599
+
600
+ // The operator avatar is a neutral outlined chip, apart from the solid
601
+ // user and assistant fills.
602
+ const applyFill = () => {
603
+ avatar.classList.add(
604
+ ...(isOperator
605
+ ? ["persona-bg-persona-container", "persona-text-persona-text", "persona-border"]
606
+ : [role === "user" ? "persona-bg-persona-accent" : "persona-bg-persona-primary", "persona-text-white"])
607
+ );
608
+ };
590
609
 
591
610
  if (avatarContent) {
592
611
  // Check if it's a URL or emoji/text
593
612
  if (avatarContent.startsWith("http") || avatarContent.startsWith("/") || avatarContent.startsWith("data:")) {
594
613
  const img = createElement("img") as HTMLImageElement;
595
614
  img.src = avatarContent;
596
- img.alt = role === "user" ? "User" : "Assistant";
615
+ img.alt = role === "user" ? "User" : isOperator ? "Operator" : "Assistant";
597
616
  img.className = "persona-w-full persona-h-full persona-rounded-full persona-object-cover";
598
617
  avatar.appendChild(img);
599
618
  } else {
600
619
  // Emoji or text
601
620
  avatar.textContent = avatarContent;
602
- avatar.classList.add(
603
- role === "user" ? "persona-bg-persona-accent" : "persona-bg-persona-primary",
604
- "persona-text-white"
605
- );
621
+ applyFill();
606
622
  }
607
623
  } else {
608
624
  // 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
- );
625
+ avatar.textContent = role === "user" ? "U" : isOperator ? "O" : "A";
626
+ applyFill();
614
627
  }
615
628
 
616
629
  return avatar;
@@ -644,13 +657,32 @@ const createTimestamp = (
644
657
  return timestamp;
645
658
  };
646
659
 
660
+ /** Assistant color utilities an operator bubble replaces with its own tokens. */
661
+ const OPERATOR_DROPPED_CLASSES = new Set([
662
+ "persona-bg-persona-container",
663
+ "persona-text-persona-text",
664
+ "persona-border-persona-message-border",
665
+ ]);
666
+
647
667
  /**
648
668
  * Get bubble classes based on layout preset
649
669
  */
650
670
  export const getBubbleClasses = (
651
- role: "user" | "assistant" | "system",
671
+ role: AgentWidgetMessageRole,
652
672
  layout: AgentWidgetMessageLayoutConfig["layout"] = "bubble"
653
673
  ): string[] => {
674
+ // Operator bubbles share the assistant's geometry per layout; only the role
675
+ // class changes, and the colors come from the operator tokens instead.
676
+ if (role === "operator") {
677
+ const classes = getBubbleClasses("assistant", layout).flatMap((name) =>
678
+ name === "persona-message-assistant-bubble"
679
+ ? ["persona-message-operator-bubble"]
680
+ : OPERATOR_DROPPED_CLASSES.has(name) ? [] : [name]
681
+ );
682
+ // Flat drops the assistant's fill, not the operator's: the operator keeps
683
+ // its background, so it needs the minimal layout's side padding.
684
+ return layout === "flat" ? [...classes, "persona-px-3"] : classes;
685
+ }
654
686
  const baseClasses = ["persona-message-bubble"];
655
687
 
656
688
  switch (layout) {
@@ -763,7 +795,7 @@ export const applyBubbleGeometry = (
763
795
  tokens?: MessageGeometryTokens
764
796
  ): void => {
765
797
  if (!tokens) return;
766
- const key = role === "user" ? "user" : "assistant";
798
+ const key = role === "user" || role === "operator" ? role : "assistant";
767
799
  const preset = BUBBLE_GEOMETRY_DEFAULTS[layout ?? "bubble"] ?? BUBBLE_GEOMETRY_DEFAULTS.bubble;
768
800
  if (tokens.padding) {
769
801
  bubble.style.padding = `var(--persona-message-${key}-padding, ${preset.padding})`;
@@ -787,13 +819,14 @@ export const customMessageActionDataAction = (id: string): string =>
787
819
 
788
820
  /**
789
821
  * Host-contributed actions this message's role qualifies for. Assistant-only by
790
- * default, matching every built-in except edit and quote.
822
+ * default, matching every built-in except edit and quote; an action reaches
823
+ * operator messages only when its `roles` lists `"operator"`.
791
824
  */
792
825
  export const customMessageActionsForRole = (
793
826
  actionsConfig: AgentWidgetMessageActionsConfig | undefined,
794
827
  role: AgentWidgetMessage["role"]
795
828
  ): AgentWidgetCustomMessageAction[] => {
796
- if (role !== "user" && role !== "assistant") return [];
829
+ if (role !== "user" && role !== "assistant" && role !== "operator") return [];
797
830
  return (actionsConfig?.custom ?? []).filter((action) =>
798
831
  (action.roles ?? ["assistant"]).includes(role)
799
832
  );
@@ -970,6 +1003,11 @@ export type CreateStandardBubbleOptions = {
970
1003
  * Full widget config (needed for loading indicator context)
971
1004
  */
972
1005
  widgetConfig?: import("../types").AgentWidgetConfig;
1006
+ /**
1007
+ * Long user message collapse (`features.userMessageCollapse`). When set on
1008
+ * a user bubble, its content is wrapped in a clip region with the toggle.
1009
+ */
1010
+ userCollapse?: UserMessageCollapseRender;
973
1011
  };
974
1012
 
975
1013
  /**
@@ -1000,21 +1038,33 @@ export const createStandardBubble = (
1000
1038
  bubble.id = `bubble-${message.id}`;
1001
1039
  bubble.setAttribute("data-message-id", message.id);
1002
1040
 
1003
- bubble.setAttribute("data-persona-theme-zone", message.role === "user" ? "user-message" : "assistant-message");
1041
+ const isOperator = message.role === "operator";
1042
+ // The V5 operator is outlined, not filled, so every layout needs the border
1043
+ // style that only the bubble layout's classes carry.
1044
+ if (isOperator && options?.widgetConfig?.future?.v5Defaults === true) bubble.classList.add("persona-border");
1045
+ bubble.setAttribute(
1046
+ "data-persona-theme-zone",
1047
+ message.role === "user" ? "user-message" : isOperator ? "operator-message" : "assistant-message"
1048
+ );
1004
1049
 
1005
1050
  const defaultMessageTokens = resolveThemeDefaults(options?.widgetConfig?.future).components.message;
1006
1051
  const messageTokens = options?.widgetConfig?.theme?.components?.message;
1052
+ const darkMessageTokens = options?.widgetConfig?.darkTheme?.components?.message;
1007
1053
  applyBubbleGeometry(
1008
1054
  bubble,
1009
1055
  message.role,
1010
1056
  layout,
1011
1057
  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 }
1058
+ ? { ...defaultMessageTokens.user, ...messageTokens?.user, ...darkMessageTokens?.user }
1059
+ : isOperator
1060
+ ? { ...defaultMessageTokens.operator, ...messageTokens?.operator, ...darkMessageTokens?.operator }
1061
+ : { ...defaultMessageTokens.assistant, ...messageTokens?.assistant, ...darkMessageTokens?.assistant }
1014
1062
  );
1015
1063
 
1016
- // Apply component-level color overrides via CSS variables
1017
- if (layout === "flat") {
1064
+ // Apply component-level color overrides via CSS variables. Flat clears the
1065
+ // user and assistant fills; an operator keeps its own background token so it
1066
+ // stays apart from the assistant prose on the same side.
1067
+ if (layout === "flat" && !isOperator) {
1018
1068
  bubble.style.backgroundColor = "transparent";
1019
1069
  bubble.style.color =
1020
1070
  message.role === "user"
@@ -1026,6 +1076,23 @@ export const createStandardBubble = (
1026
1076
  } else if (message.role === "assistant") {
1027
1077
  bubble.style.backgroundColor = 'var(--persona-message-assistant-bg, var(--persona-container))';
1028
1078
  bubble.style.color = 'var(--persona-message-assistant-text, var(--persona-text))';
1079
+ } else if (isOperator) {
1080
+ bubble.style.backgroundColor = 'var(--persona-components-message-operator-background, var(--persona-container))';
1081
+ bubble.style.color = 'var(--persona-components-message-operator-text, var(--persona-text))';
1082
+ }
1083
+
1084
+ if (isOperator) {
1085
+ const authorLabel = createElement("div", "persona-message-author persona-text-xs persona-font-medium persona-mb-1");
1086
+ const configuredLabel = options?.widgetConfig?.copy?.operatorLabel;
1087
+ const operatorLabel = typeof configuredLabel === "string" && configuredLabel.trim()
1088
+ ? configuredLabel.trim() : "Human operator";
1089
+ const displayName = typeof message.author?.displayName === "string"
1090
+ ? message.author.displayName.trim() : "";
1091
+ authorLabel.textContent = displayName ? `${displayName} · ${operatorLabel}` : operatorLabel;
1092
+ // A visible name is also the accessible name for the human-authored group.
1093
+ bubble.setAttribute("role", "group");
1094
+ bubble.setAttribute("aria-label", authorLabel.textContent);
1095
+ bubble.appendChild(authorLabel);
1029
1096
  }
1030
1097
 
1031
1098
  const imageParts = getMessageImageParts(message);
@@ -1181,6 +1248,12 @@ export const createStandardBubble = (
1181
1248
  }
1182
1249
  }
1183
1250
 
1251
+ // Everything a long user message can spill (attachments, chips, text) goes
1252
+ // in the collapse region; the timestamp and actions stay outside the clip.
1253
+ const userCollapse = message.role === "user" ? options?.userCollapse : undefined;
1254
+ const collapseRegion = userCollapse ? createUserCollapseRegion(message.id, userCollapse.maxHeight) : null;
1255
+ const contentParent = collapseRegion ?? bubble;
1256
+
1184
1257
  if (imageParts.length > 0) {
1185
1258
  const imagePreviews = createMessageImagePreviews(
1186
1259
  imageParts,
@@ -1193,7 +1266,7 @@ export const createStandardBubble = (
1193
1266
  );
1194
1267
 
1195
1268
  if (imagePreviews) {
1196
- bubble.appendChild(imagePreviews);
1269
+ contentParent.appendChild(imagePreviews);
1197
1270
  } else if (shouldHideTextUntilPreviewFails && textContentDiv) {
1198
1271
  textContentDiv.style.display = "";
1199
1272
  }
@@ -1203,7 +1276,7 @@ export const createStandardBubble = (
1203
1276
  if (audioParts.length > 0) {
1204
1277
  const audioPreviews = createMessageAudioPreviews(audioParts);
1205
1278
  if (audioPreviews) {
1206
- bubble.appendChild(audioPreviews);
1279
+ contentParent.appendChild(audioPreviews);
1207
1280
  }
1208
1281
  }
1209
1282
 
@@ -1211,7 +1284,7 @@ export const createStandardBubble = (
1211
1284
  if (videoParts.length > 0) {
1212
1285
  const videoPreviews = createMessageVideoPreviews(videoParts);
1213
1286
  if (videoPreviews) {
1214
- bubble.appendChild(videoPreviews);
1287
+ contentParent.appendChild(videoPreviews);
1215
1288
  }
1216
1289
  }
1217
1290
 
@@ -1219,7 +1292,7 @@ export const createStandardBubble = (
1219
1292
  if (fileParts.length > 0) {
1220
1293
  const filePreviews = createMessageFilePreviews(fileParts);
1221
1294
  if (filePreviews) {
1222
- bubble.appendChild(filePreviews);
1295
+ contentParent.appendChild(filePreviews);
1223
1296
  }
1224
1297
  }
1225
1298
 
@@ -1229,10 +1302,14 @@ export const createStandardBubble = (
1229
1302
  ? null
1230
1303
  : createMessageMentionChips(message.contextMentions);
1231
1304
  if (mentionChips) {
1232
- bubble.appendChild(mentionChips);
1305
+ contentParent.appendChild(mentionChips);
1233
1306
  }
1234
1307
 
1235
- bubble.appendChild(contentDiv);
1308
+ contentParent.appendChild(contentDiv);
1309
+ if (collapseRegion && userCollapse) {
1310
+ bubble.appendChild(collapseRegion);
1311
+ applyUserCollapseState(bubble, collapseRegion, message.id, userCollapse);
1312
+ }
1236
1313
 
1237
1314
  // Add timestamp below if configured
1238
1315
  if (showTimestamp && timestampPosition === "below" && message.createdAt) {
@@ -1293,14 +1370,15 @@ export const createStandardBubble = (
1293
1370
  bubble.appendChild(createStopReasonNotice(message.stopReason, stopReasonNoticeText));
1294
1371
  }
1295
1372
 
1296
- // Add message actions for assistant messages (only when not streaming and has content)
1373
+ // Add message actions for assistant and operator messages (only when not
1374
+ // streaming and has content). Operator rows get copy and opted-in custom
1375
+ // actions only: feedback, read-aloud and regenerate are about model output.
1297
1376
  const shouldShowActions =
1298
- message.role === "assistant" &&
1377
+ (message.role === "assistant" || isOperator) &&
1299
1378
  !message.streaming &&
1300
1379
  // A voice caption (the voice model's own speech) is display-only.
1301
1380
  !message.voiceCaption &&
1302
- message.content &&
1303
- message.content.trim() &&
1381
+ (messageContentText.length > 0 || (isOperator && customMessageActionsForRole(actionsConfig, "operator").length > 0)) &&
1304
1382
  actionsConfig?.enabled !== false;
1305
1383
 
1306
1384
  // User bubbles carry actions only when this message is edit- or quote-eligible;
@@ -1321,7 +1399,18 @@ export const createStandardBubble = (
1321
1399
  shouldShowUserActions
1322
1400
  ? // A user bubble never hosts the assistant-only controls.
1323
1401
  { ...actionsConfig, showCopy: false, showUpvote: false, showDownvote: false, showReadAloud: false }
1324
- : actionsConfig,
1402
+ : isOperator
1403
+ ? {
1404
+ ...actionsConfig,
1405
+ showCopy: messageContentText.length > 0 && actionsConfig.showCopy !== false,
1406
+ showUpvote: false,
1407
+ showDownvote: false,
1408
+ showReadAloud: false,
1409
+ showRegenerate: false,
1410
+ showEdit: false,
1411
+ showQuote: false,
1412
+ }
1413
+ : actionsConfig,
1325
1414
  actionCallbacks,
1326
1415
  eligibility,
1327
1416
  options?.widgetConfig?.future?.v5Defaults === true && actionsConfig.visibility === undefined
@@ -40,6 +40,49 @@ describe("scroll-to-bottom chrome", () => {
40
40
  control.destroy();
41
41
  });
42
42
 
43
+ it("names a pending approval below with a labelled shield pill, then reverts", () => {
44
+ const control = createScrollToBottomControl(icon, "transcript");
45
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
46
+ control.update(config, true, false, 2, true);
47
+ const el = control.element;
48
+ expect(el.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
49
+ expect(el.getAttribute("data-persona-scroll-to-bottom-has-label")).toBe("true");
50
+ expect(el.textContent).toBe("Approval needed");
51
+ expect(el.querySelector("svg")?.getAttribute("data-icon")).toBe("shield-check");
52
+ // The label replaces the unread count; assistive tech hears where it leads.
53
+ expect(el.querySelector<HTMLElement>("[data-persona-scroll-to-bottom-count]")?.style.display).toBe("none");
54
+ expect(el.getAttribute("aria-label")).toBe("Approval needed below. Jump to latest");
55
+
56
+ // Hidden, the approval state never shows.
57
+ control.update(config, false, false, 0, true);
58
+ expect(el.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
59
+
60
+ control.update(config, true, false, 2, false);
61
+ expect(el.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
62
+ expect(el.getAttribute("data-persona-scroll-to-bottom-has-label")).toBe("false");
63
+ expect(el.querySelector("svg")?.getAttribute("data-icon")).toBe("arrow-down");
64
+ expect(el.getAttribute("aria-label")).toBe("Jump to latest (2 new)");
65
+ control.destroy();
66
+ });
67
+
68
+ it("uses a custom approval label, opts out with an empty one, and tells custom glyphs", () => {
69
+ const states: string[] = [];
70
+ const renderIcon = vi.fn((_context: AgentWidgetScrollToBottomIconContext) => ({
71
+ element: document.createElement("span"),
72
+ update: (next: AgentWidgetScrollToBottomIconContext) => { states.push(next.state.activity); },
73
+ }));
74
+ const control = createScrollToBottomControl(icon, "transcript");
75
+ control.update({ features: { scrollToBottom: { approvalLabel: "Review request", renderIcon } } }, true, false, 0, true);
76
+ expect(control.element.textContent).toBe("Review request");
77
+ expect(renderIcon.mock.calls[0][0].state.activity).toBe("approval-below");
78
+ control.update({ features: { scrollToBottom: { approvalLabel: "", renderIcon } } }, true, false, 1, true);
79
+ expect(control.element.getAttribute("data-persona-scroll-to-bottom-has-label")).toBe("false");
80
+ expect(control.element.getAttribute("aria-label")).toBe("Approval needed below. Jump to latest");
81
+ control.update({ features: { scrollToBottom: { renderIcon } } }, true, false, 0, false);
82
+ expect(states.at(-1)).toBe("idle");
83
+ control.destroy();
84
+ });
85
+
43
86
  it("makes fading controls inert immediately and returns focus only when owned, including Shadow DOM", () => {
44
87
  const host = document.createElement("div"); document.body.appendChild(host);
45
88
  const root = host.attachShadow({ mode: "open" });
@@ -122,6 +165,98 @@ describe("scroll-to-bottom chrome", () => {
122
165
  control.destroy(); warning.mockRestore();
123
166
  });
124
167
 
168
+ describe("approval morph", () => {
169
+ const stubAnimate = () => {
170
+ const calls: { target: Element; keyframes: Keyframe[]; options: KeyframeAnimationOptions; animation: { cancel: ReturnType<typeof vi.fn> } }[] = [];
171
+ const original = (HTMLElement.prototype as { animate?: unknown }).animate;
172
+ (HTMLElement.prototype as { animate?: unknown }).animate = function (this: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions) {
173
+ const animation = { cancel: vi.fn(), finished: new Promise<void>(() => {}) };
174
+ calls.push({ target: this, keyframes, options, animation });
175
+ return animation;
176
+ };
177
+ return { calls, restore: () => { (HTMLElement.prototype as { animate?: unknown }).animate = original; } };
178
+ };
179
+
180
+ it("morphs a visible arrow into the pill on the theme's motion tokens, and back", () => {
181
+ const { calls, restore } = stubAnimate();
182
+ try {
183
+ const control = createScrollToBottomControl(icon, "transcript");
184
+ document.body.appendChild(control.element);
185
+ control.element.style.setProperty("--persona-motion-duration-base", "0.3s");
186
+ control.element.style.setProperty("--persona-motion-easing", "cubic-bezier(0.2, 0, 0, 1)");
187
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
188
+ control.update(config, true);
189
+ expect(calls).toHaveLength(0);
190
+
191
+ control.update(config, true, false, 0, true);
192
+ const [own, labelFade] = calls;
193
+ expect(own.target).toBe(control.element);
194
+ expect(own.keyframes).toHaveLength(2);
195
+ expect(own.keyframes[0]).toMatchObject({ overflow: "hidden" });
196
+ expect(own.keyframes[0]).toHaveProperty("width");
197
+ expect(own.keyframes[1]).toHaveProperty("backgroundColor");
198
+ expect(own.options).toEqual({ duration: 300, easing: "cubic-bezier(0.2, 0, 0, 1)" });
199
+ // The label fades in as the clipped pill widens.
200
+ expect(labelFade.target.textContent).toBe("Approval needed");
201
+ expect(calls).toHaveLength(2);
202
+
203
+ // Ordinary syncs in the same state leave a running morph alone.
204
+ control.update(config, true, false, 0, true);
205
+ expect(calls).toHaveLength(2);
206
+ expect(calls.every((call) => call.animation.cancel.mock.calls.length === 0)).toBe(true);
207
+
208
+ // Back to the arrow: the running morph is replaced, unclipped (the
209
+ // count badge hangs outside the button).
210
+ control.update(config, true, false, 2, false);
211
+ expect(calls.slice(0, 2).every((call) => call.animation.cancel.mock.calls.length === 1)).toBe(true);
212
+ expect(calls).toHaveLength(3);
213
+ expect(calls[2].keyframes[0]).not.toHaveProperty("overflow");
214
+ control.destroy();
215
+ } finally { restore(); }
216
+ });
217
+
218
+ it("swaps instantly when the theme turns motion off, and falls back when the duration is missing", () => {
219
+ const { calls, restore } = stubAnimate();
220
+ try {
221
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
222
+ const off = createScrollToBottomControl(icon, "transcript");
223
+ off.element.style.setProperty("--persona-motion-duration-base", "0ms");
224
+ off.update(config, true);
225
+ off.update(config, true, false, 0, true);
226
+ expect(calls).toHaveLength(0);
227
+ expect(off.element.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
228
+ off.destroy();
229
+
230
+ const unset = createScrollToBottomControl(icon, "transcript");
231
+ unset.update(config, true);
232
+ unset.update(config, true, false, 0, true);
233
+ expect(calls[0].options.duration).toBe(200);
234
+ unset.destroy();
235
+ } finally { restore(); }
236
+ });
237
+
238
+ it("does not morph a control that appears, hides, or prefers reduced motion", () => {
239
+ const { calls, restore } = stubAnimate();
240
+ try {
241
+ const config: AgentWidgetConfig = { future: { v5Defaults: true } };
242
+ const control = createScrollToBottomControl(icon, "transcript");
243
+ control.update(config, false);
244
+ control.update(config, true, false, 0, true); // appears already as the pill
245
+ control.update(config, false, false, 0, true); // hides
246
+ expect(calls).toHaveLength(0);
247
+ control.destroy();
248
+
249
+ vi.stubGlobal("matchMedia", () => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
250
+ const reduced = createScrollToBottomControl(icon, "transcript");
251
+ reduced.update(config, true);
252
+ reduced.update(config, true, false, 0, true);
253
+ expect(calls).toHaveLength(0);
254
+ expect(reduced.element.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
255
+ reduced.destroy();
256
+ } finally { restore(); }
257
+ });
258
+ });
259
+
125
260
  it("provides live reduced-motion changes and releases its media listener", () => {
126
261
  let change: (() => void) | undefined;
127
262
  const media = { matches: true, addEventListener: vi.fn((_event, callback) => { change = callback; }), removeEventListener: vi.fn() };
@@ -25,9 +25,7 @@ export function createScrollToBottomControl(
25
25
  const label = createElement("span", "");
26
26
  const badge = createElement("span", "");
27
27
  badge.setAttribute("data-persona-scroll-to-bottom-count", "");
28
- element.appendChild(glyph);
29
- element.appendChild(label);
30
- element.appendChild(badge);
28
+ element.append(glyph, label, badge);
31
29
  let config: AgentWidgetConfig = {};
32
30
  let state: AgentWidgetScrollToBottomIconState = { activity: "idle", visible: false, hovered: false, focused: false, reducedMotion: false };
33
31
  // Keep renderer identity separate from its handle, including null fallbacks.
@@ -36,6 +34,26 @@ export function createScrollToBottomControl(
36
34
  let abort: AbortController | undefined;
37
35
  let builtin = "";
38
36
  const media = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : undefined;
37
+ // Arrow <-> "Approval needed" pill: one button changing shape, so morph its
38
+ // width and colors on the theme's motion tokens while the new label fades in.
39
+ let morphs: Animation[] = [];
40
+ const paint = () => {
41
+ const style = getComputedStyle(element);
42
+ return { width: `${element.offsetWidth}px`, backgroundColor: style.backgroundColor, borderColor: style.borderColor, color: style.color };
43
+ };
44
+ const morph = (from: Keyframe) => {
45
+ const style = getComputedStyle(element);
46
+ const value = style.getPropertyValue("--persona-motion-duration-base");
47
+ const ms = parseFloat(value) * (value.includes("ms") ? 1 : 1000);
48
+ // "0ms" turns motion off; only a missing or invalid value falls back.
49
+ if (ms === 0) return;
50
+ const timing = { duration: ms > 0 ? ms : 200, easing: style.getPropertyValue("--persona-motion-easing").trim() || "ease-out" };
51
+ // Clip only while widening into the pill: the arrow's count badge hangs
52
+ // outside the button.
53
+ const clip = label.style.display === "none" ? null : { overflow: "hidden" };
54
+ morphs = [element.animate([{ ...from, ...clip }, { ...paint(), ...clip }], timing)];
55
+ if (clip) morphs.push(label.animate([{ opacity: 0 }, { opacity: 1 }], timing));
56
+ };
39
57
  state.reducedMotion = media?.matches ?? false;
40
58
  const warn = (error: unknown) => console.warn("Persona scroll icon failed", error);
41
59
  const context = (): AgentWidgetScrollToBottomIconContext => ({ config, state: { ...state }, view, signal: abort!.signal });
@@ -51,7 +69,9 @@ export function createScrollToBottomControl(
51
69
  if (handle?.element) return;
52
70
  const feature = config.features?.scrollToBottom;
53
71
  const dots = state.activity === "generating-below" && (feature?.streamingIndicator ?? (config.future?.v5Defaults ? "dots" : "arrow")) === "dots";
54
- const key = dots ? "dots" : `arrow:${feature?.iconName ?? "arrow-down"}`;
72
+ // Same glyph as the approval card it points at, so the two read as one thing.
73
+ const iconName = state.activity === "approval-below" ? "shield-check" : feature?.iconName ?? "arrow-down";
74
+ const key = dots ? "dots" : `arrow:${iconName}`;
55
75
  if (builtin === key) return;
56
76
  builtin = key;
57
77
  glyph.innerHTML = "";
@@ -64,7 +84,7 @@ export function createScrollToBottomControl(
64
84
  }
65
85
  glyph.appendChild(dotGroup);
66
86
  } else {
67
- const icon = renderIcon(feature?.iconName ?? "arrow-down", "14px", "currentColor", 2);
87
+ const icon = renderIcon(iconName, "14px", "currentColor", 2);
68
88
  if (icon) glyph.appendChild(icon);
69
89
  }
70
90
  };
@@ -106,11 +126,12 @@ export function createScrollToBottomControl(
106
126
 
107
127
  return {
108
128
  element,
109
- update(nextConfig: AgentWidgetConfig, visible: boolean, active = false, count = 0) {
129
+ /** `approval` marks a pending approval below the fold: the control names it. */
130
+ update(nextConfig: AgentWidgetConfig, visible: boolean, active = false, count = 0, approval = false) {
110
131
  const previous = config.features?.scrollToBottom;
111
132
  const next = nextConfig.features?.scrollToBottom;
112
133
  const configChanged = config.future?.v5Defaults !== nextConfig.future?.v5Defaults ||
113
- (["enabled", "iconName", "label", "streamingIndicator", "transition", "scrollBehavior", "scrollDurationMs"] as (keyof AgentWidgetScrollToBottomFeature)[])
134
+ (["enabled", "iconName", "label", "approvalLabel", "streamingIndicator", "transition", "scrollBehavior", "scrollDurationMs"] as (keyof AgentWidgetScrollToBottomFeature)[])
114
135
  .some((key) => previous?.[key] !== next?.[key]);
115
136
  config = nextConfig;
116
137
  const feature = config.features?.scrollToBottom;
@@ -132,21 +153,37 @@ export function createScrollToBottomControl(
132
153
  focusTarget.focus({ preventScroll: true });
133
154
  }
134
155
  }
135
- const text = feature?.label ?? "";
136
- const base = text || "Jump to latest";
137
- element.setAttribute("aria-label", `${base}${count > 0 ? ` (${count} new)` : ""}${active && visible ? " (response streaming below)" : ""}`);
156
+ // A waiting approval outranks streaming and the unread count: the turn is
157
+ // blocked on the reader, so the control says so in words.
158
+ const waiting = approval && visible;
159
+ // Morph only a visible control changing shape, never its fade in or out.
160
+ const reshape = state.visible && visible && (state.activity === "approval-below") !== waiting;
161
+ if (reshape || !visible) morphs.forEach((animation) => animation.cancel());
162
+ const from = reshape && !state.reducedMotion && typeof element.animate === "function" ? paint() : null;
163
+ const approvalText = feature?.approvalLabel ?? "Approval needed";
164
+ const text = waiting && approvalText ? approvalText : feature?.label ?? "";
165
+ const base = feature?.label || "Jump to latest";
166
+ element.setAttribute("aria-label", waiting
167
+ ? `${approvalText || "Approval needed"} below. ${base}`
168
+ : `${base}${count > 0 ? ` (${count} new)` : ""}${active && visible ? " (response streaming below)" : ""}`);
138
169
  element.title = text;
139
170
  element.setAttribute("data-persona-scroll-to-bottom-has-label", String(Boolean(text)));
171
+ if (waiting) element.setAttribute("data-persona-scroll-to-bottom-approval", "");
172
+ else element.removeAttribute("data-persona-scroll-to-bottom-approval");
140
173
  label.textContent = text;
141
174
  label.style.display = text ? "" : "none";
142
- badge.textContent = count > 0 ? String(count) : "";
143
- badge.style.display = count > 0 ? "" : "none";
144
- const nextState = { ...state, visible, hovered: visible && state.hovered, focused: visible && state.focused, activity: active && visible ? "generating-below" as const : "idle" as const };
175
+ const shownCount = waiting ? 0 : count;
176
+ badge.textContent = shownCount > 0 ? String(shownCount) : "";
177
+ badge.style.display = shownCount > 0 ? "" : "none";
178
+ const activity = waiting ? "approval-below" as const : active && visible ? "generating-below" as const : "idle" as const;
179
+ const nextState = { ...state, visible, hovered: visible && state.hovered, focused: visible && state.focused, activity };
145
180
  const changed = configChanged || stateChanged(nextState);
146
181
  state = nextState;
147
182
  updateRenderer(changed);
183
+ if (from) morph(from);
148
184
  },
149
185
  destroy() {
186
+ morphs.forEach((animation) => animation.cancel());
150
187
  dispose();
151
188
  lifetime.abort();
152
189
  media?.removeEventListener?.("change", motion);