@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
@@ -0,0 +1,336 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { createAgentExperience } from "./ui";
7
+ import type { AgentWidgetMessage, AgentWidgetStoredState } from "./types";
8
+ import type { AgentWidgetPlugin } from "./plugins/types";
9
+
10
+ const mounts: HTMLElement[] = [];
11
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
12
+
13
+ const makeController = (config: Record<string, unknown> = {}) => {
14
+ const mount = document.createElement("div");
15
+ document.body.appendChild(mount);
16
+ mounts.push(mount);
17
+ const controller = createAgentExperience(mount, {
18
+ apiUrl: "https://api.example.com/chat",
19
+ launcher: { enabled: false },
20
+ persistState: false,
21
+ suggestionChips: [],
22
+ ...config,
23
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
24
+ controllers.push(controller);
25
+ return { mount, controller };
26
+ };
27
+
28
+ /** Let microtasks and the coalesced render frame run. */
29
+ const settle = async () => {
30
+ for (let i = 0; i < 8; i += 1) {
31
+ // eslint-disable-next-line no-await-in-loop
32
+ await Promise.resolve();
33
+ }
34
+ await new Promise((resolve) => setTimeout(resolve, 32));
35
+ };
36
+
37
+ const row = (mount: HTMLElement, id: string) =>
38
+ mount.querySelector<HTMLElement>(`[data-wrapper-id="${id}"]`);
39
+
40
+ describe("operator role in the transcript", () => {
41
+ beforeEach(() => {
42
+ window.scrollTo = vi.fn();
43
+ });
44
+
45
+ afterEach(() => {
46
+ controllers.splice(0).forEach((controller) => {
47
+ try {
48
+ controller.destroy();
49
+ } catch {
50
+ /* already destroyed */
51
+ }
52
+ });
53
+ mounts.splice(0).forEach((mount) => mount.remove());
54
+ document.body.innerHTML = "";
55
+ window.localStorage.clear();
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ it("renders an operator row on the assistant side with its own bubble", async () => {
60
+ const { mount, controller } = makeController();
61
+ controller.injectUserMessage({ id: "u1", content: "Where is my order?" });
62
+ controller.injectAssistantMessage({ id: "a1", content: "Let me check." });
63
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here, I refunded it." });
64
+ await settle();
65
+
66
+ const operatorRow = row(mount, "op1")!;
67
+ expect(operatorRow.getAttribute("data-message-role")).toBe("operator");
68
+ expect(operatorRow.classList.contains("persona-message-row-operator")).toBe(true);
69
+ expect(operatorRow.classList.contains("persona-justify-end")).toBe(false);
70
+ expect(operatorRow.querySelector(".persona-message-operator-bubble")).not.toBeNull();
71
+ expect(operatorRow.querySelector(".persona-message-assistant-bubble")).toBeNull();
72
+
73
+ // Existing roles are untouched.
74
+ expect(row(mount, "u1")!.classList.contains("persona-justify-end")).toBe(true);
75
+ expect(row(mount, "a1")!.querySelector(".persona-message-assistant-bubble")).not.toBeNull();
76
+ });
77
+
78
+ it("names human replies with avatars off and updates provenance on reinjection", async () => {
79
+ const { mount, controller } = makeController();
80
+ controller.injectMessage({ id: "human", role: "operator", content: "Refunded" });
81
+ await settle();
82
+ expect(row(mount, "human")!.querySelector('[role="group"]')?.getAttribute("aria-label")).toBe("Human operator");
83
+ controller.injectMessage({ id: "human", role: "operator", content: "Refunded", author: { id: "sam", displayName: "Sam", kind: "human" } });
84
+ await settle();
85
+ expect(row(mount, "human")!.querySelector(".persona-message-author")?.textContent).toBe("Sam · Human operator");
86
+ expect(row(mount, "human")!.querySelector('[role="group"]')?.getAttribute("aria-label")).toBe("Sam · Human operator");
87
+ expect(controller.getMessages().find(message => message.id === "human")?.author).toEqual({ id: "sam", displayName: "Sam", kind: "human" });
88
+ });
89
+
90
+ it("persists and restores structured author provenance", async () => {
91
+ let state: AgentWidgetStoredState | null = null;
92
+ const adapter = {
93
+ load: () => state,
94
+ save: (value: AgentWidgetStoredState) => { state = JSON.parse(JSON.stringify(value)); },
95
+ clear: () => { state = null; },
96
+ };
97
+ const { controller } = makeController({ persistState: true, storageAdapter: adapter });
98
+ controller.injectMessage({ id: "saved", role: "operator", content: "Refunded", author: { id: "sam", displayName: "Sam", kind: "human" } });
99
+ await vi.waitFor(() => expect(state?.messages?.some(message => message.id === "saved")).toBe(true));
100
+ const { mount, controller: restored } = makeController({ persistState: true, storageAdapter: adapter });
101
+ await settle();
102
+ expect(restored.getMessages().find(message => message.id === "saved")?.author).toEqual({ id: "sam", displayName: "Sam", kind: "human" });
103
+ expect(row(mount, "saved")!.querySelector(".persona-message-author")?.textContent).toBe("Sam · Human operator");
104
+ });
105
+
106
+ it("hides destructive retry and edit before human replies while allowing a subsequent turn", async () => {
107
+ const { mount, controller } = makeController({ messageActions: { enabled: true, showEdit: true, showRegenerate: true } });
108
+ controller.injectUserMessage({ id: "before", content: "Question" });
109
+ controller.injectAssistantMessage({ id: "answer", content: "Answer" });
110
+ await settle();
111
+ expect(row(mount, "answer")!.querySelector('[data-action="regenerate"]')).not.toBeNull();
112
+ controller.injectMessage({ id: "human", role: "operator", content: "Refunded" });
113
+ await settle();
114
+ expect(row(mount, "before")!.querySelector('[data-action="edit"]')).toBeNull();
115
+ expect(row(mount, "answer")!.querySelector('[data-action="regenerate"]')).toBeNull();
116
+ controller.injectUserMessage({ id: "after", content: "Follow up" });
117
+ controller.injectAssistantMessage({ id: "next", content: "Next answer" });
118
+ await settle();
119
+ expect(row(mount, "after")!.querySelector('[data-action="edit"]')).not.toBeNull();
120
+ expect(row(mount, "next")!.querySelector('[data-action="regenerate"]')).not.toBeNull();
121
+ });
122
+
123
+ it("does not report operator messages as assistant messages", async () => {
124
+ const { controller } = makeController();
125
+ const assistantMessages: AgentWidgetMessage[] = [];
126
+ controller.on("assistant:message", (message) => assistantMessages.push(message));
127
+ controller.injectMessage({ id: "op1", role: "operator", content: "Hello from support" });
128
+ controller.injectAssistantMessage({ id: "a1", content: "Agent reply" });
129
+ await settle();
130
+ expect(assistantMessages.map((message) => message.id)).toEqual(["a1"]);
131
+ });
132
+
133
+ it("renders operator messages from initialMessages", async () => {
134
+ const { mount } = makeController({
135
+ initialMessages: [
136
+ { id: "in1", role: "user", content: "Hi", createdAt: "2026-01-01T00:00:00.000Z" },
137
+ { id: "op1", role: "operator", content: "Hello, Sam here.", createdAt: "2026-01-01T00:00:01.000Z" },
138
+ ] satisfies AgentWidgetMessage[],
139
+ });
140
+ await settle();
141
+ expect(row(mount, "in1")!.getAttribute("data-message-role")).toBe("user");
142
+ expect(row(mount, "op1")!.getAttribute("data-message-role")).toBe("operator");
143
+ expect(row(mount, "op1")!.querySelector(".persona-message-operator-bubble")).not.toBeNull();
144
+ });
145
+
146
+ it("shrink-wraps operator rows even when the assistant is full width", async () => {
147
+ const { mount, controller } = makeController({
148
+ layout: { messages: { assistant: { width: "full" } } },
149
+ });
150
+ controller.injectAssistantMessage({ id: "a1", content: "Let me check." });
151
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
152
+ await settle();
153
+ expect(row(mount, "a1")!.getAttribute("data-message-width")).toBe("full");
154
+ const operatorRow = row(mount, "op1")!;
155
+ expect(operatorRow.getAttribute("data-message-width")).toBe("content");
156
+ // It keeps the assistant's full-width cap, so a long message can still fill the column.
157
+ expect(operatorRow.style.getPropertyValue("--persona-message-row-max-width")).toBe(
158
+ "var(--persona-message-operator-max-width, var(--persona-message-assistant-max-width, 100%))"
159
+ );
160
+ });
161
+ });
162
+
163
+ describe("operator bubble styles", () => {
164
+ let stylesheet: HTMLStyleElement;
165
+
166
+ beforeEach(() => {
167
+ window.scrollTo = vi.fn();
168
+ stylesheet = document.createElement("style");
169
+ stylesheet.textContent = readFileSync("src/styles/widget.css", "utf8");
170
+ document.head.append(stylesheet);
171
+ });
172
+
173
+ afterEach(() => {
174
+ controllers.splice(0).forEach((controller) => controller.destroy());
175
+ mounts.splice(0).forEach((mount) => mount.remove());
176
+ stylesheet.remove();
177
+ document.body.innerHTML = "";
178
+ });
179
+
180
+ it.each(["bubble", "flat", "minimal"] as const)(
181
+ "outlines the V5 operator bubble in the %s layout",
182
+ async (layout) => {
183
+ const { mount, controller } = makeController({
184
+ future: { v5Defaults: true },
185
+ layout: { messages: { layout } },
186
+ });
187
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
188
+ await settle();
189
+ const bubble = row(mount, "op1")!.querySelector<HTMLElement>("#bubble-op1")!;
190
+ const style = getComputedStyle(bubble);
191
+ expect(style.borderTopStyle).toBe("solid");
192
+ // Width and fill come from the V5 operator tokens on the themed ancestor.
193
+ const token = (name: string) => {
194
+ for (let node: HTMLElement | null = bubble; node; node = node.parentElement) {
195
+ const value = node.style.getPropertyValue(name).trim();
196
+ if (value) return value;
197
+ }
198
+ return "";
199
+ };
200
+ expect(token("--persona-components-message-operator-borderWidth")).toBe("1px");
201
+ expect(token("--persona-components-message-operator-background")).toBe("transparent");
202
+ }
203
+ );
204
+
205
+ it.each(["flat", "minimal"] as const)("keeps the V4 %s operator borderless", async (layout) => {
206
+ const { mount, controller } = makeController({ layout: { messages: { layout } } });
207
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
208
+ await settle();
209
+ const bubble = row(mount, "op1")!.querySelector<HTMLElement>("#bubble-op1")!;
210
+ expect(getComputedStyle(bubble).borderTopStyle).not.toBe("solid");
211
+ });
212
+ });
213
+
214
+ /**
215
+ * Hosts draw per-message badges (delivery status, author label, errors) with a
216
+ * `renderMessage` plugin: it wraps the default bubble, appends a
217
+ * `data-preserve-runtime` slot, and the host fills that slot itself. These
218
+ * tests pin the route for operator rows.
219
+ */
220
+ describe("operator messages through a renderMessage plugin slot", () => {
221
+ beforeEach(() => {
222
+ window.scrollTo = vi.fn();
223
+ });
224
+
225
+ afterEach(() => {
226
+ controllers.splice(0).forEach((controller) => controller.destroy());
227
+ mounts.splice(0).forEach((mount) => mount.remove());
228
+ document.body.innerHTML = "";
229
+ vi.restoreAllMocks();
230
+ });
231
+
232
+ const slotPlugin = (seen: AgentWidgetMessage["role"][]): AgentWidgetPlugin => ({
233
+ id: "delivery-status",
234
+ renderMessage: ({ message, defaultRenderer }) => {
235
+ seen.push(message.role);
236
+ if (message.role !== "operator" && message.role !== "assistant") return null;
237
+ const element = defaultRenderer();
238
+ const slot = document.createElement("div");
239
+ slot.setAttribute("data-preserve-runtime", "true");
240
+ slot.setAttribute("data-status-slot", message.id);
241
+ element.insertBefore(slot, element.querySelector(":scope > .persona-message-actions"));
242
+ return element;
243
+ },
244
+ });
245
+
246
+ const slot = (mount: HTMLElement, id: string) =>
247
+ row(mount, id)?.querySelector<HTMLElement>(`[data-status-slot="${id}"]`) ?? null;
248
+
249
+ /** What a host does once the row is on screen: fill the slot with its own UI. */
250
+ const fillSlot = (target: HTMLElement, text: string) => {
251
+ const badge = document.createElement("span");
252
+ badge.className = "host-status";
253
+ badge.textContent = text;
254
+ target.replaceChildren(badge);
255
+ };
256
+
257
+ it("runs the hook for operator rows and uses the element it returns", async () => {
258
+ const seen: AgentWidgetMessage["role"][] = [];
259
+ const { mount, controller } = makeController({ plugins: [slotPlugin(seen)] });
260
+ controller.injectUserMessage({ id: "u1", content: "Where is my order?" });
261
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here, I refunded it." });
262
+ await settle();
263
+
264
+ expect(seen).toContain("operator");
265
+ expect(seen).toContain("user");
266
+ const operatorRow = row(mount, "op1")!;
267
+ expect(operatorRow.getAttribute("data-message-role")).toBe("operator");
268
+ expect(slot(mount, "op1")).not.toBeNull();
269
+ // The plugin returned null for the user row, so it keeps the default bubble.
270
+ expect(row(mount, "u1")!.querySelector("[data-status-slot]")).toBeNull();
271
+ expect(row(mount, "u1")!.querySelector(".persona-message-user-bubble")).not.toBeNull();
272
+ });
273
+
274
+ it("keeps the operator bubble class when a plugin wraps the default renderer", async () => {
275
+ const { mount, controller } = makeController({ plugins: [slotPlugin([])] });
276
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
277
+ await settle();
278
+
279
+ const bubble = row(mount, "op1")!.querySelector<HTMLElement>("#bubble-op1")!;
280
+ expect(bubble.classList.contains("persona-message-operator-bubble")).toBe(true);
281
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(false);
282
+ expect(bubble.getAttribute("data-persona-theme-zone")).toBe("operator-message");
283
+ expect(bubble.contains(slot(mount, "op1"))).toBe(true);
284
+ });
285
+
286
+ it("keeps the host-filled slot node across controller.update()", async () => {
287
+ const seen: AgentWidgetMessage["role"][] = [];
288
+ const { mount, controller } = makeController({ plugins: [slotPlugin(seen)] });
289
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
290
+ await settle();
291
+ const first = slot(mount, "op1")!;
292
+ fillSlot(first, "Delivered");
293
+ const rendersBefore = seen.length;
294
+
295
+ controller.update({ copy: { welcomeTitle: "Support" } });
296
+ await settle();
297
+ controller.update({ theme: { components: { message: { operator: { borderRadius: "4px" } } } } });
298
+ await settle();
299
+
300
+ // The plugin rendered the row again, and the morph kept the live slot.
301
+ expect(seen.length).toBeGreaterThan(rendersBefore);
302
+ expect(slot(mount, "op1")).toBe(first);
303
+ expect(first.isConnected).toBe(true);
304
+ expect(first.querySelector(".host-status")?.textContent).toBe("Delivered");
305
+ expect(row(mount, "op1")!.querySelector(".persona-message-operator-bubble")).not.toBeNull();
306
+ });
307
+
308
+ it("keeps the host-filled slot node when injectMessage upserts the same id", async () => {
309
+ const seen: AgentWidgetMessage["role"][] = [];
310
+ const { mount, controller } = makeController({ plugins: [slotPlugin(seen)] });
311
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
312
+ await settle();
313
+ const first = slot(mount, "op1")!;
314
+ fillSlot(first, "Sending");
315
+
316
+ // Same id, same text: a status-only change on the host side.
317
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
318
+ await settle();
319
+ expect(slot(mount, "op1")).toBe(first);
320
+
321
+ // Same id, new text: the plugin renders the row again and the slot survives.
322
+ const rendersBefore = seen.length;
323
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here, refund sent." });
324
+ await settle();
325
+ expect(seen.length).toBeGreaterThan(rendersBefore);
326
+
327
+ expect(mount.querySelectorAll('[data-wrapper-id="op1"]')).toHaveLength(1);
328
+ expect(slot(mount, "op1")).toBe(first);
329
+ // The host's content is still there; the host then updates it in place.
330
+ expect(first.querySelector(".host-status")?.textContent).toBe("Sending");
331
+ fillSlot(first, "Delivered");
332
+ expect(slot(mount, "op1")?.querySelector(".host-status")?.textContent).toBe("Delivered");
333
+ expect(row(mount, "op1")!.querySelector("#bubble-op1")?.textContent).toContain("refund sent");
334
+ expect(row(mount, "op1")!.querySelector(".persona-message-operator-bubble")).not.toBeNull();
335
+ });
336
+ });