@runtypelabs/persona 4.23.2 → 4.25.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 (166) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-CI7OckWI.d.cts → types-Db61aI9J.d.cts} +2 -0
  4. package/dist/animations/{types-CI7OckWI.d.ts → types-Db61aI9J.d.ts} +2 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +278 -37
  12. package/dist/approval-ui.d.ts +278 -37
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
  15. package/dist/artifacts-ui.d.cts +273 -25
  16. package/dist/artifacts-ui.d.ts +273 -25
  17. package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
  18. package/dist/codegen.cjs +11 -11
  19. package/dist/codegen.js +13 -13
  20. package/dist/context-mentions.d.cts +273 -25
  21. package/dist/context-mentions.d.ts +273 -25
  22. package/dist/event-stream-view.d.cts +273 -25
  23. package/dist/event-stream-view.d.ts +273 -25
  24. package/dist/forms-ui.d.cts +319 -27
  25. package/dist/forms-ui.d.ts +319 -27
  26. package/dist/history-view.d.cts +2 -0
  27. package/dist/history-view.d.ts +2 -0
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +344 -39
  31. package/dist/index.d.ts +344 -39
  32. package/dist/index.global.js +57 -54
  33. package/dist/index.global.js.map +1 -1
  34. package/dist/index.js +70 -69
  35. package/dist/index.js.map +1 -1
  36. package/dist/install.global.js +1 -1
  37. package/dist/install.global.js.map +1 -1
  38. package/dist/launcher.global.js +2 -2
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/session-reconnect.d.cts +273 -25
  41. package/dist/session-reconnect.d.ts +273 -25
  42. package/dist/smart-dom-reader.d.cts +273 -25
  43. package/dist/smart-dom-reader.d.ts +273 -25
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +280 -25
  46. package/dist/theme-editor-preview.d.ts +280 -25
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +293 -27
  50. package/dist/theme-editor.d.ts +293 -27
  51. package/dist/theme-editor.js +10 -10
  52. package/dist/theme-reference.cjs +1 -1
  53. package/dist/theme-reference.d.cts +1522 -11
  54. package/dist/theme-reference.d.ts +1522 -11
  55. package/dist/theme-reference.js +1 -1
  56. package/dist/voice-runtime-7DA7MJH4.js +1 -0
  57. package/dist/voice-runtime.cjs +1 -1
  58. package/dist/voice-runtime.d.cts +33 -0
  59. package/dist/voice-runtime.d.ts +33 -0
  60. package/dist/voice-runtime.js +1 -1
  61. package/dist/widget.css +1 -1
  62. package/package.json +3 -2
  63. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  64. package/src/approval-ui-entry.ts +0 -1
  65. package/src/approval-ui-loader.ts +5 -4
  66. package/src/approval-ui.ts +0 -1
  67. package/src/client-identity-proof.test.ts +193 -0
  68. package/src/client-session-warm.test.ts +216 -0
  69. package/src/client-visitor-history.test.ts +6 -0
  70. package/src/client.ts +122 -30
  71. package/src/codegen.test.ts +24 -0
  72. package/src/components/activity-row.test.ts +100 -0
  73. package/src/components/activity-row.ts +136 -0
  74. package/src/components/approval-actions.test.ts +8 -5
  75. package/src/components/approval-actions.ts +19 -6
  76. package/src/components/approval-bubble.test.ts +56 -9
  77. package/src/components/approval-bubble.ts +36 -41
  78. package/src/components/composer-builder.ts +5 -2
  79. package/src/components/composer-parts.ts +10 -2
  80. package/src/components/header-builder.test.ts +32 -0
  81. package/src/components/header-builder.ts +7 -4
  82. package/src/components/header-layouts.test.ts +12 -0
  83. package/src/components/header-layouts.ts +3 -2
  84. package/src/components/launcher.ts +11 -2
  85. package/src/components/message-bubble.test.ts +22 -0
  86. package/src/components/message-bubble.ts +7 -3
  87. package/src/components/panel.test.ts +52 -1
  88. package/src/components/panel.ts +47 -8
  89. package/src/components/pill-composer-builder.test.ts +2 -0
  90. package/src/components/pill-composer-builder.ts +0 -1
  91. package/src/components/reasoning-bubble.ts +29 -17
  92. package/src/components/suggestions.test.ts +1 -0
  93. package/src/components/suggestions.ts +0 -16
  94. package/src/components/tool-bubble.ts +131 -114
  95. package/src/components/tool-details.test.ts +87 -0
  96. package/src/components/tool-details.ts +102 -0
  97. package/src/components/welcome.test.ts +8 -0
  98. package/src/components/welcome.ts +4 -2
  99. package/src/defaults-parity.test.ts +158 -0
  100. package/src/defaults.test.ts +69 -0
  101. package/src/defaults.ts +128 -49
  102. package/src/generated/runtype-openapi-contract.ts +2 -2
  103. package/src/index-core.ts +4 -0
  104. package/src/install.test.ts +24 -0
  105. package/src/install.ts +3 -1
  106. package/src/launcher-global.ts +2 -1
  107. package/src/launcher-variants.test.ts +52 -0
  108. package/src/runtime/init-update-reset.test.ts +21 -0
  109. package/src/session.ts +59 -7
  110. package/src/session.voice.test.ts +72 -1
  111. package/src/styles/widget-styles.test.ts +19 -0
  112. package/src/styles/widget.css +295 -23
  113. package/src/theme-editor/index.ts +2 -0
  114. package/src/theme-editor/preview-utils.test.ts +8 -0
  115. package/src/theme-editor/preview-utils.ts +7 -3
  116. package/src/theme-editor/sections.test.ts +31 -0
  117. package/src/theme-editor/sections.ts +55 -1
  118. package/src/theme-editor/state-preferences.test.ts +49 -0
  119. package/src/theme-editor/state.ts +73 -15
  120. package/src/theme-reference.test.ts +20 -0
  121. package/src/theme-reference.ts +11 -5
  122. package/src/tool-call-display-defaults.test.ts +33 -1
  123. package/src/types/theme.ts +80 -3
  124. package/src/types.ts +207 -22
  125. package/src/ui.activity-row.test.ts +91 -0
  126. package/src/ui.approval-plugin.test.ts +63 -0
  127. package/src/ui.composer-compact.test.ts +24 -2
  128. package/src/ui.composer-placement.test.ts +23 -0
  129. package/src/ui.detached-panel.test.ts +33 -6
  130. package/src/ui.docked.test.ts +19 -0
  131. package/src/ui.header-update-stability.test.ts +25 -0
  132. package/src/ui.identity-proof.test.ts +58 -0
  133. package/src/ui.launcher-teaser.test.ts +25 -0
  134. package/src/ui.message-width.test.ts +17 -2
  135. package/src/ui.reasoning-display.test.ts +92 -0
  136. package/src/ui.rebuild-composer.test.ts +20 -3
  137. package/src/ui.scroll.test.ts +32 -0
  138. package/src/ui.session-init.test.ts +312 -0
  139. package/src/ui.status-indicator.test.ts +82 -0
  140. package/src/ui.suggest-replies.test.ts +11 -0
  141. package/src/ui.ts +422 -74
  142. package/src/ui.v5-defaults.test.ts +144 -0
  143. package/src/ui.voice-prewarm.test.ts +222 -0
  144. package/src/ui.welcome.test.ts +92 -3
  145. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  146. package/src/utils/code-generators.ts +14 -0
  147. package/src/utils/composer-compact.test.ts +0 -2
  148. package/src/utils/composer-compact.ts +2 -4
  149. package/src/utils/composer-placement.ts +2 -3
  150. package/src/utils/config-merge.test.ts +81 -5
  151. package/src/utils/config-merge.ts +13 -3
  152. package/src/utils/defaults-provenance.ts +67 -0
  153. package/src/utils/dock.ts +7 -1
  154. package/src/utils/launcher-variant.ts +16 -0
  155. package/src/utils/panel-config.ts +97 -0
  156. package/src/utils/status-indicator.ts +39 -0
  157. package/src/utils/theme.test.ts +182 -0
  158. package/src/utils/theme.ts +131 -18
  159. package/src/utils/tokens.test.ts +122 -1
  160. package/src/utils/tokens.ts +177 -17
  161. package/src/voice/runtype-voice-provider.ts +238 -27
  162. package/src/voice/voice.test.ts +272 -2
  163. package/src/welcome.test.ts +23 -0
  164. package/src/welcome.ts +16 -9
  165. package/dist/approval-ui-K7R5Q5OC.js +0 -1
  166. package/dist/voice-runtime-EFR3R5ES.js +0 -1
