@runtypelabs/persona 3.17.0 → 3.19.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 (61) hide show
  1. package/README.md +143 -1
  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-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
  5. package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +47 -47
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +580 -4
  11. package/dist/index.d.ts +580 -4
  12. package/dist/index.global.js +102 -1636
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +45 -45
  15. package/dist/index.js.map +1 -1
  16. package/dist/theme-editor.cjs +2844 -752
  17. package/dist/theme-editor.d.cts +337 -1
  18. package/dist/theme-editor.d.ts +337 -1
  19. package/dist/theme-editor.js +2958 -752
  20. package/dist/theme-reference.cjs +1 -1
  21. package/dist/theme-reference.d.cts +14 -0
  22. package/dist/theme-reference.d.ts +14 -0
  23. package/dist/widget.css +780 -0
  24. package/package.json +1 -1
  25. package/src/client.test.ts +134 -0
  26. package/src/client.ts +71 -0
  27. package/src/components/ask-user-question-bubble.test.ts +583 -0
  28. package/src/components/ask-user-question-bubble.ts +924 -0
  29. package/src/components/composer-builder.test.ts +52 -0
  30. package/src/components/composer-builder.ts +67 -490
  31. package/src/components/composer-parts.test.ts +152 -0
  32. package/src/components/composer-parts.ts +452 -0
  33. package/src/components/header-builder.ts +22 -299
  34. package/src/components/header-parts.ts +360 -0
  35. package/src/components/messages.ts +33 -1
  36. package/src/components/panel.test.ts +61 -0
  37. package/src/components/panel.ts +303 -9
  38. package/src/components/pill-composer-builder.test.ts +85 -0
  39. package/src/components/pill-composer-builder.ts +183 -0
  40. package/src/defaults.ts +21 -0
  41. package/src/index.ts +20 -1
  42. package/src/plugins/types.ts +57 -0
  43. package/src/runtime/init.ts +4 -2
  44. package/src/runtime/persist-state.test.ts +152 -0
  45. package/src/session.test.ts +183 -0
  46. package/src/session.ts +242 -3
  47. package/src/styles/widget.css +780 -0
  48. package/src/types/theme.ts +15 -0
  49. package/src/types.ts +271 -1
  50. package/src/ui.ask-user-question-plugin.test.ts +649 -0
  51. package/src/ui.component-directive.test.ts +183 -0
  52. package/src/ui.composer-bar.test.ts +1009 -0
  53. package/src/ui.ts +1439 -76
  54. package/src/utils/attachment-manager.ts +1 -1
  55. package/src/utils/dock.test.ts +45 -0
  56. package/src/utils/dock.ts +3 -0
  57. package/src/utils/icons.ts +314 -58
  58. package/src/utils/storage.ts +10 -2
  59. package/src/utils/stream-animation.ts +7 -2
  60. package/src/utils/theme.test.ts +36 -0
  61. package/src/utils/tokens.ts +23 -0
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ createAttachmentControls,
6
+ createComposerTextarea,
7
+ createMicButton,
8
+ createSendButton,
9
+ createStatusText,
10
+ createSuggestionsRow,
11
+ } from "./composer-parts";
12
+ import type { AgentWidgetConfig } from "../types";
13
+
14
+ const baseConfig: AgentWidgetConfig = { apiUrl: "/api" };
15
+
16
+ describe("createComposerTextarea", () => {
17
+ it("returns a textarea with the data attribute and composer-textarea class", () => {
18
+ const { textarea } = createComposerTextarea(baseConfig);
19
+ expect(textarea.tagName).toBe("TEXTAREA");
20
+ expect(textarea.getAttribute("data-persona-composer-input")).toBe("");
21
+ expect(textarea.classList.contains("persona-composer-textarea")).toBe(true);
22
+ });
23
+
24
+ it("attachAutoResize wires an input listener that grows up to maxHeight", () => {
25
+ const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
26
+ document.body.appendChild(textarea);
27
+ attachAutoResize();
28
+ Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
29
+ textarea.value = "lots of text";
30
+ textarea.dispatchEvent(new Event("input"));
31
+ // jsdom doesn't compute scrollHeight; we just verify the handler ran by
32
+ // checking that height was set to a numeric px value (not auto).
33
+ expect(textarea.style.height).toMatch(/px$/);
34
+ document.body.removeChild(textarea);
35
+ });
36
+
37
+ it("honors maxHeight overrides set after construction", () => {
38
+ const { textarea, attachAutoResize } = createComposerTextarea(baseConfig);
39
+ document.body.appendChild(textarea);
40
+ textarea.style.maxHeight = "200px";
41
+ attachAutoResize();
42
+ Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 10000 });
43
+ textarea.dispatchEvent(new Event("input"));
44
+ expect(textarea.style.height).toBe("200px");
45
+ document.body.removeChild(textarea);
46
+ });
47
+ });
48
+
49
+ describe("createSendButton", () => {
50
+ it("returns button + wrapper + setMode handle, with submit data attr", () => {
51
+ const send = createSendButton(baseConfig);
52
+ expect(send.button.tagName).toBe("BUTTON");
53
+ expect(send.button.type).toBe("submit");
54
+ expect(send.button.getAttribute("data-persona-composer-submit")).toBe("");
55
+ expect(send.wrapper.contains(send.button)).toBe(true);
56
+ expect(typeof send.setMode).toBe("function");
57
+ });
58
+
59
+ it("setMode('stop') updates the aria-label and label text", () => {
60
+ const send = createSendButton({
61
+ ...baseConfig,
62
+ copy: { sendButtonLabel: "Send", stopButtonLabel: "Stop" },
63
+ });
64
+ expect(send.button.textContent).toBe("Send");
65
+ expect(send.button.getAttribute("aria-label")).toBe("Send message");
66
+ send.setMode("stop");
67
+ expect(send.button.textContent).toBe("Stop");
68
+ expect(send.button.getAttribute("aria-label")).toBe("Stop generating");
69
+ send.setMode("send");
70
+ expect(send.button.textContent).toBe("Send");
71
+ });
72
+ });
73
+
74
+ describe("createMicButton", () => {
75
+ it("returns null when voice recognition is disabled", () => {
76
+ expect(createMicButton(baseConfig)).toBeNull();
77
+ });
78
+
79
+ it("returns null when voice recognition is enabled but browser support is missing", () => {
80
+ const config: AgentWidgetConfig = {
81
+ ...baseConfig,
82
+ voiceRecognition: { enabled: true },
83
+ };
84
+ // jsdom has neither webkitSpeechRecognition nor SpeechRecognition by default,
85
+ // and no Runtype provider configured → null.
86
+ expect(createMicButton(config)).toBeNull();
87
+ });
88
+
89
+ it("returns a button when a Runtype voice provider is configured", () => {
90
+ const config: AgentWidgetConfig = {
91
+ ...baseConfig,
92
+ voiceRecognition: { enabled: true, provider: { type: "runtype" } },
93
+ };
94
+ const mic = createMicButton(config);
95
+ expect(mic).not.toBeNull();
96
+ expect(mic!.button.getAttribute("data-persona-composer-mic")).toBe("");
97
+ expect(mic!.button.type).toBe("button");
98
+ });
99
+ });
100
+
101
+ describe("createAttachmentControls", () => {
102
+ it("returns null when attachments are disabled", () => {
103
+ expect(createAttachmentControls(baseConfig)).toBeNull();
104
+ });
105
+
106
+ it("returns button + wrapper + input + previewsContainer when enabled", () => {
107
+ const config: AgentWidgetConfig = {
108
+ ...baseConfig,
109
+ attachments: { enabled: true },
110
+ };
111
+ const att = createAttachmentControls(config);
112
+ expect(att).not.toBeNull();
113
+ expect(att!.button.classList.contains("persona-attachment-button")).toBe(true);
114
+ expect(att!.input.type).toBe("file");
115
+ expect(att!.input.style.display).toBe("none");
116
+ expect(att!.previewsContainer.classList.contains("persona-attachment-previews")).toBe(true);
117
+ expect(att!.previewsContainer.style.display).toBe("none");
118
+ });
119
+ });
120
+
121
+ describe("createStatusText", () => {
122
+ it("returns a div with the status data attribute and idle text", () => {
123
+ const status = createStatusText({
124
+ ...baseConfig,
125
+ statusIndicator: { idleText: "Online" },
126
+ });
127
+ expect(status.tagName).toBe("DIV");
128
+ expect(status.getAttribute("data-persona-composer-status")).toBe("");
129
+ expect(status.textContent).toBe("Online");
130
+ });
131
+
132
+ it("renders an anchor tag when idleLink is configured", () => {
133
+ const status = createStatusText({
134
+ ...baseConfig,
135
+ statusIndicator: { idleText: "Powered by", idleLink: "https://example.com" },
136
+ });
137
+ const link = status.querySelector("a");
138
+ expect(link).not.toBeNull();
139
+ expect(link!.href).toBe("https://example.com/");
140
+ });
141
+ });
142
+
143
+ describe("createSuggestionsRow", () => {
144
+ it("returns a div with the suggestions class chain", () => {
145
+ const row = createSuggestionsRow();
146
+ expect(row.tagName).toBe("DIV");
147
+ expect(row.className).toContain("persona-mb-3");
148
+ expect(row.className).toContain("persona-flex");
149
+ expect(row.className).toContain("persona-flex-wrap");
150
+ expect(row.className).toContain("persona-gap-2");
151
+ });
152
+ });
@@ -0,0 +1,452 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { AgentWidgetConfig } from "../types";
4
+ import { ALL_SUPPORTED_MIME_TYPES } from "../utils/content";
5
+
6
+ /**
7
+ * Low-level composer control factories. Both `buildComposer` (full,
8
+ * column-stacked card) and `buildPillComposer` (single-row pill) consume
9
+ * these — the only meaningful difference between the two composers is the
10
+ * layout shell + className. No DOM assembly here; each factory returns the
11
+ * element plus any handles the caller needs.
12
+ *
13
+ * Stable selectors (data attributes + class hooks) live with the elements
14
+ * so `bindComposerRefsFromFooter()` in ui.ts finds them regardless of
15
+ * which builder ran.
16
+ */
17
+
18
+ export interface ComposerTextareaParts {
19
+ textarea: HTMLTextAreaElement;
20
+ /**
21
+ * Wire the input listener that grows the textarea up to its current
22
+ * `maxHeight`. Caller decides when to attach (full composer attaches
23
+ * immediately; pill composer also attaches because expanded mode users
24
+ * want multi-line composition).
25
+ */
26
+ attachAutoResize: () => void;
27
+ }
28
+
29
+ export const createComposerTextarea = (config?: AgentWidgetConfig): ComposerTextareaParts => {
30
+ const textarea = createElement("textarea") as HTMLTextAreaElement;
31
+ textarea.setAttribute("data-persona-composer-input", "");
32
+ textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
33
+ textarea.className =
34
+ "persona-w-full persona-min-h-[24px] persona-resize-none persona-border-none persona-bg-transparent persona-text-sm persona-text-persona-primary focus:persona-outline-none focus:persona-border-none persona-composer-textarea";
35
+ textarea.rows = 1;
36
+
37
+ textarea.style.fontFamily =
38
+ 'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
39
+ textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
40
+
41
+ // Auto-resize: expand up to 3 lines for the full composer (line-height ~20px
42
+ // for text-sm). The pill composer overrides this maxHeight after construction
43
+ // (allowing more growth in expanded mode), and the closure below honors
44
+ // whatever maxHeight is set at the time of the input event.
45
+ const defaultMaxLines = 3;
46
+ const lineHeight = 20;
47
+ textarea.style.maxHeight = `${defaultMaxLines * lineHeight}px`;
48
+ textarea.style.overflowY = "auto";
49
+
50
+ // Read maxHeight at event time so callers can change it after construction.
51
+ const readMaxHeight = (): number => {
52
+ const parsed = parseFloat(textarea.style.maxHeight);
53
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMaxLines * lineHeight;
54
+ };
55
+
56
+ const attachAutoResize = () => {
57
+ textarea.addEventListener("input", () => {
58
+ textarea.style.height = "auto";
59
+ const newHeight = Math.min(textarea.scrollHeight, readMaxHeight());
60
+ textarea.style.height = `${newHeight}px`;
61
+ });
62
+ };
63
+
64
+ // Strip browser default focus rings — the composer wraps the textarea in
65
+ // its own surface, so the textarea itself must be visually transparent.
66
+ textarea.style.border = "none";
67
+ textarea.style.outline = "none";
68
+ textarea.style.borderWidth = "0";
69
+ textarea.style.borderStyle = "none";
70
+ textarea.style.borderColor = "transparent";
71
+ textarea.addEventListener("focus", () => {
72
+ textarea.style.border = "none";
73
+ textarea.style.outline = "none";
74
+ textarea.style.borderWidth = "0";
75
+ textarea.style.borderStyle = "none";
76
+ textarea.style.borderColor = "transparent";
77
+ textarea.style.boxShadow = "none";
78
+ });
79
+ textarea.addEventListener("blur", () => {
80
+ textarea.style.border = "none";
81
+ textarea.style.outline = "none";
82
+ });
83
+
84
+ return { textarea, attachAutoResize };
85
+ };
86
+
87
+ export interface SendButtonParts {
88
+ button: HTMLButtonElement;
89
+ wrapper: HTMLElement;
90
+ /**
91
+ * Swap the button between its idle ("send") and streaming ("stop")
92
+ * appearances. In icon mode this swaps the SVG; in text mode it swaps
93
+ * the label. Tooltip text and aria-label update too.
94
+ */
95
+ setMode: (mode: "send" | "stop") => void;
96
+ }
97
+
98
+ export const createSendButton = (config?: AgentWidgetConfig): SendButtonParts => {
99
+ const sendButtonConfig = config?.sendButton ?? {};
100
+ const useIcon = sendButtonConfig.useIcon ?? false;
101
+ const iconText = sendButtonConfig.iconText ?? "↑";
102
+ const iconName = sendButtonConfig.iconName;
103
+ const stopIconName = sendButtonConfig.stopIconName ?? "square";
104
+ const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
105
+ const stopTooltipText = sendButtonConfig.stopTooltipText ?? "Stop generating";
106
+ const sendLabel = config?.copy?.sendButtonLabel ?? "Send";
107
+ const stopLabel = config?.copy?.stopButtonLabel ?? "Stop";
108
+ const showTooltip = sendButtonConfig.showTooltip ?? false;
109
+ const buttonSize = sendButtonConfig.size ?? "40px";
110
+ const backgroundColor = sendButtonConfig.backgroundColor;
111
+ const textColor = sendButtonConfig.textColor;
112
+
113
+ const wrapper = createElement("div", "persona-send-button-wrapper");
114
+
115
+ const button = createElement(
116
+ "button",
117
+ useIcon
118
+ ? "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
119
+ : "persona-rounded-button persona-bg-persona-accent persona-px-4 persona-py-2 persona-text-sm persona-font-semibold disabled:persona-opacity-50 persona-cursor-pointer"
120
+ ) as HTMLButtonElement;
121
+
122
+ button.type = "submit";
123
+ button.setAttribute("data-persona-composer-submit", "");
124
+
125
+ // Both icons are pre-rendered so setMode can swap cheaply.
126
+ let sendIcon: SVGElement | null = null;
127
+ let stopIcon: SVGElement | null = null;
128
+
129
+ if (useIcon) {
130
+ button.style.width = buttonSize;
131
+ button.style.height = buttonSize;
132
+ button.style.minWidth = buttonSize;
133
+ button.style.minHeight = buttonSize;
134
+ button.style.fontSize = "18px";
135
+ button.style.lineHeight = "1";
136
+
137
+ button.innerHTML = "";
138
+
139
+ if (textColor) {
140
+ button.style.color = textColor;
141
+ } else {
142
+ button.style.color = "var(--persona-button-primary-fg, #ffffff)";
143
+ }
144
+
145
+ const iconSize = parseFloat(buttonSize) || 24;
146
+ const iconColor = textColor?.trim() || "currentColor";
147
+
148
+ if (iconName) {
149
+ sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
150
+ if (sendIcon) {
151
+ button.appendChild(sendIcon);
152
+ } else {
153
+ button.textContent = iconText;
154
+ }
155
+ } else {
156
+ button.textContent = iconText;
157
+ }
158
+
159
+ stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
160
+
161
+ if (backgroundColor) {
162
+ button.style.backgroundColor = backgroundColor;
163
+ } else {
164
+ button.classList.add("persona-bg-persona-primary");
165
+ }
166
+ } else {
167
+ button.textContent = sendLabel;
168
+ if (textColor) {
169
+ button.style.color = textColor;
170
+ } else {
171
+ button.classList.add("persona-text-white");
172
+ }
173
+ }
174
+
175
+ if (sendButtonConfig.borderWidth) {
176
+ button.style.borderWidth = sendButtonConfig.borderWidth;
177
+ button.style.borderStyle = "solid";
178
+ }
179
+ if (sendButtonConfig.borderColor) {
180
+ button.style.borderColor = sendButtonConfig.borderColor;
181
+ }
182
+
183
+ if (sendButtonConfig.paddingX) {
184
+ button.style.paddingLeft = sendButtonConfig.paddingX;
185
+ button.style.paddingRight = sendButtonConfig.paddingX;
186
+ } else {
187
+ button.style.paddingLeft = "";
188
+ button.style.paddingRight = "";
189
+ }
190
+ if (sendButtonConfig.paddingY) {
191
+ button.style.paddingTop = sendButtonConfig.paddingY;
192
+ button.style.paddingBottom = sendButtonConfig.paddingY;
193
+ } else {
194
+ button.style.paddingTop = "";
195
+ button.style.paddingBottom = "";
196
+ }
197
+
198
+ let tooltip: HTMLElement | null = null;
199
+ if (showTooltip && tooltipText) {
200
+ tooltip = createElement("div", "persona-send-button-tooltip");
201
+ tooltip.textContent = tooltipText;
202
+ wrapper.appendChild(tooltip);
203
+ }
204
+
205
+ button.setAttribute("aria-label", tooltipText);
206
+ wrapper.appendChild(button);
207
+
208
+ let currentMode: "send" | "stop" = "send";
209
+ const setMode = (mode: "send" | "stop") => {
210
+ if (mode === currentMode) return;
211
+ currentMode = mode;
212
+ const label = mode === "stop" ? stopTooltipText : tooltipText;
213
+ button.setAttribute("aria-label", label);
214
+ if (tooltip) {
215
+ tooltip.textContent = label;
216
+ }
217
+
218
+ if (useIcon) {
219
+ if (sendIcon && stopIcon) {
220
+ const next = mode === "stop" ? stopIcon : sendIcon;
221
+ const prev = mode === "stop" ? sendIcon : stopIcon;
222
+ if (prev.parentNode === button) {
223
+ button.replaceChild(next, prev);
224
+ } else {
225
+ button.appendChild(next);
226
+ }
227
+ }
228
+ } else {
229
+ button.textContent = mode === "stop" ? stopLabel : sendLabel;
230
+ }
231
+ };
232
+
233
+ return { button, wrapper, setMode };
234
+ };
235
+
236
+ export interface MicButtonParts {
237
+ button: HTMLButtonElement;
238
+ wrapper: HTMLElement;
239
+ }
240
+
241
+ /**
242
+ * Returns null when voice recognition is disabled or the browser doesn't
243
+ * support either the Web Speech API or a Runtype voice provider.
244
+ */
245
+ export const createMicButton = (config?: AgentWidgetConfig): MicButtonParts | null => {
246
+ const voiceRecognitionConfig = config?.voiceRecognition ?? {};
247
+ const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
248
+ if (!voiceRecognitionEnabled) return null;
249
+
250
+ const hasSpeechRecognition =
251
+ typeof window !== "undefined" &&
252
+ (typeof (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition !== "undefined" ||
253
+ typeof (window as unknown as { SpeechRecognition?: unknown }).SpeechRecognition !== "undefined");
254
+ const hasRuntypeProvider = voiceRecognitionConfig.provider?.type === "runtype";
255
+ const hasVoiceInput = hasSpeechRecognition || hasRuntypeProvider;
256
+ if (!hasVoiceInput) return null;
257
+
258
+ const buttonSize = config?.sendButton?.size ?? "40px";
259
+ const wrapper = createElement("div", "persona-send-button-wrapper");
260
+ const button = createElement(
261
+ "button",
262
+ "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
263
+ ) as HTMLButtonElement;
264
+
265
+ button.type = "button";
266
+ button.setAttribute("data-persona-composer-mic", "");
267
+ button.setAttribute("aria-label", "Start voice recognition");
268
+
269
+ const micIconName = voiceRecognitionConfig.iconName ?? "mic";
270
+ const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
271
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
272
+
273
+ const micBackgroundColor =
274
+ voiceRecognitionConfig.backgroundColor ?? config?.sendButton?.backgroundColor;
275
+ const micIconColor = voiceRecognitionConfig.iconColor ?? config?.sendButton?.textColor;
276
+
277
+ button.style.width = micIconSize;
278
+ button.style.height = micIconSize;
279
+ button.style.minWidth = micIconSize;
280
+ button.style.minHeight = micIconSize;
281
+ button.style.fontSize = "18px";
282
+ button.style.lineHeight = "1";
283
+
284
+ if (micIconColor) {
285
+ button.style.color = micIconColor;
286
+ } else {
287
+ button.style.color = "var(--persona-text, #111827)";
288
+ }
289
+
290
+ const iconColorValue = micIconColor || "currentColor";
291
+ const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
292
+ if (micIconSvg) {
293
+ button.appendChild(micIconSvg);
294
+ } else {
295
+ button.textContent = "🎤";
296
+ }
297
+
298
+ if (micBackgroundColor) {
299
+ button.style.backgroundColor = micBackgroundColor;
300
+ }
301
+
302
+ if (voiceRecognitionConfig.borderWidth) {
303
+ button.style.borderWidth = voiceRecognitionConfig.borderWidth;
304
+ button.style.borderStyle = "solid";
305
+ }
306
+ if (voiceRecognitionConfig.borderColor) {
307
+ button.style.borderColor = voiceRecognitionConfig.borderColor;
308
+ }
309
+
310
+ if (voiceRecognitionConfig.paddingX) {
311
+ button.style.paddingLeft = voiceRecognitionConfig.paddingX;
312
+ button.style.paddingRight = voiceRecognitionConfig.paddingX;
313
+ }
314
+ if (voiceRecognitionConfig.paddingY) {
315
+ button.style.paddingTop = voiceRecognitionConfig.paddingY;
316
+ button.style.paddingBottom = voiceRecognitionConfig.paddingY;
317
+ }
318
+
319
+ wrapper.appendChild(button);
320
+
321
+ const micTooltipText = voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
322
+ const showMicTooltip = voiceRecognitionConfig.showTooltip ?? false;
323
+ if (showMicTooltip && micTooltipText) {
324
+ const tooltip = createElement("div", "persona-send-button-tooltip");
325
+ tooltip.textContent = micTooltipText;
326
+ wrapper.appendChild(tooltip);
327
+ }
328
+
329
+ return { button, wrapper };
330
+ };
331
+
332
+ export interface AttachmentControlParts {
333
+ button: HTMLButtonElement;
334
+ wrapper: HTMLElement;
335
+ input: HTMLInputElement;
336
+ previewsContainer: HTMLElement;
337
+ }
338
+
339
+ /**
340
+ * Returns null when attachments are disabled. Caller decides where to
341
+ * place the previewsContainer (full composer puts it inside the form
342
+ * above the textarea; pill composer floats it above the pill in a
343
+ * separate row).
344
+ */
345
+ export const createAttachmentControls = (config?: AgentWidgetConfig): AttachmentControlParts | null => {
346
+ const attachmentsConfig = config?.attachments ?? {};
347
+ if (attachmentsConfig.enabled !== true) return null;
348
+
349
+ const buttonSize = config?.sendButton?.size ?? "40px";
350
+
351
+ const previewsContainer = createElement(
352
+ "div",
353
+ "persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2"
354
+ );
355
+ previewsContainer.style.display = "none";
356
+
357
+ const input = createElement("input") as HTMLInputElement;
358
+ input.type = "file";
359
+ input.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
360
+ input.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
361
+ input.style.display = "none";
362
+ input.setAttribute("aria-label", "Attach files");
363
+
364
+ const wrapper = createElement("div", "persona-send-button-wrapper");
365
+ const button = createElement(
366
+ "button",
367
+ "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-attachment-button"
368
+ ) as HTMLButtonElement;
369
+ button.type = "button";
370
+ button.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
371
+
372
+ const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
373
+ const attachIconSize = buttonSize;
374
+ const buttonSizeNum = parseFloat(attachIconSize) || 40;
375
+ const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
376
+
377
+ button.style.width = attachIconSize;
378
+ button.style.height = attachIconSize;
379
+ button.style.minWidth = attachIconSize;
380
+ button.style.minHeight = attachIconSize;
381
+ button.style.fontSize = "18px";
382
+ button.style.lineHeight = "1";
383
+ button.style.backgroundColor = "transparent";
384
+ button.style.color = "var(--persona-primary, #111827)";
385
+ button.style.border = "none";
386
+ button.style.borderRadius = "6px";
387
+ button.style.transition = "background-color 0.15s ease";
388
+
389
+ button.addEventListener("mouseenter", () => {
390
+ button.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
391
+ });
392
+ button.addEventListener("mouseleave", () => {
393
+ button.style.backgroundColor = "transparent";
394
+ });
395
+
396
+ const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
397
+ if (attachIconSvg) {
398
+ button.appendChild(attachIconSvg);
399
+ } else {
400
+ button.textContent = "📎";
401
+ }
402
+
403
+ button.addEventListener("click", (e) => {
404
+ e.preventDefault();
405
+ input.click();
406
+ });
407
+
408
+ wrapper.appendChild(button);
409
+
410
+ const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
411
+ const tooltip = createElement("div", "persona-send-button-tooltip");
412
+ tooltip.textContent = attachTooltipText;
413
+ wrapper.appendChild(tooltip);
414
+
415
+ return { button, wrapper, input, previewsContainer };
416
+ };
417
+
418
+ export const createStatusText = (config?: AgentWidgetConfig): HTMLElement => {
419
+ const statusConfig = config?.statusIndicator ?? {};
420
+ const alignClass =
421
+ statusConfig.align === "left"
422
+ ? "persona-text-left"
423
+ : statusConfig.align === "center"
424
+ ? "persona-text-center"
425
+ : "persona-text-right";
426
+ const statusText = createElement(
427
+ "div",
428
+ `persona-mt-2 ${alignClass} persona-text-xs persona-text-persona-muted`
429
+ );
430
+ statusText.setAttribute("data-persona-composer-status", "");
431
+
432
+ const isVisible = statusConfig.visible ?? true;
433
+ statusText.style.display = isVisible ? "" : "none";
434
+ const idleLabel = statusConfig.idleText ?? "Online";
435
+ if (statusConfig.idleLink) {
436
+ const link = createElement("a") as HTMLAnchorElement;
437
+ link.href = statusConfig.idleLink;
438
+ link.target = "_blank";
439
+ link.rel = "noopener noreferrer";
440
+ link.textContent = idleLabel;
441
+ link.style.color = "inherit";
442
+ link.style.textDecoration = "none";
443
+ statusText.appendChild(link);
444
+ } else {
445
+ statusText.textContent = idleLabel;
446
+ }
447
+
448
+ return statusText;
449
+ };
450
+
451
+ export const createSuggestionsRow = (): HTMLElement =>
452
+ createElement("div", "persona-mb-3 persona-flex persona-flex-wrap persona-gap-2");