@runtypelabs/persona 3.34.0 → 3.35.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 (186) hide show
  1. package/README.md +11 -11
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
  5. package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +11 -11
  9. package/dist/codegen.js +10 -10
  10. package/dist/index.cjs +46 -44
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +514 -158
  13. package/dist/index.d.ts +514 -158
  14. package/dist/index.global.js +41 -89
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +46 -44
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/launcher.global.js +2 -2
  21. package/dist/launcher.global.js.map +1 -1
  22. package/dist/markdown-parsers.js +53 -0
  23. package/dist/plugin-kit.d.cts +4 -4
  24. package/dist/plugin-kit.d.ts +4 -4
  25. package/dist/runtype-tts.js +1 -0
  26. package/dist/smart-dom-reader.cjs +1 -1
  27. package/dist/smart-dom-reader.d.cts +352 -103
  28. package/dist/smart-dom-reader.d.ts +352 -103
  29. package/dist/smart-dom-reader.js +1 -1
  30. package/dist/theme-editor-preview.cjs +203 -0
  31. package/dist/theme-editor-preview.d.cts +5748 -0
  32. package/dist/theme-editor-preview.d.ts +5748 -0
  33. package/dist/theme-editor-preview.js +203 -0
  34. package/dist/theme-editor.cjs +14 -131
  35. package/dist/theme-editor.d.cts +362 -574
  36. package/dist/theme-editor.d.ts +362 -574
  37. package/dist/theme-editor.js +14 -131
  38. package/dist/theme-reference.cjs +1 -1
  39. package/dist/theme-reference.d.cts +1 -1
  40. package/dist/theme-reference.d.ts +1 -1
  41. package/dist/theme-reference.js +1 -1
  42. package/dist/voice-worklet-player.cjs +80 -0
  43. package/dist/voice-worklet-player.d.cts +215 -0
  44. package/dist/voice-worklet-player.d.ts +215 -0
  45. package/dist/voice-worklet-player.js +80 -0
  46. package/dist/widget.css +54 -36
  47. package/package.json +16 -2
  48. package/src/animations/glyph-cycle.ts +11 -14
  49. package/src/animations/wipe.ts +1 -1
  50. package/src/ask-user-question-tool.test.ts +1 -1
  51. package/src/ask-user-question-tool.ts +6 -8
  52. package/src/client.test.ts +67 -12
  53. package/src/client.ts +40 -32
  54. package/src/codegen.test.ts +1 -1
  55. package/src/codegen.ts +1 -1
  56. package/src/components/approval-bubble.test.ts +1 -1
  57. package/src/components/approval-bubble.ts +3 -3
  58. package/src/components/artifact-pane.ts +3 -3
  59. package/src/components/ask-user-question-bubble.test.ts +14 -14
  60. package/src/components/ask-user-question-bubble.ts +10 -10
  61. package/src/components/composer-builder.ts +17 -16
  62. package/src/components/composer-parts.test.ts +54 -0
  63. package/src/components/composer-parts.ts +95 -126
  64. package/src/components/demo-carousel.ts +1 -1
  65. package/src/components/event-stream-view.test.ts +7 -7
  66. package/src/components/event-stream-view.ts +5 -5
  67. package/src/components/header-builder.ts +33 -27
  68. package/src/components/header-layouts.ts +1 -1
  69. package/src/components/header-parts.test.ts +90 -0
  70. package/src/components/header-parts.ts +62 -93
  71. package/src/components/launcher.test.ts +11 -0
  72. package/src/components/launcher.ts +1 -1
  73. package/src/components/message-bubble.test.ts +74 -2
  74. package/src/components/message-bubble.ts +25 -11
  75. package/src/components/messages.ts +2 -2
  76. package/src/components/panel.test.ts +1 -1
  77. package/src/components/panel.ts +92 -97
  78. package/src/components/pill-composer-builder.ts +28 -24
  79. package/src/components/reasoning-bubble.ts +2 -2
  80. package/src/components/suggestions.ts +1 -1
  81. package/src/components/tool-bubble.ts +1 -1
  82. package/src/components/widget-view.test.ts +181 -0
  83. package/src/components/widget-view.ts +234 -0
  84. package/src/defaults.ts +54 -51
  85. package/src/generated/runtype-openapi-contract.ts +5 -0
  86. package/src/index-core.ts +30 -4
  87. package/src/index-global.ts +73 -6
  88. package/src/index.ts +7 -1
  89. package/src/install.test.ts +11 -11
  90. package/src/install.ts +28 -18
  91. package/src/launcher-global.ts +25 -14
  92. package/src/markdown-parsers-eager.ts +20 -0
  93. package/src/markdown-parsers-entry.ts +4 -0
  94. package/src/markdown-parsers-loader.ts +49 -0
  95. package/src/plugin-kit.test.ts +1 -1
  96. package/src/plugin-kit.ts +7 -7
  97. package/src/plugins/types.ts +5 -5
  98. package/src/postprocessors.ts +27 -14
  99. package/src/runtime/host-layout.test.ts +1 -1
  100. package/src/runtime/host-layout.ts +4 -5
  101. package/src/runtime/init.ts +2 -2
  102. package/src/session.test.ts +5 -6
  103. package/src/session.ts +333 -227
  104. package/src/session.voice.test.ts +146 -0
  105. package/src/session.webmcp.test.ts +24 -25
  106. package/src/smart-dom-reader.ts +3 -4
  107. package/src/styles/widget.css +54 -36
  108. package/src/suggest-replies-tool.test.ts +3 -3
  109. package/src/suggest-replies-tool.ts +5 -5
  110. package/src/testing/index.ts +1 -1
  111. package/src/theme-editor/color-utils.ts +2 -2
  112. package/src/theme-editor/index.ts +4 -13
  113. package/src/theme-editor/presets.ts +1 -1
  114. package/src/theme-editor/preview-utils.ts +22 -20
  115. package/src/theme-editor/preview.ts +4 -1
  116. package/src/theme-editor/role-mappings.ts +3 -3
  117. package/src/theme-editor/sections.ts +21 -20
  118. package/src/theme-editor/types.ts +1 -1
  119. package/src/theme-editor/webmcp/index.ts +1 -1
  120. package/src/theme-editor/webmcp/summary.ts +1 -2
  121. package/src/theme-editor/webmcp/tools.ts +30 -30
  122. package/src/theme-editor/webmcp/types.ts +2 -2
  123. package/src/theme-editor-preview.ts +10 -0
  124. package/src/theme-reference.ts +26 -26
  125. package/src/types.ts +368 -99
  126. package/src/ui.approval-plugin.test.ts +3 -3
  127. package/src/ui.ask-user-question-plugin.test.ts +9 -9
  128. package/src/ui.component-directive.test.ts +4 -4
  129. package/src/ui.composer-bar.test.ts +10 -11
  130. package/src/ui.composer-keyboard.test.ts +5 -5
  131. package/src/ui.header-icon-color.test.ts +59 -0
  132. package/src/ui.scroll.test.ts +5 -6
  133. package/src/ui.stop-button.test.ts +3 -4
  134. package/src/ui.suggest-replies.test.ts +2 -2
  135. package/src/ui.ts +292 -131
  136. package/src/utils/buttons.ts +1 -1
  137. package/src/utils/code-generators.ts +1 -1
  138. package/src/utils/composer-history.ts +3 -3
  139. package/src/utils/dom-context.test.ts +2 -2
  140. package/src/utils/dom-context.ts +7 -7
  141. package/src/utils/dom.test.ts +67 -0
  142. package/src/utils/dom.ts +75 -0
  143. package/src/utils/dropdown.ts +2 -2
  144. package/src/utils/event-stream-buffer.test.ts +2 -2
  145. package/src/utils/message-fingerprint.test.ts +9 -0
  146. package/src/utils/message-fingerprint.ts +5 -0
  147. package/src/utils/morph.ts +1 -1
  148. package/src/utils/sanitize.ts +32 -16
  149. package/src/utils/sequence-buffer.test.ts +8 -8
  150. package/src/utils/sequence-buffer.ts +4 -4
  151. package/src/utils/smart-dom-adapter.test.ts +1 -1
  152. package/src/utils/smart-dom-adapter.ts +4 -5
  153. package/src/utils/speech-text.test.ts +122 -0
  154. package/src/utils/speech-text.ts +101 -0
  155. package/src/utils/stream-animation.test.ts +3 -3
  156. package/src/utils/stream-animation.ts +6 -6
  157. package/src/utils/theme.test.ts +1 -1
  158. package/src/utils/throughput-tracker.test.ts +11 -11
  159. package/src/utils/throughput-tracker.ts +9 -9
  160. package/src/utils/tokens.ts +13 -13
  161. package/src/utils/virtual-scroller.ts +1 -1
  162. package/src/vendor/smart-dom-reader/README.md +4 -4
  163. package/src/vendor/smart-dom-reader/index.d.ts +1 -1
  164. package/src/vendor/smart-dom-reader/index.js +2 -2
  165. package/src/version.ts +1 -1
  166. package/src/voice/audio-playback-manager.test.ts +159 -0
  167. package/src/voice/audio-playback-manager.ts +145 -30
  168. package/src/voice/browser-speech-engine.ts +130 -0
  169. package/src/voice/fallback-speech-engine.ts +89 -0
  170. package/src/voice/index.ts +21 -0
  171. package/src/voice/read-aloud-controller.test.ts +143 -0
  172. package/src/voice/read-aloud-controller.ts +136 -0
  173. package/src/voice/runtype-speech-engine.test.ts +280 -0
  174. package/src/voice/runtype-speech-engine.ts +211 -0
  175. package/src/voice/runtype-tts-entry.ts +10 -0
  176. package/src/voice/runtype-tts-loader.test.ts +47 -0
  177. package/src/voice/runtype-tts-loader.ts +42 -0
  178. package/src/voice/runtype-voice-provider.ts +332 -537
  179. package/src/voice/voice-factory.ts +26 -4
  180. package/src/voice/voice.test.ts +321 -7
  181. package/src/voice/worklet-playback-engine.ts +272 -0
  182. package/src/voice-worklet-player.ts +39 -0
  183. package/src/webmcp-bridge.test.ts +6 -6
  184. package/src/webmcp-bridge.ts +24 -25
  185. package/src/webmcp-polyfill.ts +1 -1
  186. package/src/voice/voice-activity-detector.ts +0 -90