@@ -0,0 +1,144 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createAgentExperience } from "./ui";
4
+
5
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
6
+ afterEach(() => {
7
+ controllers.splice(0).forEach((controller) => controller.destroy());
8
+ document.body.replaceChildren();
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe.each([false, true])("core defaults lifecycle (v5=%s)", (v5Defaults) => {
13
+ it("applies a live defaults switch to existing messages and header", () => {
14
+ window.scrollTo = vi.fn();
15
+ const mount = document.createElement("div");
16
+ document.body.append(mount);
17
+ const controller = createAgentExperience(mount, {
18
+ apiUrl: "/dispatch", persistState: false,
19
+ future: { v5Defaults }, launcher: { autoExpand: true },
20
+ });
21
+ controllers.push(controller);
22
+ controller.injectUserMessage({ content: "Hello" });
23
+ controller.injectAssistantMessage({ content: "Welcome" });
24
+ const assertState = (v5: boolean) => {
25
+ expect(mount.dataset.personaDefaults).toBe(v5 ? "v5" : "v4");
26
+ const header = mount.querySelector<HTMLElement>(".persona-widget-header")!;
27
+ expect((header.firstElementChild as HTMLElement).style.width).toBe(v5 ? "20px" : "40px");
28
+ expect(mount.style.getPropertyValue("--persona-components-header-padding"))
29
+ .toBe(v5 ? "8px 8px 8px 16px" : "20px 24px");
30
+ const bubble = mount.querySelector<HTMLElement>(".persona-message-row-assistant .persona-message-bubble")!;
31
+ expect(bubble.style.lineHeight).toBe(v5 ? "var(--persona-message-assistant-line-height, 1.75)" : "");
32
+ const status = mount.querySelector<HTMLElement>("[data-persona-composer-status]")!;
33
+ expect(status.style.display).toBe(v5 ? "none" : "");
34
+ };
35
+ assertState(v5Defaults);
36
+ controller.update({ future: { v5Defaults: !v5Defaults } });
37
+ assertState(!v5Defaults);
38
+ controller.update({ future: { v5Defaults } });
39
+ assertState(v5Defaults);
40
+ });
41
+
42
+ it("exposes composer chrome and honors explicit glyph overrides", () => {
43
+ window.scrollTo = vi.fn();
44
+ const mount = document.createElement("div");
45
+ document.body.append(mount);
46
+ const controller = createAgentExperience(mount, {
47
+ apiUrl: "/dispatch", persistState: false, future: { v5Defaults },
48
+ launcher: { autoExpand: true },
49
+ });
50
+ controllers.push(controller);
51
+ expect(mount.style.getPropertyValue("--persona-components-composer-sendIconSize"))
52
+ .toBe(v5Defaults ? "18px" : "");
53
+ expect(mount.style.getPropertyValue("--persona-components-composer-sendButtonRadius"))
54
+ .toBe(v5Defaults ? "9999px" : "");
55
+ expect(mount.style.getPropertyValue("--persona-components-composer-footerBorder"))
56
+ .toBe(v5Defaults ? "none" : "");
57
+ controller.update({
58
+ sendButton: { iconName: "send", iconSize: "15px" },
59
+ theme: { components: { composer: {
60
+ sendIconSize: "22px", sendButtonRadius: "8px", footerBorder: "2px solid red",
61
+ } } },
62
+ });
63
+ const svg = mount.querySelector<SVGElement>("[data-persona-composer-submit] svg")!;
64
+ expect(svg.getAttribute("width")).toBe("15");
65
+ expect(svg.style.width).toBe("");
66
+ expect(mount.style.getPropertyValue("--persona-components-composer-sendButtonRadius")).toBe("8px");
67
+ expect(mount.style.getPropertyValue("--persona-components-composer-footerBorder")).toBe("2px solid red");
68
+ });
69
+
70
+ it("switches composer focus defaults live and retains explicit focus and resting chrome", () => {
71
+ window.scrollTo = vi.fn();
72
+ const mount = document.createElement("div");
73
+ document.body.append(mount);
74
+ const controller = createAgentExperience(mount, {
75
+ apiUrl: "/dispatch", persistState: false, future: { v5Defaults },
76
+ launcher: { autoExpand: true },
77
+ theme: { components: { composer: { borderColor: "#123456", shadow: "0 3px 8px #123456" } } },
78
+ });
79
+ controllers.push(controller);
80
+ const assertDefaults = (v5: boolean) => {
81
+ expect(mount.style.getPropertyValue("--persona-components-composer-focusBorderColor"))
82
+ .toBe(v5 ? "color-mix(in srgb, var(--persona-text) 28%, var(--persona-border))" : "");
83
+ expect(mount.style.getPropertyValue("--persona-components-composer-focusRing"))
84
+ .toBe(v5 ? "1px solid color-mix(in srgb, var(--persona-text) 12%, transparent)" : "");
85
+ expect(mount.style.getPropertyValue("--persona-composer-border-color")).toBe("#123456");
86
+ expect(mount.style.getPropertyValue("--persona-composer-shadow")).toBe("0 3px 8px #123456");
87
+ // No inline outline may suppress the stylesheet's ring.
88
+ expect(mount.querySelector<HTMLElement>("[data-persona-composer-form]")!.style.outline).toBe("");
89
+ };
90
+ assertDefaults(v5Defaults);
91
+ controller.update({ future: { v5Defaults: !v5Defaults } });
92
+ assertDefaults(!v5Defaults);
93
+ controller.update({ theme: { components: { composer: {
94
+ focusBorderColor: "#abcdef", focusRing: "2px solid #fedcba",
95
+ } } } });
96
+ for (const state of [v5Defaults, !v5Defaults]) {
97
+ controller.update({ future: { v5Defaults: state } });
98
+ expect(mount.style.getPropertyValue("--persona-components-composer-focusBorderColor")).toBe("#abcdef");
99
+ expect(mount.style.getPropertyValue("--persona-components-composer-focusRing")).toBe("2px solid #fedcba");
100
+ }
101
+ controller.update({ theme: { components: { composer: { focusRing: "none" } } } });
102
+ expect(mount.style.getPropertyValue("--persona-components-composer-focusRing")).toBe("none");
103
+ });
104
+
105
+ it("switches default placement and fade while retaining explicit choices", () => {
106
+ window.scrollTo = vi.fn();
107
+ const mount = document.createElement("div");
108
+ document.body.append(mount);
109
+ const controller = createAgentExperience(mount, {
110
+ apiUrl: "/dispatch", persistState: false, future: { v5Defaults },
111
+ launcher: { autoExpand: true },
112
+ });
113
+ controllers.push(controller);
114
+ for (const state of [v5Defaults, !v5Defaults]) {
115
+ controller.update({ future: { v5Defaults: state } });
116
+ expect(mount.dataset.personaComposerPlacement).toBe(state ? "overlay" : "block");
117
+ expect(mount.style.getPropertyValue("--persona-composer-overlay-band"))
118
+ .toBe(state ? "linear-gradient(to bottom, transparent, var(--persona-container) 24px)" : "transparent");
119
+ }
120
+ controller.update({ composer: { placement: "block" }, theme: { components: { composer: { overlayBand: "transparent" } } } });
121
+ controller.update({ future: { v5Defaults: !v5Defaults } });
122
+ expect(mount.dataset.personaComposerPlacement).toBe("block");
123
+ expect(mount.style.getPropertyValue("--persona-composer-overlay-band")).toBe("transparent");
124
+ });
125
+
126
+ it("uses fullscreen turn spacing in full-height embeds and honors explicit spacing", () => {
127
+ window.scrollTo = vi.fn();
128
+ const mount = document.createElement("div");
129
+ document.body.append(mount);
130
+ const controller = createAgentExperience(mount, {
131
+ apiUrl: "/dispatch", persistState: false, future: { v5Defaults },
132
+ launcher: { enabled: false, fullHeight: true },
133
+ });
134
+ controllers.push(controller);
135
+ const messages = mount.querySelector<HTMLElement>(".persona-widget-messages")!;
136
+ expect(messages.style.gap).toContain("--persona-components-message-fullscreenGap");
137
+ expect(mount.style.getPropertyValue("--persona-components-message-fullscreenGap"))
138
+ .toBe(v5Defaults ? "28px" : "");
139
+ controller.update({ theme: { components: { message: { gap: "33px" } } } });
140
+ expect(mount.style.getPropertyValue("--persona-components-message-fullscreenGap")).toBe("33px");
141
+ controller.update({ theme: { components: { message: { fullscreenGap: "42px" } } } });
142
+ expect(mount.style.getPropertyValue("--persona-components-message-fullscreenGap")).toBe("42px");
143
+ });
144
+ });
@@ -0,0 +1,222 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createAgentExperience } from "./ui";
5
+ import type {
6
+ AgentWidgetConfig,
7
+ AgentWidgetVoicePrewarmHook,
8
+ AgentWidgetVoiceRecognitionConfig,
9
+ } from "./types";
10
+
11
+ let controller: ReturnType<typeof createAgentExperience> | undefined;
12
+
13
+ beforeEach(() => {
14
+ window.scrollTo = vi.fn();
15
+ });
16
+
17
+ afterEach(() => {
18
+ controller?.destroy();
19
+ controller = undefined;
20
+ document.body.replaceChildren();
21
+ vi.restoreAllMocks();
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ const initResponse = () =>
26
+ Response.json({
27
+ sessionId: "session-1",
28
+ expiresAt: new Date(Date.now() + 600_000).toISOString(),
29
+ flow: { id: "agent-1", name: "Agent", description: null },
30
+ config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
31
+ });
32
+
33
+ const runtypeVoice = (
34
+ extra: Partial<AgentWidgetVoiceRecognitionConfig> = {}
35
+ ): AgentWidgetVoiceRecognitionConfig => ({
36
+ enabled: true,
37
+ provider: { type: "runtype", runtype: { agentId: "agent-1" } },
38
+ ...extra,
39
+ });
40
+
41
+ function mountWidget(config: Partial<AgentWidgetConfig>) {
42
+ const prewarms: Array<{ url: string; init?: RequestInit }> = [];
43
+ const inits: string[] = [];
44
+ vi.stubGlobal(
45
+ "fetch",
46
+ vi.fn(async (url: string, init?: RequestInit) => {
47
+ if (url.endsWith("/voice/prewarm")) {
48
+ prewarms.push({ url, init });
49
+ return new Response(null, { status: 204 });
50
+ }
51
+ if (url.endsWith("/v1/client/init")) {
52
+ inits.push(url);
53
+ return initResponse();
54
+ }
55
+ return new Response('data: {"type":"done"}\n\n', {
56
+ headers: { "Content-Type": "text/event-stream" },
57
+ });
58
+ })
59
+ );
60
+ const mount = document.createElement("div");
61
+ document.body.appendChild(mount);
62
+ controller = createAgentExperience(mount, {
63
+ apiUrl: "https://api.example.com",
64
+ agentId: "agent-1",
65
+ launcher: { enabled: false },
66
+ persistState: false,
67
+ ...config,
68
+ } as AgentWidgetConfig);
69
+ const mic = () => mount.querySelector<HTMLButtonElement>("[data-persona-composer-mic]")!;
70
+ const hover = () => mic().dispatchEvent(new Event("pointerover", { bubbles: true }));
71
+ return { mount, mic, hover, prewarms, inits };
72
+ }
73
+
74
+ // Lets the lazy voice runtime install and any fire-and-forget fetch run.
75
+ const settle = async () => {
76
+ for (let i = 0; i < 5; i += 1) await new Promise((r) => setTimeout(r, 0));
77
+ };
78
+
79
+ describe("voiceRecognition.prewarm: default", () => {
80
+ it("prewarms once on mic hover in client token mode (latched)", async () => {
81
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
82
+ await settle();
83
+ expect(w.prewarms).toHaveLength(0);
84
+
85
+ w.hover();
86
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
87
+ expect(w.prewarms[0].url).toBe(
88
+ "https://api.example.com/v1/client/agents/agent-1/voice/prewarm"
89
+ );
90
+ expect(w.prewarms[0].init?.headers).toEqual({ Authorization: "Bearer ct_test" });
91
+
92
+ w.hover();
93
+ w.mic().dispatchEvent(new Event("pointerdown", { bubbles: true }));
94
+ w.mic().dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
95
+ await settle();
96
+ expect(w.prewarms).toHaveLength(1);
97
+ });
98
+
99
+ it("also prewarms on keyboard focus", async () => {
100
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
101
+ await settle();
102
+ w.mic().dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
103
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
104
+ });
105
+
106
+ it("ignores hover elsewhere in the widget", async () => {
107
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
108
+ await settle();
109
+ w.mount
110
+ .querySelector("[data-persona-composer-input]")!
111
+ .dispatchEvent(new Event("pointerover", { bubbles: true }));
112
+ await settle();
113
+ expect(w.prewarms).toHaveLength(0);
114
+ });
115
+
116
+ it("does not auto-prewarm outside client token mode", async () => {
117
+ const w = mountWidget({
118
+ voiceRecognition: {
119
+ enabled: true,
120
+ provider: { type: "runtype", runtype: { agentId: "agent-1", clientToken: "ct_voice" } },
121
+ },
122
+ });
123
+ await settle();
124
+ w.hover();
125
+ await settle();
126
+ expect(w.prewarms).toHaveLength(0);
127
+ });
128
+
129
+ it("warms the client session on the browser (Web Speech) path", async () => {
130
+ vi.stubGlobal("SpeechRecognition", vi.fn());
131
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: { enabled: true } });
132
+ await settle();
133
+ expect(w.inits).toHaveLength(0);
134
+ w.hover();
135
+ await vi.waitFor(() => expect(w.inits).toHaveLength(1));
136
+ expect(w.prewarms).toHaveLength(0);
137
+ });
138
+ });
139
+
140
+ describe("voiceRecognition.prewarm: explicit settings", () => {
141
+ it("false opts out", async () => {
142
+ const w = mountWidget({
143
+ clientToken: "ct_test",
144
+ voiceRecognition: runtypeVoice({ prewarm: false }),
145
+ });
146
+ await settle();
147
+ w.hover();
148
+ await settle();
149
+ expect(w.prewarms).toHaveLength(0);
150
+ });
151
+
152
+ it("'hover' enables it outside client token mode", async () => {
153
+ const w = mountWidget({
154
+ voiceRecognition: {
155
+ enabled: true,
156
+ prewarm: "hover",
157
+ provider: { type: "runtype", runtype: { agentId: "agent-1", clientToken: "ct_voice" } },
158
+ },
159
+ });
160
+ await settle();
161
+ w.hover();
162
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
163
+ expect(w.prewarms[0].init?.headers).toEqual({ Authorization: "Bearer ct_voice" });
164
+ });
165
+
166
+ it("a custom hook receives warm and the mic button, replaces hover, and is cleaned up", async () => {
167
+ const cleanup = vi.fn();
168
+ let context: Parameters<AgentWidgetVoicePrewarmHook>[0] | undefined;
169
+ const hook = vi.fn((ctx: Parameters<AgentWidgetVoicePrewarmHook>[0]) => {
170
+ context = ctx;
171
+ return cleanup;
172
+ });
173
+ const w = mountWidget({
174
+ clientToken: "ct_test",
175
+ voiceRecognition: runtypeVoice({ prewarm: hook }),
176
+ });
177
+ await settle();
178
+ expect(hook).toHaveBeenCalledOnce();
179
+ expect(context?.micButton).toBe(w.mic());
180
+ expect(context?.mount).toBe(w.mount);
181
+
182
+ w.hover();
183
+ await settle();
184
+ expect(w.prewarms).toHaveLength(0);
185
+
186
+ context!.warm();
187
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
188
+
189
+ controller!.destroy();
190
+ controller = undefined;
191
+ expect(cleanup).toHaveBeenCalledOnce();
192
+ });
193
+
194
+ it("update() swaps the hook and turning prewarm off releases it", async () => {
195
+ const cleanupA = vi.fn();
196
+ const hookA = vi.fn(() => cleanupA);
197
+ const hookB = vi.fn();
198
+ mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice({ prewarm: hookA }) });
199
+ await settle();
200
+
201
+ controller!.update({ voiceRecognition: runtypeVoice({ prewarm: hookB }) });
202
+ expect(cleanupA).toHaveBeenCalledOnce();
203
+ expect(hookB).toHaveBeenCalledOnce();
204
+
205
+ controller!.update({ voiceRecognition: runtypeVoice({ prewarm: false }) });
206
+ expect(hookB).toHaveBeenCalledOnce();
207
+ expect(hookA).toHaveBeenCalledOnce();
208
+ });
209
+
210
+ it("a throwing hook does not break mount", async () => {
211
+ const w = mountWidget({
212
+ clientToken: "ct_test",
213
+ voiceRecognition: runtypeVoice({
214
+ prewarm: () => {
215
+ throw new Error("host bug");
216
+ },
217
+ }),
218
+ });
219
+ await settle();
220
+ expect(w.mic()).toBeTruthy();
221
+ });
222
+ });
@@ -328,8 +328,8 @@ describe("welcome visibility state machine", () => {
328
328
  });
