@runtypelabs/persona 4.32.2 → 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 (158) 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-D0tcIZJM.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-B9Zu-wrk.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/launcher.global.js +2 -2
  75. package/dist/launcher.global.js.map +1 -1
  76. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.cts → loading-label-runtime-loader-JecjOfJS.d.cts} +1 -1
  77. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.ts → loading-label-runtime-loader-JecjOfJS.d.ts} +1 -1
  78. package/dist/loading-label-runtime.d.cts +1 -1
  79. package/dist/loading-label-runtime.d.ts +1 -1
  80. package/dist/{message-bubble-CkZGlCLC.d.ts → message-bubble-CBYtpDsT.d.cts} +2 -2
  81. package/dist/{message-bubble-CkZGlCLC.d.cts → message-bubble-CBYtpDsT.d.ts} +2 -2
  82. package/dist/preferences-runtime.d.cts +1 -1
  83. package/dist/preferences-runtime.d.ts +1 -1
  84. package/dist/{preview-DdUvoLb4.d.cts → preview-_UJwwt2L.d.cts} +2 -2
  85. package/dist/{preview-DdUvoLb4.d.ts → preview-_UJwwt2L.d.ts} +2 -2
  86. package/dist/{registry-Dz4iHNST.d.cts → registry-B5ZV3K-h.d.cts} +1 -1
  87. package/dist/{registry-Dz4iHNST.d.ts → registry-B5ZV3K-h.d.ts} +1 -1
  88. package/dist/{session-Baxp9iRK.d.cts → session-Bys8_XuB.d.cts} +10 -9
  89. package/dist/{session-Baxp9iRK.d.ts → session-Bys8_XuB.d.ts} +10 -9
  90. package/dist/session-reconnect.d.cts +2 -2
  91. package/dist/session-reconnect.d.ts +2 -2
  92. package/dist/smart-dom-reader.d.cts +1 -1
  93. package/dist/smart-dom-reader.d.ts +1 -1
  94. package/dist/theme-editor-preview.cjs +31 -31
  95. package/dist/theme-editor-preview.d.cts +1 -1
  96. package/dist/theme-editor-preview.d.ts +1 -1
  97. package/dist/theme-editor-preview.js +19 -19
  98. package/dist/theme-editor.cjs +1 -1
  99. package/dist/theme-editor.d.cts +2 -2
  100. package/dist/theme-editor.d.ts +2 -2
  101. package/dist/theme-editor.js +1 -1
  102. package/dist/theme-reference.cjs +1 -1
  103. package/dist/theme-reference.d.cts +4 -2
  104. package/dist/theme-reference.d.ts +4 -2
  105. package/dist/theme-reference.js +1 -1
  106. package/dist/{tool-loading-animation-un3kA5Ur.d.cts → tool-loading-animation-d5qAQcwv.d.cts} +1 -1
  107. package/dist/{tool-loading-animation-un3kA5Ur.d.ts → tool-loading-animation-d5qAQcwv.d.ts} +1 -1
  108. package/dist/{turn-activity-ui-BGNhtMcH.d.cts → turn-activity-ui-q6fHVnx-.d.cts} +2 -2
  109. package/dist/{turn-activity-ui-BGNhtMcH.d.ts → turn-activity-ui-q6fHVnx-.d.ts} +2 -2
  110. package/dist/turn-activity-ui.d.cts +1 -1
  111. package/dist/turn-activity-ui.d.ts +1 -1
  112. package/dist/{types-BOZHQIk0.d.cts → types-B_mj21jg.d.cts} +91 -7
  113. package/dist/{types-BOZHQIk0.d.ts → types-B_mj21jg.d.ts} +91 -7
  114. package/dist/voice-client-tools.d.cts +1 -1
  115. package/dist/voice-client-tools.d.ts +1 -1
  116. package/dist/voice-runtime.cjs +1 -1
  117. package/dist/voice-runtime.d.cts +2 -2
  118. package/dist/voice-runtime.d.ts +2 -2
  119. package/dist/voice-runtime.js +1 -1
  120. package/dist/voice-worklet-player.d.cts +2 -2
  121. package/dist/voice-worklet-player.d.ts +2 -2
  122. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.cts → webmcp-runtime-entry-ClEO3qdI.d.cts} +1 -1
  123. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.ts → webmcp-runtime-entry-ClEO3qdI.d.ts} +1 -1
  124. package/dist/webmcp-runtime.d.cts +1 -1
  125. package/dist/webmcp-runtime.d.ts +1 -1
  126. package/dist/widget.css +1 -1
  127. package/package.json +1 -1
  128. package/src/client-dispatch.ts +69 -16
  129. package/src/client.operator-role.test.ts +217 -0
  130. package/src/components/message-bubble.test.ts +169 -0
  131. package/src/components/message-bubble.ts +97 -28
  132. package/src/defaults-parity.test.ts +14 -0
  133. package/src/index-core.ts +7 -0
  134. package/src/session.resubmit-from.test.ts +27 -0
  135. package/src/session.test.ts +10 -0
  136. package/src/session.ts +8 -5
  137. package/src/styles/widget.css +23 -1
  138. package/src/theme-reference.ts +3 -1
  139. package/src/types/theme.ts +21 -0
  140. package/src/types.ts +71 -6
  141. package/src/ui.ask-user-question-plugin.test.ts +154 -0
  142. package/src/ui.operator-role.test.ts +336 -0
  143. package/src/ui.ts +37 -12
  144. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +236 -0
  145. package/src/utils/brand.test.ts +14 -0
  146. package/src/utils/brand.ts +8 -4
  147. package/src/utils/history-messages.test.ts +30 -0
  148. package/src/utils/history-messages.ts +26 -8
  149. package/src/utils/message-fingerprint.ts +4 -0
  150. package/src/utils/resubmit-turn.test.ts +13 -0
  151. package/src/utils/resubmit-turn.ts +12 -1
  152. package/src/utils/tokens.ts +31 -0
  153. package/src/welcome.test.ts +16 -0
  154. package/src/welcome.ts +10 -2
  155. package/dist/chunk-BNE40cEG.js +0 -1
  156. package/dist/chunk-Bz9jAgba.js +0 -1
  157. package/dist/chunk-CdpVP6LZ.js +0 -1
  158. package/dist/chunk-CjkechxR.js +0 -1