@@ -0,0 +1,90 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { createCloseButton, createClearChatButton } from "./header-parts";
5
+ import type { AgentWidgetConfig } from "../types";
6
+
7
+ const baseConfig: AgentWidgetConfig = { apiUrl: "/api" };
8
+
9
+ describe("createCloseButton", () => {
10
+ it("keeps the default utility classes when no style overrides are set", () => {
11
+ const { button } = createCloseButton(baseConfig, {});
12
+ expect(button.tagName).toBe("BUTTON");
13
+ expect(button.type).toBe("button");
14
+ expect(button.getAttribute("aria-label")).toBe("Close chat");
15
+ expect(button.classList.contains("persona-rounded-full")).toBe(true);
16
+ expect(button.classList.contains("persona-border-none")).toBe(true);
17
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(true);
18
+ });
19
+
20
+ it("hides the button when showClose is false", () => {
21
+ const { button } = createCloseButton(baseConfig, { showClose: false });
22
+ expect(button.style.display).toBe("none");
23
+ });
24
+
25
+ it("drops each default class and sets the inline style for the matching override", () => {
26
+ const { button } = createCloseButton(
27
+ {
28
+ ...baseConfig,
29
+ launcher: {
30
+ closeButtonBackgroundColor: "rgb(1, 2, 3)",
31
+ closeButtonBorderRadius: "4px",
32
+ closeButtonBorderWidth: "2px",
33
+ closeButtonBorderColor: "rgb(4, 5, 6)",
34
+ },
35
+ },
36
+ {}
37
+ );
38
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(false);
39
+ expect(button.style.backgroundColor).toBe("rgb(1, 2, 3)");
40
+ expect(button.classList.contains("persona-rounded-full")).toBe(false);
41
+ expect(button.style.borderRadius).toBe("4px");
42
+ expect(button.classList.contains("persona-border-none")).toBe(false);
43
+ expect(button.style.borderWidth).toBe("2px");
44
+ expect(button.style.borderStyle).toBe("solid");
45
+ });
46
+
47
+ it("defaults border width to 0px when only border color is provided", () => {
48
+ const { button } = createCloseButton(
49
+ { ...baseConfig, launcher: { closeButtonBorderColor: "rgb(7, 8, 9)" } },
50
+ {}
51
+ );
52
+ expect(button.classList.contains("persona-border-none")).toBe(false);
53
+ expect(button.style.borderWidth).toBe("0px");
54
+ expect(button.style.borderStyle).toBe("solid");
55
+ });
56
+ });
57
+
58
+ describe("createClearChatButton", () => {
59
+ it("keeps the default utility classes when no style overrides are set", () => {
60
+ const { button } = createClearChatButton(baseConfig, {});
61
+ expect(button.type).toBe("button");
62
+ expect(button.classList.contains("persona-rounded-full")).toBe(true);
63
+ expect(button.classList.contains("persona-border-none")).toBe(true);
64
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(true);
65
+ });
66
+
67
+ it("drops each default class and sets the inline style for the matching override", () => {
68
+ const { button } = createClearChatButton(
69
+ {
70
+ ...baseConfig,
71
+ launcher: {
72
+ clearChat: {
73
+ backgroundColor: "rgb(1, 2, 3)",
74
+ borderRadius: "4px",
75
+ borderWidth: "2px",
76
+ borderColor: "rgb(4, 5, 6)",
77
+ },
78
+ },
79
+ },
80
+ {}
81
+ );
82
+ expect(button.classList.contains("hover:persona-bg-gray-100")).toBe(false);
83
+ expect(button.style.backgroundColor).toBe("rgb(1, 2, 3)");
84
+ expect(button.classList.contains("persona-rounded-full")).toBe(false);
85
+ expect(button.style.borderRadius).toBe("4px");
86
+ expect(button.classList.contains("persona-border-none")).toBe(false);
87
+ expect(button.style.borderWidth).toBe("2px");
88
+ expect(button.style.borderStyle).toBe("solid");
89
+ });
90
+ });
@@ -1,4 +1,4 @@
1
- import { createElement, createElementInDocument } from "../utils/dom";
1
+ import { createElement, createElementInDocument, createNode, cx } from "../utils/dom";
2
2
  import { renderLucideIcon } from "../utils/icons";