329
329
 
330
330
  describe("welcome greeting bubble", () => {
331
- it("renders an assistant-styled bubble above the transcript", () => {
332
- const { mount } = makeController({ welcome: { message: "Hi, I'm Ada." } });
331
+ it.each([false, true])("renders an assistant-styled bubble above the transcript (v5=%s)", (v5Defaults) => {
332
+ const { mount } = makeController({ future: { v5Defaults }, welcome: { message: "Hi, I'm Ada." } });
333
333
  const greeting = greetingHost(mount);
334
334
  expect(isVisible(greeting)).toBe(true);
335
335
  expect(greeting.textContent).toBe("Hi, I'm Ada.");
@@ -342,7 +342,7 @@ describe("welcome greeting bubble", () => {
342
342
  const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
343
343
  const children = Array.from(body.children);
344
344
  const transcript = body.querySelector<HTMLElement>(
345
- ":scope > .persona-gap-3"
345
+ ":scope > .persona-widget-messages"
346
346
  )!;
347
347
  expect(children.indexOf(greeting)).toBeLessThan(
348
348
  children.indexOf(transcript)
@@ -508,3 +508,92 @@ describe("welcome live updates", () => {
508
508
  expect(isVisible(welcomeHost(mount))).toBe(true);
509
509
  });
510
510
  });
511
+
512
+ describe("centered welcome", () => {
513
+ it("groups legacy starters in the V5 welcome and restores them after clear", () => {
514
+ const { mount, controller } = makeController({ future: { v5Defaults: true }, suggestionChips: ["One", "Two", "Three", "Four", "Five"] });
515
+ const host = welcomeHost(mount);
516
+ expect(host.getAttribute("data-persona-welcome-layout")).toBe("centered");
517
+ expect(host.querySelector("h2")?.textContent).toBe("What can I help with?");
518
+ expect(host.querySelectorAll(".persona-suggestion")).toHaveLength(3);
519
+ expect(host.querySelector(".persona-suggestions")?.getAttribute("data-variant")).toBe("chip");
520
+ controller.injectTestMessage({ type: "message", message: assistantMessage() });
521
+ expect(isVisible(host)).toBe(true);
522
+ sendUserMessage(controller);
523
+ expect(isVisible(host)).toBe(false);
524
+ controller.clearChat();
525
+ expect(isVisible(host)).toBe(true);
526
+ expect(host.querySelectorAll(".persona-suggestion")).toHaveLength(3);
527
+ });
528
+
529
+ it("raises fullscreen empty composers and defaults starters to four cards", () => {
530
+ const { mount } = makeController({ future: { v5Defaults: true }, launcher: { enabled: false, fullHeight: true }, suggestionChips: ["One", "Two", "Three", "Four", "Five"] });
531
+ expect(mount.getAttribute("data-persona-welcome-anchor")).toBe("center");
532
+ expect(welcomeHost(mount).querySelectorAll(".persona-suggestion")).toHaveLength(4);
533
+ expect(welcomeHost(mount).querySelector(".persona-suggestions")?.getAttribute("data-variant")).toBe("card");
534
+ });
535
+ });
536
+
537
+ it("updates starter density when the floating panel becomes mobile fullscreen", () => {
538
+ const originalWidth = window.innerWidth;
539
+ try {
540
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
541
+ const { mount } = makeController({ future: { v5Defaults: true }, launcher: { enabled: true }, suggestionChips: ["One", "Two", "Three", "Four", "Five"] });
542
+ expect(welcomeHost(mount).querySelectorAll(".persona-suggestion")).toHaveLength(3);
543
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 500 });
544
+ window.dispatchEvent(new Event("resize"));
545
+ expect(mount.getAttribute("data-persona-welcome-anchor")).toBe("center");
546
+ expect(welcomeHost(mount).querySelectorAll(".persona-suggestion")).toHaveLength(4);
547
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1000 });
548
+ window.dispatchEvent(new Event("resize"));
549
+ expect(mount.getAttribute("data-persona-welcome-anchor")).toBe("bottom");
550
+ expect(welcomeHost(mount).querySelectorAll(".persona-suggestion")).toHaveLength(3);
551
+ } finally {
552
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
553
+ }
554
+ });
555
+
556
+ describe.each([false, true])("explicit welcome layout (V5 defaults: %s)", (v5Defaults) => {
557
+ it("makes top override hero centering in empty and active states", () => {
558
+ const { mount, controller } = makeController({ future: { v5Defaults }, welcome: { layout: "top", variant: "hero", dismiss: "never" } });
559
+ expect(mount.getAttribute("data-persona-welcome-layout-explicit")).toBe("top");
560
+ expect(isVisible(welcomeHost(mount))).toBe(true);
561
+ sendUserMessage(controller);
562
+ expect(mount.getAttribute("data-persona-welcome-layout-explicit")).toBe("top");
563
+ expect(isVisible(welcomeHost(mount))).toBe(true);
564
+ });
565
+
566
+ it("centers and resets explicit greeting/starters while honoring overrides", () => {
567
+ const { mount, controller } = makeController({
568
+ future: { v5Defaults },
569
+ welcome: { layout: "centered", title: "Custom", subtitle: "Scope", dismiss: "on-first-message", align: "start", anchor: "bottom" },
570
+ suggestions: { starters: { items: ["One", "Two", "Three", "Four", "Five"], maxItems: 2, variant: "list", behavior: "fill" } },
571
+ });
572
+ expect(welcomeHost(mount).getAttribute("data-persona-welcome-layout")).toBe("centered");
573
+ expect(welcomeHost(mount).getAttribute("data-persona-welcome-align")).toBe("start");
574
+ expect(mount.getAttribute("data-persona-welcome-anchor")).toBe("bottom");
575
+ expect(welcomeHost(mount).querySelector("h2")?.textContent).toBe("Custom");
576
+ expect(welcomeHost(mount).querySelectorAll(".persona-suggestion")).toHaveLength(2);
577
+ controller.injectTestMessage({ type: "message", message: assistantMessage() });
578
+ expect(isVisible(welcomeHost(mount))).toBe(true);
579
+ sendUserMessage(controller);
580
+ expect(isVisible(welcomeHost(mount))).toBe(false);
581
+ controller.clearChat();
582
+ expect(isVisible(welcomeHost(mount))).toBe(true);
583
+ });
584
+
585
+ it("centers the measured fullscreen group and preserves an explicit percentage", () => {
586
+ const { mount, controller } = makeController({ future: { v5Defaults }, launcher: { enabled: false, fullHeight: true }, composer: { placement: "overlay" }, welcome: { layout: "centered" } });
587
+ const footer = mount.querySelector<HTMLElement>(".persona-widget-footer")!;
588
+ const container = footer.parentElement!;
589
+ Object.defineProperty(container, "clientHeight", { configurable: true, value: 800 });
590
+ Object.defineProperty(footer, "offsetHeight", { configurable: true, value: 100 });
591
+ Object.defineProperty(welcomeHost(mount), "offsetHeight", { configurable: true, value: 200 });
592
+ window.dispatchEvent(new Event("resize"));
593
+ // jsdom has no stylesheet geometry: the zero gap/body origin gives
594
+ // (800 - 200 - 100) / 2 rather than the old 44%-top anchoring.
595
+ expect(mount.style.getPropertyValue("--persona-composer-lift")).toBe("250px");
596
+ controller.update({ welcome: { layout: "centered", anchorComposerTop: "40%" } });
597
+ expect(mount.style.getPropertyValue("--persona-composer-lift")).toBe("380px");
598
+ });
599
+ });