@@ -689,3 +689,157 @@ describe("renderAskUserQuestion plugin hook", () => {
689
689
  controller.destroy();
690
690
  });
691
691
  });
692
+
693
+ describe("typing indicator while ask_user_question arguments stream", () => {
694
+ afterEach(() => {
695
+ document.body.innerHTML = "";
696
+ if (typeof localStorage !== "undefined") localStorage.clear();
697
+ vi.restoreAllMocks();
698
+ vi.unstubAllGlobals();
699
+ });
700
+
701
+ // Streaming transcript updates render once per animation frame; queue the
702
+ // frames so each injection can be rendered synchronously.
703
+ let frames = new Map<number, (time: number) => void>();
704
+ let nextFrameId = 1;
705
+ const renderFrame = () => {
706
+ const pending = [...frames.values()];
707
+ frames.clear();
708
+ pending.forEach((callback) => callback(0));
709
+ };
710
+
711
+ const mountStreaming = async (config: Record<string, unknown> = {}) => {
712
+ frames = new Map();
713
+ vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
714
+ const id = nextFrameId++;
715
+ frames.set(id, callback);
716
+ return id;
717
+ });
718
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
719
+ global.fetch = vi.fn().mockImplementation(
720
+ () => new Promise(() => {})
721
+ ) as unknown as typeof fetch;
722
+
723
+ const mount = createMount();
724
+ const controller = createAgentExperience(mount, {
725
+ apiUrl: "https://api.example.com/chat",
726
+ launcher: { enabled: false },
727
+ ...config,
728
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
729
+
730
+ controller.submitMessage("hello");
731
+ await Promise.resolve();
732
+ await Promise.resolve();
733
+ return { mount, controller };
734
+ };
735
+
736
+ // Stamped in the future so the rows sort after the submitted user message.
737
+ const sequence = Date.now() + 60_000;
738
+ const createdAt = new Date(sequence).toISOString();
739
+ const fullArgs = {
740
+ questions: [
741
+ { question: "Which audience?", options: [{ label: "Hobbyists" }, { label: "Pros" }] },
742
+ ],
743
+ };
744
+
745
+ const injectStreamingAsk = (
746
+ controller: ReturnType<typeof createAgentExperience>,
747
+ { chunks = [], args }: { chunks?: string[]; args?: unknown } = {}
748
+ ) => {
749
+ controller.injectTestMessage({
750
+ type: "message",
751
+ message: {
752
+ id: "ask-1",
753
+ role: "assistant",
754
+ content: "",
755
+ createdAt,
756
+ sequence,
757
+ streaming: true,
758
+ variant: "tool",
759
+ toolCall: { id: "ask-1", name: "ask_user_question", status: "running", chunks, args },
760
+ },
761
+ });
762
+ renderFrame();
763
+ };
764
+
765
+ const injectAwaitingAsk = (controller: ReturnType<typeof createAgentExperience>) => {
766
+ controller.injectTestMessage({
767
+ type: "message",
768
+ message: {
769
+ id: "ask-1",
770
+ role: "assistant",
771
+ content: "",
772
+ createdAt,
773
+ sequence,
774
+ streaming: false,
775
+ variant: "tool",
776
+ toolCall: { id: "ask-1", name: "ask_user_question", status: "complete", chunks: [], args: fullArgs },
777
+ agentMetadata: { executionId: "exec_123", awaitingLocalTool: true },
778
+ },
779
+ });
780
+ renderFrame();
781
+ };
782
+
783
+ const typingIndicator = (mount: HTMLElement) =>
784
+ mount.querySelector('[data-typing-indicator="true"]');
785
+ const askSheet = (mount: HTMLElement) => mount.querySelector("[data-persona-ask-sheet-for]");
786
+
787
+ it("keeps the dots until the built-in sheet mounts when no plugin renders the question", async () => {
788
+ const { mount, controller } = await mountStreaming();
789
+
790
+ injectStreamingAsk(controller);
791
+ expect(askSheet(mount)).toBeNull();
792
+ expect(typingIndicator(mount)).not.toBeNull();
793
+
794
+ injectAwaitingAsk(controller);
795
+ expect(askSheet(mount)).not.toBeNull();
796
+ expect(typingIndicator(mount)).toBeNull();
797
+
798
+ controller.destroy();
799
+ });
800
+
801
+ it("keeps the dots while the plugin returns null for a partial payload", async () => {
802
+ const plugin: AgentWidgetPlugin = {
803
+ id: "partial-asker",
804
+ renderAskUserQuestion: ({ payload }) => {
805
+ if (!payload?.questions?.[0]?.options?.length) return null;
806
+ const root = document.createElement("div");
807
+ root.setAttribute("data-test-id", "partial-ask");
808
+ return root;
809
+ },
810
+ };
811
+ const { mount, controller } = await mountStreaming({ plugins: [plugin] });
812
+
813
+ injectStreamingAsk(controller, { chunks: ['{"questions":[{"question":"Which'] });
814
+ expect(mount.querySelector('[data-test-id="partial-ask"]')).toBeNull();
815
+ expect(askSheet(mount)).toBeNull();
816
+ expect(typingIndicator(mount)).not.toBeNull();
817
+
818
+ injectStreamingAsk(controller, { args: fullArgs });
819
+ expect(mount.querySelector('[data-test-id="partial-ask"]')).not.toBeNull();
820
+ expect(typingIndicator(mount)).toBeNull();
821
+
822
+ controller.destroy();
823
+ });
824
+
825
+ it.each([true, false])("hides the dots as soon as the plugin renders a bubble (showToolCalls=%s)", async (showToolCalls) => {
826
+ const plugin: AgentWidgetPlugin = {
827
+ id: "eager-asker",
828
+ renderAskUserQuestion: () => {
829
+ const root = document.createElement("div");
830
+ root.setAttribute("data-test-id", "eager-ask");
831
+ return root;
832
+ },
833
+ };
834
+ const { mount, controller } = await mountStreaming({
835
+ plugins: [plugin],
836
+ features: { showToolCalls },
837
+ });
838
+
839
+ injectStreamingAsk(controller);
840
+ expect(mount.querySelector('[data-test-id="eager-ask"]')).not.toBeNull();
841
+ expect(typingIndicator(mount)).toBeNull();
842
+
843
+ controller.destroy();
844
+ });
845
+ });
@@ -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
+ });
package/src/ui.ts CHANGED
@@ -112,6 +112,7 @@ import {
112
112
  type MessageEditEditor
113
113
  } from "./components/message-edit-editor";
