@runtypelabs/persona 1.36.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 +1080 -0
  2. package/dist/index.cjs +140 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2626 -0
  5. package/dist/index.d.ts +2626 -0
  6. package/dist/index.global.js +1843 -0
  7. package/dist/index.global.js.map +1 -0
  8. package/dist/index.js +140 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/install.global.js +2 -0
  11. package/dist/install.global.js.map +1 -0
  12. package/dist/widget.css +1627 -0
  13. package/package.json +79 -0
  14. package/src/@types/idiomorph.d.ts +37 -0
  15. package/src/client.test.ts +387 -0
  16. package/src/client.ts +1589 -0
  17. package/src/components/composer-builder.ts +530 -0
  18. package/src/components/feedback.ts +379 -0
  19. package/src/components/forms.ts +170 -0
  20. package/src/components/header-builder.ts +455 -0
  21. package/src/components/header-layouts.ts +303 -0
  22. package/src/components/launcher.ts +193 -0
  23. package/src/components/message-bubble.ts +528 -0
  24. package/src/components/messages.ts +54 -0
  25. package/src/components/panel.ts +204 -0
  26. package/src/components/reasoning-bubble.ts +144 -0
  27. package/src/components/registry.ts +87 -0
  28. package/src/components/suggestions.ts +97 -0
  29. package/src/components/tool-bubble.ts +288 -0
  30. package/src/defaults.ts +321 -0
  31. package/src/index.ts +175 -0
  32. package/src/install.ts +284 -0
  33. package/src/plugins/registry.ts +77 -0
  34. package/src/plugins/types.ts +95 -0
  35. package/src/postprocessors.ts +194 -0
  36. package/src/runtime/init.ts +162 -0
  37. package/src/session.ts +376 -0
  38. package/src/styles/tailwind.css +20 -0
  39. package/src/styles/widget.css +1627 -0
  40. package/src/types.ts +1635 -0
  41. package/src/ui.ts +3341 -0
  42. package/src/utils/actions.ts +227 -0
  43. package/src/utils/attachment-manager.ts +384 -0
  44. package/src/utils/code-generators.test.ts +500 -0
  45. package/src/utils/code-generators.ts +1806 -0
  46. package/src/utils/component-middleware.ts +137 -0
  47. package/src/utils/component-parser.ts +119 -0
  48. package/src/utils/constants.ts +16 -0
  49. package/src/utils/content.ts +306 -0
  50. package/src/utils/dom.ts +25 -0
  51. package/src/utils/events.ts +41 -0
  52. package/src/utils/formatting.test.ts +166 -0
  53. package/src/utils/formatting.ts +470 -0
  54. package/src/utils/icons.ts +92 -0
  55. package/src/utils/message-id.ts +37 -0
  56. package/src/utils/morph.ts +36 -0
  57. package/src/utils/positioning.ts +17 -0
  58. package/src/utils/storage.ts +72 -0
  59. package/src/utils/theme.ts +105 -0
  60. package/src/widget.css +1 -0
  61. package/widget.css +1 -0