3
3
  import { AgentWidgetConfig } from "../types";
4
4
  import { PORTALED_OVERLAY_Z_INDEX } from "../utils/constants";
@@ -25,14 +25,14 @@ export interface CreateCloseButtonOptions {
25
25
  /**
26
26
  * Explicit button-size override that wins over `launcher.closeButtonSize`.
27
27
  * Use when the call site has its own opinion about the size that should
28
- * take precedence over the global launcher config e.g. composer-bar's
28
+ * take precedence over the global launcher config: e.g. composer-bar's
29
29
  * minimal close icon, where size is part of the mode's UX, not something
30
30
  * that should inherit from the floating launcher's button size.
31
31
  */
32
32
  buttonSize?: string;
33
33
  /**
34
34
  * Override the rendered icon size (default: "28px"). Pair with
35
- * `buttonSize` when scaling the whole control down otherwise the
35
+ * `buttonSize` when scaling the whole control down: otherwise the
36
36
  * 28px icon will overflow a smaller button.
37
37
  */
38
38
  iconSize?: string;
@@ -85,24 +85,41 @@ export const createCloseButton = (
85
85
 
86
86
  const wrapper = createElement("div", wrapperClassName);
87
87
 
88
- const button = createElement(
89
- "button",
90
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
91
- ) as HTMLButtonElement;
92
- button.style.height = closeButtonSize;
93
- button.style.width = closeButtonSize;
94
- button.type = "button";
95
-
96
88
  const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
97
89
  const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
98
-
99
- button.setAttribute("aria-label", closeButtonTooltipText);
100
- button.style.display = showClose ? "" : "none";
101
-
102
90
  const closeButtonIconName = launcher.closeButtonIconName ?? "x";
103
91
  const closeButtonIconText = launcher.closeButtonIconText ?? "×";
104
- button.style.color =
105
- launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor;
92
+ // hover-bg / border-none / rounded-full are default utility classes that
93
+ // apply only when the matching style override is absent; an override sets
94
+ // the inline style instead (cx omits the class).
95
+ const hasCloseBorder = Boolean(
96
+ launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor
97
+ );
98
+
99
+ const button = createNode("button", {
100
+ className: cx(
101
+ "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
102
+ !launcher.closeButtonBackgroundColor && "hover:persona-bg-gray-100",
103
+ !hasCloseBorder && "persona-border-none",
104
+ !launcher.closeButtonBorderRadius && "persona-rounded-full"
105
+ ),
106
+ attrs: { type: "button", "aria-label": closeButtonTooltipText },
107
+ style: {
108
+ height: closeButtonSize,
109
+ width: closeButtonSize,
110
+ display: showClose ? undefined : "none",
111
+ color: launcher.closeButtonColor || HEADER_THEME_CSS.actionIconColor,
112
+ backgroundColor: launcher.closeButtonBackgroundColor || undefined,
113
+ border: hasCloseBorder
114
+ ? `${launcher.closeButtonBorderWidth || "0px"} solid ${launcher.closeButtonBorderColor || "transparent"}`
115
+ : undefined,
116
+ borderRadius: launcher.closeButtonBorderRadius || undefined,
117
+ paddingLeft: launcher.closeButtonPaddingX || undefined,
118
+ paddingRight: launcher.closeButtonPaddingX || undefined,
119
+ paddingTop: launcher.closeButtonPaddingY || undefined,
120
+ paddingBottom: launcher.closeButtonPaddingY || undefined,
121
+ },
122
+ });
106
123
 
107
124
  // The X glyph's paths occupy only the middle 50% of its 24x24 viewBox
108
125
  // (from 6,6 to 18,18), while other header icons (e.g. refresh-cw) span
@@ -118,47 +135,6 @@ export const createCloseButton = (
118
135
  button.textContent = closeButtonIconText;
119
136
  }
120
137
 
121
- if (launcher.closeButtonBackgroundColor) {
122
- button.style.backgroundColor = launcher.closeButtonBackgroundColor;
123
- button.classList.remove("hover:persona-bg-gray-100");
124
- } else {
125
- button.style.backgroundColor = "";
126
- button.classList.add("hover:persona-bg-gray-100");
127
- }
128
-
129
- if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
130
- const borderWidth = launcher.closeButtonBorderWidth || "0px";
131
- const borderColor = launcher.closeButtonBorderColor || "transparent";
132
- button.style.border = `${borderWidth} solid ${borderColor}`;
133
- button.classList.remove("persona-border-none");
134
- } else {
135
- button.style.border = "";
136
- button.classList.add("persona-border-none");
137
- }
138
-
139
- if (launcher.closeButtonBorderRadius) {
140
- button.style.borderRadius = launcher.closeButtonBorderRadius;
141
- button.classList.remove("persona-rounded-full");
142
- } else {
143
- button.style.borderRadius = "";
144
- button.classList.add("persona-rounded-full");
145
- }
146
-
147
- if (launcher.closeButtonPaddingX) {
148
- button.style.paddingLeft = launcher.closeButtonPaddingX;
149
- button.style.paddingRight = launcher.closeButtonPaddingX;
150
- } else {
151
- button.style.paddingLeft = "";
152
- button.style.paddingRight = "";
153
- }
154
- if (launcher.closeButtonPaddingY) {
155
- button.style.paddingTop = launcher.closeButtonPaddingY;
156
- button.style.paddingBottom = launcher.closeButtonPaddingY;
157
- } else {
158
- button.style.paddingTop = "";
159
- button.style.paddingBottom = "";
160
- }
161
-
162
138
  wrapper.appendChild(button);
163
139
 
164
140
  if (closeButtonShowTooltip && closeButtonTooltipText) {
@@ -259,15 +235,34 @@ export const createClearChatButton = (
259
235
 
260
236
  const wrapper = createElement("div", wrapperClassName);
261
237
 
262
- const button = createElement(
263
- "button",
264
- "persona-inline-flex persona-items-center persona-justify-center persona-rounded-full hover:persona-bg-gray-100 persona-cursor-pointer persona-border-none"
265
- ) as HTMLButtonElement;
266
- button.style.height = clearChatSize;
267
- button.style.width = clearChatSize;
268
- button.type = "button";
269
- button.setAttribute("aria-label", clearChatTooltipText);
270
- button.style.color = clearChatIconColor || HEADER_THEME_CSS.actionIconColor;
238
+ // hover-bg / border-none / rounded-full are default utility classes that
239
+ // apply only when the matching style override is absent; an override sets
240
+ // the inline style instead (cx omits the class).
241
+ const hasClearChatBorder = Boolean(clearChatBorderWidth || clearChatBorderColor);
242
+
243
+ const button = createNode("button", {
244
+ className: cx(
245
+ "persona-inline-flex persona-items-center persona-justify-center persona-cursor-pointer",
246
+ !clearChatBgColor && "hover:persona-bg-gray-100",
247
+ !hasClearChatBorder && "persona-border-none",
248
+ !clearChatBorderRadius && "persona-rounded-full"
249
+ ),
250
+ attrs: { type: "button", "aria-label": clearChatTooltipText },
251
+ style: {
252
+ height: clearChatSize,
253
+ width: clearChatSize,
254
+ color: clearChatIconColor || HEADER_THEME_CSS.actionIconColor,
255
+ backgroundColor: clearChatBgColor || undefined,
256
+ border: hasClearChatBorder
257
+ ? `${clearChatBorderWidth || "0px"} solid ${clearChatBorderColor || "transparent"}`
258
+ : undefined,
259
+ borderRadius: clearChatBorderRadius || undefined,
260
+ paddingLeft: clearChatPaddingX || undefined,
261
+ paddingRight: clearChatPaddingX || undefined,
262
+ paddingTop: clearChatPaddingY || undefined,
263
+ paddingBottom: clearChatPaddingY || undefined,
264
+ },
265
+ });
271
266
 
272
267
  const iconSvg = renderLucideIcon(clearChatIconName, iconSize, "currentColor", 1);
273
268
  if (iconSvg) {
@@ -275,32 +270,6 @@ export const createClearChatButton = (
275
270
  button.appendChild(iconSvg);
276
271
  }
277
272
 
278
- if (clearChatBgColor) {
279
- button.style.backgroundColor = clearChatBgColor;
280
- button.classList.remove("hover:persona-bg-gray-100");
281
- }
282
-
283
- if (clearChatBorderWidth || clearChatBorderColor) {
284
- const borderWidth = clearChatBorderWidth || "0px";
285
- const borderColor = clearChatBorderColor || "transparent";
286
- button.style.border = `${borderWidth} solid ${borderColor}`;
287
- button.classList.remove("persona-border-none");
288
- }
289
-
290
- if (clearChatBorderRadius) {
291
- button.style.borderRadius = clearChatBorderRadius;
292
- button.classList.remove("persona-rounded-full");
293
- }
294
-
295
- if (clearChatPaddingX) {
296
- button.style.paddingLeft = clearChatPaddingX;
297
- button.style.paddingRight = clearChatPaddingX;
298
- }
299
- if (clearChatPaddingY) {
300
- button.style.paddingTop = clearChatPaddingY;
301
- button.style.paddingBottom = clearChatPaddingY;
302
- }
303
-
304
273
  wrapper.appendChild(button);
305
274
 
306
275
  if (clearChatShowTooltip && clearChatTooltipText) {
@@ -5,6 +5,17 @@ import { createLauncherButton } from "./launcher";
5
5
  import { DEFAULT_WIDGET_CONFIG } from "../defaults";
6
6
 
7
7
  describe("createLauncherButton", () => {
8
+ it("uses the package default subtitle when no subtitle is configured", () => {
9
+ const { element, update } = createLauncherButton(undefined, () => {});
10
+ update({});
11
+ const subtitleEl = element.querySelector("[data-role='launcher-subtitle']");
12
+ expect(subtitleEl?.textContent).toBe("Here to help you get answers fast");
13
+ expect(subtitleEl?.getAttribute("title")).toBe(
14
+ "Here to help you get answers fast"
15
+ );
16
+ element.remove();
17
+ });
18
+
8
19
  it("applies collapsedMaxWidth when set", () => {
9
20
  const { element, update } = createLauncherButton(undefined, () => {});
10
21
  update({
@@ -41,7 +41,7 @@ export const createLauncherButton = (
41
41
 
42
42
  const subtitleEl = button.querySelector("[data-role='launcher-subtitle']");
43
43
  if (subtitleEl) {
44
- const s = launcher.subtitle ?? "Get answers fast";
44
+ const s = launcher.subtitle ?? "Here to help you get answers fast";
45
45
  subtitleEl.textContent = s;
46
46
  subtitleEl.setAttribute("title", s);
47
47
  }
@@ -155,7 +155,7 @@ describe("resolveStopReasonNoticeText", () => {
155
155
  });
156
156
  });
157
157
 
158
- describe("createStandardBubble stopReason notice", () => {
158
+ describe("createStandardBubble: stopReason notice", () => {
159
159
  const renderWithStopReason = (
160
160
  overrides: Partial<AgentWidgetMessage>,
161
161
  widgetConfig?: Partial<AgentWidgetConfig>
@@ -333,7 +333,7 @@ describe("isSafeMediaSrc", () => {
333
333
  });
334
334
  });
335
335
 
336
- describe("createStandardBubble audio/video/file content parts", () => {
336
+ describe("createStandardBubble: audio/video/file content parts", () => {
337
337
  it("renders an <audio> element with controls for an audio content part", () => {
338
338
  const bubble = createStandardBubble(
339
339
  makeMessage({
@@ -466,3 +466,75 @@ describe("createStandardBubble — audio/video/file content parts", () => {
466
466
  expect(link?.getAttribute("href")).toBe("https://example.com/report.pdf");
467
467
  });
468
468
  });
469
+
470
+ describe("createStandardBubble: timestamp position", () => {
471
+ it("tucks an inline timestamp inside the last content block so it trails the text", () => {
472
+ const bubble = createStandardBubble(
473
+ makeMessage({ content: "Hello there" }),
474
+ ({ text }) => `<p>${text}</p>`,
475
+ { timestamp: { show: true, position: "inline" } }
476
+ );
477
+
478
+ // Markdown content is block-wrapped; the inline timestamp must live inside
479
+ // that block (trailing the text), not as a block sibling under the bubble.
480
+ const inline = bubble.querySelector("p .persona-timestamp-inline");
481
+ expect(inline).not.toBeNull();
482
+ expect(inline?.textContent).toMatch(/\d/);
483
+ expect(Array.from(bubble.children)).not.toContain(inline);
484
+ // Must be an inline-level <span>; a <div> here is invalid inside a <p>.
485
+ expect(inline?.tagName).toBe("SPAN");
486
+ });
487
+
488
+ it("uses an inline element that survives an innerHTML re-parse (idiomorph round-trip)", () => {
489
+ const bubble = createStandardBubble(
490
+ makeMessage({ content: "Hello there" }),
491
+ ({ text }) => `<p>${text}</p>`,
492
+ { timestamp: { show: true, position: "inline" } }
493
+ );
494
+ const content = bubble.querySelector(".persona-message-content") as HTMLElement;
495
+
496
+ // Re-serializing + re-parsing is what the re-render path does. A <div>
497
+ // inside a <p> would be auto-closed out to a sibling (leaving an empty
498
+ // <p>), dropping the timestamp onto its own line; a <span> stays put.
499
+ const serialized = content.innerHTML;
500
+ content.innerHTML = serialized;
501
+
502
+ expect(content.querySelector("p .persona-timestamp-inline")).not.toBeNull();
503
+ const emptyParagraphs = Array.from(content.querySelectorAll("p")).filter(
504
+ (p) => p.textContent === ""
505
+ );
506
+ expect(emptyParagraphs.length).toBe(0);
507
+ });
508
+
509
+ it("falls back to the content container for plain-text (non-block) content", () => {
510
+ const bubble = createStandardBubble(
511
+ makeMessage({ content: "Hello there" }),
512
+ ({ text }) => text,
513
+ { timestamp: { show: true, position: "inline" } }
514
+ );
515
+
516
+ const content = bubble.querySelector(".persona-message-content")!;
517
+ const inline = content.querySelector(".persona-timestamp-inline");
518
+ expect(inline).not.toBeNull();
519
+ expect(content.contains(inline)).toBe(true);
520
+ });
521
+
522
+ it("renders a below timestamp as a block under the content (direct bubble child)", () => {
523
+ const bubble = createStandardBubble(
524
+ makeMessage({ content: "Hello there" }),
525
+ ({ text }) => `<p>${text}</p>`,
526
+ { timestamp: { show: true, position: "below" } }
527
+ );
528
+
529
+ // "below" must not produce an inline timestamp, and the timestamp block is
530
+ // appended to the bubble (under the content), not nested in the content.
531
+ expect(bubble.querySelector(".persona-timestamp-inline")).toBeNull();
532
+ const below = Array.from(bubble.children).find(
533
+ (el) =>
534
+ el.classList.contains("persona-mt-1") &&
535
+ el.classList.contains("persona-text-xs")
536
+ );
537
+ expect(below).toBeDefined();
538
+ expect(below?.textContent).toMatch(/\d/);
539
+ });
540
+ });
@@ -28,7 +28,7 @@ import {
28
28
  * Default copy for the inline notice rendered when a turn ends with a
29
29
  * non-natural stop reason. Deployers override per-reason via
30
30
  * `config.copy.stopReasonNotice`. Returns `null` for natural completions
31
- * (`end_turn`) and uninformative reasons (`unknown`) those never render
31
+ * (`end_turn`) and uninformative reasons (`unknown`): those never render
32
32
  * an affordance.
33
33
  */
34
34
  export const getDefaultStopReasonNoticeCopy = (
@@ -62,7 +62,7 @@ export const resolveStopReasonNoticeText = (
62
62
  ): string | null => {
63
63
  if (!stopReason) return null;
64
64
  const fallback = getDefaultStopReasonNoticeCopy(stopReason);
65
- // Reasons without a default (end_turn, unknown) never render overrides
65
+ // Reasons without a default (end_turn, unknown) never render: overrides
66
66
  // for those keys are intentionally ignored.
67
67
  if (fallback === null) return null;
68
68
  const override = overrides?.[stopReason];
@@ -498,10 +498,11 @@ const createAvatar = (
498
498
  */
499
499
  const createTimestamp = (
500
500
  message: AgentWidgetMessage,
501
- timestampConfig: AgentWidgetTimestampConfig
501
+ timestampConfig: AgentWidgetTimestampConfig,
502
+ tagName: "div" | "span" = "div"
502
503
  ): HTMLElement => {
503
504
  const timestamp = createElement(
504
- "div",
505
+ tagName,
505
506
  "persona-text-xs persona-text-persona-muted"
506
507
  );
507
508
 
@@ -629,9 +630,10 @@ export const createMessageActions = (
629
630
  const showCopy = actionsConfig.showCopy ?? true;
630
631
  const showUpvote = actionsConfig.showUpvote ?? true;
631
632
  const showDownvote = actionsConfig.showDownvote ?? true;
633
+ const showReadAloud = actionsConfig.showReadAloud ?? false;
632
634
 
633
635
  // Don't render the container at all when no actions are visible
634
- if (!showCopy && !showUpvote && !showDownvote) {
636
+ if (!showCopy && !showUpvote && !showDownvote && !showReadAloud) {
635
637
  const empty = createElement("div");
636
638
  empty.style.display = "none";
637
639
  empty.id = `actions-${message.id}`;
@@ -686,6 +688,12 @@ export const createMessageActions = (
686
688
  container.appendChild(createActionButton("copy", "Copy message", "copy"));
687
689
  }
688
690
 
691
+ // Read-aloud button. Click handling and play/pause/resume state are managed
692
+ // in ui.ts (event delegation + ReadAloudController) so they survive morphs.
693
+ if (showReadAloud) {
694
+ container.appendChild(createActionButton("volume-2", "Read aloud", "read-aloud"));
695
+ }
696
+
689
697
  // Upvote button
690
698
  if (showUpvote) {
691
699
  container.appendChild(createActionButton("thumbs-up", "Upvote", "upvote"));
@@ -768,7 +776,7 @@ export const createStandardBubble = (
768
776
  ? resolveStreamAnimationPlugin(streamAnimation.type, streamPluginOverrides)
769
777
  : null;
770
778
  // Stay in "streaming-animated" mode while the plugin reports in-flight
771
- // work for this message e.g. glyph-cycle's tick loops still walking
779
+ // work for this message: e.g. glyph-cycle's tick loops still walking
772
780
  // through the tail after the last token arrived. Without this, the final
773
781
  // non-animated render rips out the cycling spans mid-animation.
774
782
  const pluginStillAnimating =
@@ -861,11 +869,17 @@ export const createStandardBubble = (
861
869
  }
862
870
  }
863
871
 
864
- // Add inline timestamp if configured
872
+ // Usung <span>, not <div> for timestamp
873
+ // because a block child of a <p> gets re-parented outside of it on re-render (idiomorph).
865
874
  if (showTimestamp && timestampPosition === "inline" && message.createdAt) {
866
- const timestamp = createTimestamp(message, timestampConfig!);
867
- timestamp.classList.add("persona-ml-2", "persona-inline");
868
- contentDiv.appendChild(timestamp);
875
+ const timestamp = createTimestamp(message, timestampConfig!, "span");
876
+ timestamp.classList.add("persona-timestamp-inline");
877
+ const lastBlock = contentDiv.lastElementChild;
878
+ if (lastBlock) {
879
+ lastBlock.appendChild(timestamp);
880
+ } else {
881
+ contentDiv.appendChild(timestamp);
882
+ }
869
883
  }
870
884
 
871
885
  if (imageParts.length > 0) {
@@ -930,7 +944,7 @@ export const createStandardBubble = (
930
944
  : null;
931
945
 
932
946
  // Add typing indicator (or skeleton placeholder) for streaming assistant
933
- // messages. Check the buffered content a plugin's `bufferContent` may
947
+ // messages. Check the buffered content: a plugin's `bufferContent` may
934
948
  // hold back the first N chars (e.g. glyph-cycle waits for 50 chars), during
935
949
  // which the bubble would otherwise appear empty.
936
950
  //
@@ -34,7 +34,7 @@ export const renderMessages = (
34
34
  if (!showReasoning) return;
35
35
  bubble = createReasoningBubble(message, config);
36
36
  } else if (isAskUserQuestionMessage(message)) {
37
- // No transcript bubble the overlay sheet is the only question UI.
37
+ // No transcript bubble: the overlay sheet is the only question UI.
38
38
  if (config?.features?.askUserQuestion?.enabled === false) return;
39
39
  if (!message.agentMetadata?.askUserQuestionAnswered) {
40
40
  if (message.toolCall?.id) liveAskToolIds.add(message.toolCall.id);
@@ -45,7 +45,7 @@ export const renderMessages = (
45
45
  isSuggestRepliesMessage(message) &&
46
46
  config?.features?.suggestReplies?.enabled !== false
47
47
  ) {
48
- // No transcript bubble the chips above the composer are the only UI.
48
+ // No transcript bubble: the chips above the composer are the only UI.
49
49
  // When the feature is disabled the message falls through to the generic
50
50
  // tool bubble below (and is never auto-resumed), making the parked
51
51
  // execution visible instead of silently swallowed.
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
4
4
  import { createWrapper } from "./panel";
5
5
  import type { AgentWidgetConfig } from "../types";
6
6
 
7
- describe("createWrapper composer-bar mode", () => {
7
+ describe("createWrapper: composer-bar mode", () => {
8
8
  it("marks the wrapper with composer-bar data-attrs and leaves geometry to updateOpenState", () => {
9
9
  const config: AgentWidgetConfig = {
10
10
  apiUrl: "/api",