114
114
  import {
115
+ canResubmitUserMessage,
115
116
  findPrecedingUserMessageId,
116
117
  findRetryableAssistantMessageId,
117
118
  isEditableUserMessage
@@ -1408,8 +1409,9 @@ export const createAgentExperience = (
1408
1409
  const messageActionCallbacks: MessageActionCallbacks = {
1409
1410
  onCopy: (message: AgentWidgetMessage) => {
1410
1411
  eventBus.emit("message:copy", message);
1411
- // Send copy feedback to API if in client token mode
1412
- if (session?.isClientTokenMode()) {
1412
+ // Send copy feedback to API if in client token mode. Operator messages
1413
+ // are host-injected and unknown to the API, so they never report.
1414
+ if (session?.isClientTokenMode() && message.role !== "operator") {
1413
1415
  session.submitMessageFeedback(message.id, 'copy').catch((error) => {
1414
1416
  if (config.debug) {
1415
1417
  // eslint-disable-next-line no-console
@@ -6097,21 +6099,26 @@ export const createAgentExperience = (
6097
6099
  wrapper: HTMLElement,
6098
6100
  role: AgentWidgetMessage["role"]
6099
6101
  ): void => {
6102
+ // Operator rows sit on the assistant's side and share its max width, but
6103
+ // always shrink-wrap: a full-column operator bubble reads as an input.
6100
6104
  const sizingRole = role === "user" ? "user" : "assistant";
6101
6105
  const roleLayout = config.layout?.messages?.[sizingRole];
6102
- const width = roleLayout?.width ?? "content";
6106
+ const configuredWidth = roleLayout?.width ?? "content";
6107
+ const width = role === "operator" ? "content" : configuredWidth;
6103
6108
  // Only an explicit config value is stamped inline; the 85%/100% defaults
6104
6109
  // live in widget.css so `components.message.<role>.maxWidth` can win.
6110
+ const fullMaxWidth = role === "operator"
6111
+ ? "var(--persona-message-operator-max-width, var(--persona-message-assistant-max-width, 100%))"
6112
+ : `var(--persona-message-${sizingRole}-max-width, 100%)`;
6105
6113
  const maxWidth =
6106
- roleLayout?.maxWidth ?? (width === "full"
6107
- ? `var(--persona-message-${sizingRole}-max-width, 100%)`
6108
- : undefined);
6114
+ roleLayout?.maxWidth ?? (configuredWidth === "full" ? fullMaxWidth : undefined);
6109
6115
 
6110
6116
  wrapper.classList.add("persona-message-row");
6111
6117
  wrapper.classList.remove(
6112
6118
  "persona-message-row-user",
6113
6119
  "persona-message-row-assistant",
6114
6120
  "persona-message-row-system",
6121
+ "persona-message-row-operator",
6115
6122
  "persona-message-width-content",
6116
6123
  "persona-message-width-full"
6117
6124
  );
@@ -6248,6 +6255,10 @@ export const createAgentExperience = (
6248
6255
  ? findRetryableAssistantMessageId(messages)
6249
6256
  : null;
6250
6257
  const editEnabled = actionsCfg?.showEdit === true && actionsCfg.enabled !== false;
6258
+ let lastOperatorIndex = -1;
6259
+ messages.forEach((message, index) => {
6260
+ if (message.role === "operator") lastOperatorIndex = index;
6261
+ });
6251
6262
  const quoteEnabled = actionsCfg?.showQuote === true && actionsCfg.enabled !== false;
6252
6263
  /** The live editor, re-grafted into its morphed wrapper after the pass. */
6253
6264
  let editHydrate: { messageId: string; element: HTMLElement } | null = null;
@@ -6289,7 +6300,7 @@ export const createAgentExperience = (
6289
6300
  );
6290
6301
  };
6291
6302
 
6292
- messages.forEach((message) => {
6303
+ messages.forEach((message, messageIndex) => {
6293
6304
  if (isDroppedCompletedRow(message)) {
6294
6305
  return;
6295
6306
  }
@@ -6356,7 +6367,7 @@ export const createAgentExperience = (
6356
6367
  isLastAssistant: message.id === lastAssistantId,
6357
6368
  canRegenerate: retryableAssistantId === message.id &&
6358
6369
  (actionsCfg?.showRegenerate === true || message.dispatchError === true),
6359
- canEdit: editEnabled && isEditableUserMessage(message),
6370
+ canEdit: editEnabled && messageIndex > lastOperatorIndex && isEditableUserMessage(message),
6360
6371
  canQuote:
6361
6372
  quoteEnabled && !message.variant && (message.content?.trim().length ?? 0) > 0,
6362
6373
  };
@@ -6957,10 +6968,23 @@ export const createAgentExperience = (
6957
6968
  // "the assistant is already visibly responding": otherwise the transcript
6958
6969
  // goes blank for the whole tool phase. When those rows are shown, their own
6959
6970
  // bubble carries the loading animation and the standalone dots stay hidden.
6971
+ //
6972
+ // A pending `ask_user_question` call is visible only once its question has
6973
+ // rendered: a plugin bubble mounted for it this pass, or the built-in sheet
6974
+ // mounted for its tool call. Until then (arguments still streaming, a plugin
6975
+ // returning null for a partial payload, the sheet waiting on the pause) it
6976
+ // renders nothing, so it must not hide the dots either.
6977
+ const isUnrenderedAskRow = (msg: AgentWidgetMessage): boolean =>
6978
+ !(msg.toolCall?.id && liveAskToolIds.has(msg.toolCall.id)) &&
6979
+ !askPluginHydrate.some((entry) => entry.messageId === msg.id);
6960
6980
  const isHiddenActivityRow = (msg: AgentWidgetMessage): boolean =>
6961
- (msg.variant === "tool" && !showToolCalls) ||
6962
- (msg.variant === "reasoning" && !showReasoning) ||
6963
- isDroppedCompletedRow(msg);
6981
+ isAskUserQuestionMessage(msg) &&
6982
+ msg.agentMetadata?.askUserQuestionAnswered !== true &&
6983
+ config.features?.askUserQuestion?.enabled !== false
6984
+ ? isUnrenderedAskRow(msg)
6985
+ : (msg.variant === "tool" && !showToolCalls) ||
6986
+ (msg.variant === "reasoning" && !showReasoning) ||
6987
+ isDroppedCompletedRow(msg);
6964
6988
  const hasStreamingAssistantMessage = messages.some(
6965
6989
  (msg) => msg.role === "assistant" && msg.streaming && !isHiddenActivityRow(msg)
6966
6990
  );
@@ -10053,7 +10077,8 @@ export const createAgentExperience = (
10053
10077
  function beginMessageEdit(messageId: string): void {
10054
10078
  if (config.messageActions?.showEdit !== true) return;
10055
10079
  const message = session.getMessages().find((m) => m.id === messageId);
10056
- if (!message || !isEditableUserMessage(message)) return;
10080
+ if (!message || !isEditableUserMessage(message) ||
10081
+ !canResubmitUserMessage(session.getMessages(), messageId)) return;
10057
10082
  if (editingMessageId === messageId) return;
10058
10083
  cancelMessageEdit();
10059
10084
  editingMessageId = messageId;