@@ -0,0 +1,530 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { AgentWidgetConfig, ContentPart } from "../types";
4
+ import { ALL_SUPPORTED_MIME_TYPES } from "../utils/content";
5
+
6
+ export interface ComposerElements {
7
+ footer: HTMLElement;
8
+ suggestions: HTMLElement;
9
+ composerForm: HTMLFormElement;
10
+ textarea: HTMLTextAreaElement;
11
+ sendButton: HTMLButtonElement;
12
+ sendButtonWrapper: HTMLElement;
13
+ micButton: HTMLButtonElement | null;
14
+ micButtonWrapper: HTMLElement | null;
15
+ statusText: HTMLElement;
16
+ // Attachment elements
17
+ attachmentButton: HTMLButtonElement | null;
18
+ attachmentButtonWrapper: HTMLElement | null;
19
+ attachmentInput: HTMLInputElement | null;
20
+ attachmentPreviewsContainer: HTMLElement | null;
21
+ // Actions row layout elements
22
+ actionsRow: HTMLElement;
23
+ leftActions: HTMLElement;
24
+ rightActions: HTMLElement;
25
+ }
26
+
27
+ /**
28
+ * Pending attachment before it's added to the message
29
+ */
30
+ export interface PendingAttachment {
31
+ id: string;
32
+ file: File;
33
+ previewUrl: string;
34
+ contentPart: ContentPart;
35
+ }
36
+
37
+ export interface ComposerBuildContext {
38
+ config?: AgentWidgetConfig;
39
+ onSubmit?: (text: string) => void;
40
+ disabled?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Helper to get font family CSS value from config preset
45
+ */
46
+ const getFontFamilyValue = (
47
+ family: "sans-serif" | "serif" | "mono"
48
+ ): string => {
49
+ switch (family) {
50
+ case "serif":
51
+ return 'Georgia, "Times New Roman", Times, serif';
52
+ case "mono":
53
+ return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
54
+ case "sans-serif":
55
+ default:
56
+ return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Build the composer/footer section of the panel.
62
+ * Extracted for reuse and plugin override support.
63
+ */
64
+ export const buildComposer = (context: ComposerBuildContext): ComposerElements => {
65
+ const { config } = context;
66
+
67
+ const footer = createElement(
68
+ "div",
69
+ "tvw-widget-footer tvw-border-t-cw-divider tvw-bg-cw-surface tvw-px-6 tvw-py-4"
70
+ );
71
+
72
+ const suggestions = createElement(
73
+ "div",
74
+ "tvw-mb-3 tvw-flex tvw-flex-wrap tvw-gap-2"
75
+ );
76
+
77
+ // Composer form uses column layout: textarea on top, actions row below
78
+ const composerForm = createElement(
79
+ "form",
80
+ `tvw-widget-composer tvw-flex tvw-flex-col tvw-gap-2 tvw-rounded-2xl tvw-border tvw-border-gray-200 tvw-bg-cw-input-background tvw-px-4 tvw-py-3`
81
+ ) as HTMLFormElement;
82
+ // Prevent form from getting focus styles
83
+ composerForm.style.outline = "none";
84
+
85
+ const textarea = createElement("textarea") as HTMLTextAreaElement;
86
+ textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
87
+ textarea.className =
88
+ "tvw-w-full tvw-min-h-[24px] tvw-resize-none tvw-border-none tvw-bg-transparent tvw-text-sm tvw-text-cw-primary focus:tvw-outline-none focus:tvw-border-none";
89
+ textarea.rows = 1;
90
+
91
+ // Apply font family and weight from config
92
+ const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
93
+ const fontWeight = config?.theme?.inputFontWeight ?? "400";
94
+
95
+ textarea.style.fontFamily = getFontFamilyValue(fontFamily);
96
+ textarea.style.fontWeight = fontWeight;
97
+
98
+ // Set up auto-resize: expand up to 3 lines, then scroll
99
+ // Line height is ~20px for text-sm (14px * 1.25 line-height), so 3 lines ≈ 60px
100
+ const maxLines = 3;
101
+ const lineHeight = 20; // Approximate line height for text-sm
102
+ const maxHeight = maxLines * lineHeight;
103
+ textarea.style.maxHeight = `${maxHeight}px`;
104
+ textarea.style.overflowY = "auto";
105
+
106
+ // Auto-resize function
107
+ const autoResize = () => {
108
+ // Reset height to auto to get the correct scrollHeight
109
+ textarea.style.height = "auto";
110
+ // Set height to scrollHeight (capped by maxHeight via CSS)
111
+ const newHeight = Math.min(textarea.scrollHeight, maxHeight);
112
+ textarea.style.height = `${newHeight}px`;
113
+ };
114
+
115
+ // Listen for input to auto-resize
116
+ textarea.addEventListener("input", autoResize);
117
+
118
+ // Explicitly remove border and outline on focus to prevent browser defaults
119
+ textarea.style.border = "none";
120
+ textarea.style.outline = "none";
121
+ textarea.style.borderWidth = "0";
122
+ textarea.style.borderStyle = "none";
123
+ textarea.style.borderColor = "transparent";
124
+ textarea.addEventListener("focus", () => {
125
+ textarea.style.border = "none";
126
+ textarea.style.outline = "none";
127
+ textarea.style.borderWidth = "0";
128
+ textarea.style.borderStyle = "none";
129
+ textarea.style.borderColor = "transparent";
130
+ textarea.style.boxShadow = "none";
131
+ });
132
+ textarea.addEventListener("blur", () => {
133
+ textarea.style.border = "none";
134
+ textarea.style.outline = "none";
135
+ });
136
+
137
+ // Send button configuration
138
+ const sendButtonConfig = config?.sendButton ?? {};
139
+ const useIcon = sendButtonConfig.useIcon ?? false;
140
+ const iconText = sendButtonConfig.iconText ?? "↑";
141
+ const iconName = sendButtonConfig.iconName;
142
+ const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
143
+ const showTooltip = sendButtonConfig.showTooltip ?? false;
144
+ const buttonSize = sendButtonConfig.size ?? "40px";
145
+ const backgroundColor = sendButtonConfig.backgroundColor;
146
+ const textColor = sendButtonConfig.textColor;
147
+
148
+ // Create wrapper for tooltip positioning
149
+ const sendButtonWrapper = createElement("div", "tvw-send-button-wrapper");
150
+
151
+ const sendButton = createElement(
152
+ "button",
153
+ useIcon
154
+ ? "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
155
+ : "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold disabled:tvw-opacity-50 tvw-cursor-pointer"
156
+ ) as HTMLButtonElement;
157
+
158
+ sendButton.type = "submit";
159
+
160
+ if (useIcon) {
161
+ // Icon mode: circular button
162
+ sendButton.style.width = buttonSize;
163
+ sendButton.style.height = buttonSize;
164
+ sendButton.style.minWidth = buttonSize;
165
+ sendButton.style.minHeight = buttonSize;
166
+ sendButton.style.fontSize = "18px";
167
+ sendButton.style.lineHeight = "1";
168
+
169
+ // Clear any existing content
170
+ sendButton.innerHTML = "";
171
+
172
+ // Use Lucide icon if iconName is provided, otherwise fall back to iconText
173
+ if (iconName) {
174
+ const iconSize = parseFloat(buttonSize) || 24;
175
+ const iconColor =
176
+ textColor && typeof textColor === "string" && textColor.trim()
177
+ ? textColor.trim()
178
+ : "currentColor";
179
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
180
+ if (iconSvg) {
181
+ sendButton.appendChild(iconSvg);
182
+ sendButton.style.color = iconColor;
183
+ } else {
184
+ // Fallback to text if icon fails to render
185
+ sendButton.textContent = iconText;
186
+ if (textColor) {
187
+ sendButton.style.color = textColor;
188
+ } else {
189
+ sendButton.classList.add("tvw-text-white");
190
+ }
191
+ }
192
+ } else {
193
+ sendButton.textContent = iconText;
194
+ if (textColor) {
195
+ sendButton.style.color = textColor;
196
+ } else {
197
+ sendButton.classList.add("tvw-text-white");
198
+ }
199
+ }
200
+
201
+ if (backgroundColor) {
202
+ sendButton.style.backgroundColor = backgroundColor;
203
+ } else {
204
+ sendButton.classList.add("tvw-bg-cw-primary");
205
+ }
206
+ } else {
207
+ // Text mode: existing behavior
208
+ sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
209
+ if (textColor) {
210
+ sendButton.style.color = textColor;
211
+ } else {
212
+ sendButton.classList.add("tvw-text-white");
213
+ }
214
+ }
215
+
216
+ // Apply existing styling from config
217
+ if (sendButtonConfig.borderWidth) {
218
+ sendButton.style.borderWidth = sendButtonConfig.borderWidth;
219
+ sendButton.style.borderStyle = "solid";
220
+ }
221
+ if (sendButtonConfig.borderColor) {
222
+ sendButton.style.borderColor = sendButtonConfig.borderColor;
223
+ }
224
+
225
+ // Apply padding styling (works in both icon and text mode)
226
+ if (sendButtonConfig.paddingX) {
227
+ sendButton.style.paddingLeft = sendButtonConfig.paddingX;
228
+ sendButton.style.paddingRight = sendButtonConfig.paddingX;
229
+ } else {
230
+ sendButton.style.paddingLeft = "";
231
+ sendButton.style.paddingRight = "";
232
+ }
233
+ if (sendButtonConfig.paddingY) {
234
+ sendButton.style.paddingTop = sendButtonConfig.paddingY;
235
+ sendButton.style.paddingBottom = sendButtonConfig.paddingY;
236
+ } else {
237
+ sendButton.style.paddingTop = "";
238
+ sendButton.style.paddingBottom = "";
239
+ }
240
+
241
+ // Add tooltip if enabled
242
+ if (showTooltip && tooltipText) {
243
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
244
+ tooltip.textContent = tooltipText;
245
+ sendButtonWrapper.appendChild(tooltip);
246
+ }
247
+
248
+ sendButtonWrapper.appendChild(sendButton);
249
+
250
+ // Voice recognition mic button
251
+ const voiceRecognitionConfig = config?.voiceRecognition ?? {};
252
+ const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
253
+ let micButton: HTMLButtonElement | null = null;
254
+ let micButtonWrapper: HTMLElement | null = null;
255
+
256
+ // Check browser support for speech recognition
257
+ const hasSpeechRecognition =
258
+ typeof window !== "undefined" &&
259
+ (typeof (window as any).webkitSpeechRecognition !== "undefined" ||
260
+ typeof (window as any).SpeechRecognition !== "undefined");
261
+
262
+ if (voiceRecognitionEnabled && hasSpeechRecognition) {
263
+ micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
264
+ micButton = createElement(
265
+ "button",
266
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
267
+ ) as HTMLButtonElement;
268
+
269
+ micButton.type = "button";
270
+ micButton.setAttribute("aria-label", "Start voice recognition");
271
+
272
+ const micIconName = voiceRecognitionConfig.iconName ?? "mic";
273
+ const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
274
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
275
+
276
+ // Use dedicated colors from voice recognition config, fallback to send button colors
277
+ const micBackgroundColor =
278
+ voiceRecognitionConfig.backgroundColor ?? backgroundColor;
279
+ const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
280
+
281
+ micButton.style.width = micIconSize;
282
+ micButton.style.height = micIconSize;
283
+ micButton.style.minWidth = micIconSize;
284
+ micButton.style.minHeight = micIconSize;
285
+ micButton.style.fontSize = "18px";
286
+ micButton.style.lineHeight = "1";
287
+
288
+ // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
289
+ const iconColorValue = micIconColor || "currentColor";
290
+ const micIconSvg = renderLucideIcon(
291
+ micIconName,
292
+ micIconSizeNum,
293
+ iconColorValue,
294
+ 1.5
295
+ );
296
+ if (micIconSvg) {
297
+ micButton.appendChild(micIconSvg);
298
+ micButton.style.color = iconColorValue;
299
+ } else {
300
+ // Fallback to text if icon fails
301
+ micButton.textContent = "🎤";
302
+ micButton.style.color = iconColorValue;
303
+ }
304
+
305
+ // Apply background color
306
+ if (micBackgroundColor) {
307
+ micButton.style.backgroundColor = micBackgroundColor;
308
+ } else {
309
+ micButton.classList.add("tvw-bg-cw-primary");
310
+ }
311
+
312
+ // Apply icon/text color
313
+ if (micIconColor) {
314
+ micButton.style.color = micIconColor;
315
+ } else if (!micIconColor && !textColor) {
316
+ micButton.classList.add("tvw-text-white");
317
+ }
318
+
319
+ // Apply border styling
320
+ if (voiceRecognitionConfig.borderWidth) {
321
+ micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
322
+ micButton.style.borderStyle = "solid";
323
+ }
324
+ if (voiceRecognitionConfig.borderColor) {
325
+ micButton.style.borderColor = voiceRecognitionConfig.borderColor;
326
+ }
327
+
328
+ // Apply padding styling
329
+ if (voiceRecognitionConfig.paddingX) {
330
+ micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
331
+ micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
332
+ }
333
+ if (voiceRecognitionConfig.paddingY) {
334
+ micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
335
+ micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
336
+ }
337
+
338
+ micButtonWrapper.appendChild(micButton);
339
+
340
+ // Add tooltip if enabled
341
+ const micTooltipText =
342
+ voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
343
+ const showMicTooltip = voiceRecognitionConfig.showTooltip ?? false;
344
+ if (showMicTooltip && micTooltipText) {
345
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
346
+ tooltip.textContent = micTooltipText;
347
+ micButtonWrapper.appendChild(tooltip);
348
+ }
349
+ }
350
+
351
+ // Attachment button and file input
352
+ const attachmentsConfig = config?.attachments ?? {};
353
+ const attachmentsEnabled = attachmentsConfig.enabled === true;
354
+ let attachmentButton: HTMLButtonElement | null = null;
355
+ let attachmentButtonWrapper: HTMLElement | null = null;
356
+ let attachmentInput: HTMLInputElement | null = null;
357
+ let attachmentPreviewsContainer: HTMLElement | null = null;
358
+
359
+ if (attachmentsEnabled) {
360
+ // Create previews container (shown above textarea when attachments are added)
361
+ attachmentPreviewsContainer = createElement(
362
+ "div",
363
+ "tvw-attachment-previews tvw-flex tvw-flex-wrap tvw-gap-2 tvw-mb-2"
364
+ );
365
+ attachmentPreviewsContainer.style.display = "none"; // Hidden until attachments added
366
+
367
+ // Create hidden file input
368
+ attachmentInput = createElement("input") as HTMLInputElement;
369
+ attachmentInput.type = "file";
370
+ attachmentInput.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
371
+ attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
372
+ attachmentInput.style.display = "none";
373
+ attachmentInput.setAttribute("aria-label", "Attach files");
374
+
375
+ // Create attachment button wrapper for tooltip
376
+ attachmentButtonWrapper = createElement("div", "tvw-send-button-wrapper");
377
+
378
+ // Create attachment button
379
+ attachmentButton = createElement(
380
+ "button",
381
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer tvw-attachment-button"
382
+ ) as HTMLButtonElement;
383
+ attachmentButton.type = "button";
384
+ attachmentButton.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
385
+
386
+ // Default to paperclip icon
387
+ const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
388
+ const attachIconSize = buttonSize;
389
+ const buttonSizeNum = parseFloat(attachIconSize) || 40;
390
+ // Icon should be ~60% of button size to match other icons visually
391
+ const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
392
+
393
+ attachmentButton.style.width = attachIconSize;
394
+ attachmentButton.style.height = attachIconSize;
395
+ attachmentButton.style.minWidth = attachIconSize;
396
+ attachmentButton.style.minHeight = attachIconSize;
397
+ attachmentButton.style.fontSize = "18px";
398
+ attachmentButton.style.lineHeight = "1";
399
+ attachmentButton.style.backgroundColor = "transparent";
400
+ attachmentButton.style.color = "var(--cw-primary, #111827)";
401
+ attachmentButton.style.border = "none";
402
+ attachmentButton.style.borderRadius = "6px";
403
+ attachmentButton.style.transition = "background-color 0.15s ease";
404
+
405
+ // Add hover effect via mouseenter/mouseleave
406
+ attachmentButton.addEventListener("mouseenter", () => {
407
+ attachmentButton!.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
408
+ });
409
+ attachmentButton.addEventListener("mouseleave", () => {
410
+ attachmentButton!.style.backgroundColor = "transparent";
411
+ });
412
+
413
+ // Render the icon
414
+ const attachIconSvg = renderLucideIcon(
415
+ attachIconName,
416
+ attachIconSizeNum,
417
+ "currentColor",
418
+ 1.5
419
+ );
420
+ if (attachIconSvg) {
421
+ attachmentButton.appendChild(attachIconSvg);
422
+ } else {
423
+ attachmentButton.textContent = "📎";
424
+ }
425
+
426
+ // Click handler to open file picker
427
+ attachmentButton.addEventListener("click", (e) => {
428
+ e.preventDefault();
429
+ attachmentInput?.click();
430
+ });
431
+
432
+ attachmentButtonWrapper.appendChild(attachmentButton);
433
+
434
+ // Add tooltip if configured
435
+ const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
436
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
437
+ tooltip.textContent = attachTooltipText;
438
+ attachmentButtonWrapper.appendChild(tooltip);
439
+ }
440
+
441
+ // Focus textarea when composer form container is clicked
442
+ composerForm.addEventListener("click", (e) => {
443
+ // Don't focus if clicking on the send button, mic button, attachment button, or their wrappers
444
+ if (
445
+ e.target !== sendButton &&
446
+ e.target !== sendButtonWrapper &&
447
+ e.target !== micButton &&
448
+ e.target !== micButtonWrapper &&
449
+ e.target !== attachmentButton &&
450
+ e.target !== attachmentButtonWrapper
451
+ ) {
452
+ textarea.focus();
453
+ }
454
+ });
455
+
456
+ // Layout structure:
457
+ // - Row 1: Image previews (smaller, above textarea)
458
+ // - Row 2: Textarea (full width)
459
+ // - Row 3: Actions row (attachment left, mic/send right)
460
+
461
+ // Add image previews first (above textarea)
462
+ if (attachmentPreviewsContainer) {
463
+ // Make previews smaller
464
+ attachmentPreviewsContainer.style.gap = "8px";
465
+ composerForm.append(attachmentPreviewsContainer);
466
+ }
467
+
468
+ // Hidden file input
469
+ if (attachmentInput) {
470
+ composerForm.append(attachmentInput);
471
+ }
472
+
473
+ // Textarea row (full width)
474
+ composerForm.append(textarea);
475
+
476
+ // Actions row: attachment on left, mic/send on right
477
+ const actionsRow = createElement("div", "tvw-flex tvw-items-center tvw-justify-between tvw-w-full");
478
+
479
+ // Left side: attachment button
480
+ const leftActions = createElement("div", "tvw-flex tvw-items-center tvw-gap-2");
481
+ if (attachmentButtonWrapper) {
482
+ leftActions.append(attachmentButtonWrapper);
483
+ }
484
+
485
+ // Right side: mic and send buttons
486
+ const rightActions = createElement("div", "tvw-flex tvw-items-center tvw-gap-1");
487
+ if (micButtonWrapper) {
488
+ rightActions.append(micButtonWrapper);
489
+ }
490
+ rightActions.append(sendButtonWrapper);
491
+
492
+ actionsRow.append(leftActions, rightActions);
493
+ composerForm.append(actionsRow);
494
+
495
+ const statusText = createElement(
496
+ "div",
497
+ "tvw-mt-2 tvw-text-right tvw-text-xs tvw-text-cw-muted"
498
+ );
499
+
500
+ // Apply status indicator config
501
+ const statusConfig = config?.statusIndicator ?? {};
502
+ const isVisible = statusConfig.visible ?? true;
503
+ statusText.style.display = isVisible ? "" : "none";
504
+ statusText.textContent = statusConfig.idleText ?? "Online";
505
+
506
+ footer.append(suggestions, composerForm, statusText);
507
+
508
+ return {
509
+ footer,
510
+ suggestions,
511
+ composerForm,
512
+ textarea,
513
+ sendButton,
514
+ sendButtonWrapper,
515
+ micButton,
516
+ micButtonWrapper,
517
+ statusText,
518
+ // Attachment elements
519
+ attachmentButton,
520
+ attachmentButtonWrapper,
521
+ attachmentInput,
522
+ attachmentPreviewsContainer,
523
+ // Actions row layout elements
524
+ actionsRow,
525
+ leftActions,
526
+ rightActions
527
+ };
528
+ };
529
+
530
+