@runtypelabs/persona 3.18.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +47 -47
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +281 -4
  5. package/dist/index.d.ts +281 -4
  6. package/dist/index.global.js +102 -1636
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +47 -47
  9. package/dist/index.js.map +1 -1
  10. package/dist/theme-editor.cjs +1438 -619
  11. package/dist/theme-editor.d.cts +119 -1
  12. package/dist/theme-editor.d.ts +119 -1
  13. package/dist/theme-editor.js +1552 -619
  14. package/dist/widget.css +348 -0
  15. package/package.json +1 -1
  16. package/src/components/composer-builder.test.ts +52 -0
  17. package/src/components/composer-builder.ts +67 -490
  18. package/src/components/composer-parts.test.ts +152 -0
  19. package/src/components/composer-parts.ts +452 -0
  20. package/src/components/header-builder.ts +22 -299
  21. package/src/components/header-parts.ts +360 -0
  22. package/src/components/panel.test.ts +61 -0
  23. package/src/components/panel.ts +262 -5
  24. package/src/components/pill-composer-builder.test.ts +85 -0
  25. package/src/components/pill-composer-builder.ts +183 -0
  26. package/src/index.ts +4 -0
  27. package/src/runtime/init.ts +4 -2
  28. package/src/runtime/persist-state.test.ts +152 -0
  29. package/src/styles/widget.css +348 -0
  30. package/src/types.ts +121 -1
  31. package/src/ui.component-directive.test.ts +183 -0
  32. package/src/ui.composer-bar.test.ts +1009 -0
  33. package/src/ui.ts +809 -72
  34. package/src/utils/attachment-manager.ts +1 -1
  35. package/src/utils/dock.test.ts +45 -0
  36. package/src/utils/dock.ts +3 -0
  37. package/src/utils/icons.ts +314 -58
  38. package/src/utils/stream-animation.ts +7 -2
@@ -1,7 +1,13 @@
1
1
  import { createElement } from "../utils/dom";
2
- import { renderLucideIcon } from "../utils/icons";
3
2
  import { AgentWidgetConfig, ContentPart } from "../types";
4
- import { ALL_SUPPORTED_MIME_TYPES } from "../utils/content";
3
+ import {
4
+ createAttachmentControls,
5
+ createComposerTextarea,
6
+ createMicButton,
7
+ createSendButton,
8
+ createStatusText,
9
+ createSuggestionsRow,
10
+ } from "./composer-parts";
5
11
 
6
12
  export interface ComposerElements {
7
13
  footer: HTMLElement;
@@ -13,12 +19,10 @@ export interface ComposerElements {
13
19
  micButton: HTMLButtonElement | null;
14
20
  micButtonWrapper: HTMLElement | null;
15
21
  statusText: HTMLElement;
16
- // Attachment elements
17
22
  attachmentButton: HTMLButtonElement | null;
18
23
  attachmentButtonWrapper: HTMLElement | null;
19
24
  attachmentInput: HTMLInputElement | null;
20
25
  attachmentPreviewsContainer: HTMLElement | null;
21
- // Actions row layout elements
22
26
  actionsRow: HTMLElement;
23
27
  leftActions: HTMLElement;
24
28
  rightActions: HTMLElement;
@@ -31,9 +35,6 @@ export interface ComposerElements {
31
35
  setSendButtonMode: (mode: "send" | "stop") => void;
32
36
  }
33
37
 
34
- /**
35
- * Pending attachment before it's added to the message
36
- */
37
38
  export interface PendingAttachment {
38
39
  id: string;
39
40
  file: File;
@@ -48,8 +49,10 @@ export interface ComposerBuildContext {
48
49
  }
49
50
 
50
51
  /**
51
- * Build the composer/footer section of the panel.
52
- * Extracted for reuse and plugin override support.
52
+ * Build the full footer + composer form (column-stacked card layout) for
53
+ * the floating, docked, and inline-embed launcher modes. The pill variant
54
+ * for `mountMode: "composer-bar"` lives in `pill-composer-builder.ts` and
55
+ * shares the same low-level part factories from `composer-parts.ts`.
53
56
  */
54
57
  export const buildComposer = (context: ComposerBuildContext): ComposerElements => {
55
58
  const { config } = context;
@@ -60,491 +63,69 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
60
63
  );
61
64
  footer.setAttribute("data-persona-theme-zone", "composer");
62
65
 
63
- const suggestions = createElement(
64
- "div",
65
- "persona-mb-3 persona-flex persona-flex-wrap persona-gap-2"
66
- );
66
+ const suggestions = createSuggestionsRow();
67
67
 
68
- // Composer form uses column layout: textarea on top, actions row below
69
68
  const composerForm = createElement(
70
69
  "form",
71
- `persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3`
70
+ "persona-widget-composer persona-flex persona-flex-col persona-gap-2 persona-rounded-2xl persona-border persona-border-gray-200 persona-bg-persona-input-background persona-px-4 persona-py-3"
72
71
  ) as HTMLFormElement;
73
72
  composerForm.setAttribute("data-persona-composer-form", "");
74
- // Prevent form from getting focus styles
75
73
  composerForm.style.outline = "none";
76
74
 
77
- const textarea = createElement("textarea") as HTMLTextAreaElement;
78
- textarea.setAttribute("data-persona-composer-input", "");
79
- textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
80
- textarea.className =
81
- "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";
82
- textarea.rows = 1;
83
-
84
- textarea.style.fontFamily =
85
- 'var(--persona-input-font-family, var(--persona-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif))';
86
- textarea.style.fontWeight = "var(--persona-input-font-weight, var(--persona-font-weight, 400))";
87
-
88
- // Set up auto-resize: expand up to 3 lines, then scroll
89
- // Line height is ~20px for text-sm (14px * 1.25 line-height), so 3 lines ≈ 60px
90
- const maxLines = 3;
91
- const lineHeight = 20; // Approximate line height for text-sm
92
- const maxHeight = maxLines * lineHeight;
93
- textarea.style.maxHeight = `${maxHeight}px`;
94
- textarea.style.overflowY = "auto";
95
-
96
- // Auto-resize function
97
- const autoResize = () => {
98
- // Reset height to auto to get the correct scrollHeight
99
- textarea.style.height = "auto";
100
- // Set height to scrollHeight (capped by maxHeight via CSS)
101
- const newHeight = Math.min(textarea.scrollHeight, maxHeight);
102
- textarea.style.height = `${newHeight}px`;
103
- };
104
-
105
- // Listen for input to auto-resize
106
- textarea.addEventListener("input", autoResize);
107
-
108
- // Explicitly remove border and outline on focus to prevent browser defaults
109
- textarea.style.border = "none";
110
- textarea.style.outline = "none";
111
- textarea.style.borderWidth = "0";
112
- textarea.style.borderStyle = "none";
113
- textarea.style.borderColor = "transparent";
114
- textarea.addEventListener("focus", () => {
115
- textarea.style.border = "none";
116
- textarea.style.outline = "none";
117
- textarea.style.borderWidth = "0";
118
- textarea.style.borderStyle = "none";
119
- textarea.style.borderColor = "transparent";
120
- textarea.style.boxShadow = "none";
121
- });
122
- textarea.addEventListener("blur", () => {
123
- textarea.style.border = "none";
124
- textarea.style.outline = "none";
125
- });
126
-
127
- // Send button configuration
128
- const sendButtonConfig = config?.sendButton ?? {};
129
- const useIcon = sendButtonConfig.useIcon ?? false;
130
- const iconText = sendButtonConfig.iconText ?? "↑";
131
- const iconName = sendButtonConfig.iconName;
132
- const stopIconName = sendButtonConfig.stopIconName ?? "square";
133
- const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
134
- const stopTooltipText = sendButtonConfig.stopTooltipText ?? "Stop generating";
135
- const sendLabel = config?.copy?.sendButtonLabel ?? "Send";
136
- const stopLabel = config?.copy?.stopButtonLabel ?? "Stop";
137
- const showTooltip = sendButtonConfig.showTooltip ?? false;
138
- const buttonSize = sendButtonConfig.size ?? "40px";
139
- const backgroundColor = sendButtonConfig.backgroundColor;
140
- const textColor = sendButtonConfig.textColor;
141
-
142
- // Create wrapper for tooltip positioning
143
- const sendButtonWrapper = createElement("div", "persona-send-button-wrapper");
144
-
145
- const sendButton = createElement(
146
- "button",
147
- useIcon
148
- ? "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
149
- : "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"
150
- ) as HTMLButtonElement;
151
-
152
- sendButton.type = "submit";
153
- sendButton.setAttribute("data-persona-composer-submit", "");
154
-
155
- // Icons for both modes are pre-rendered so setSendButtonMode can swap them
156
- // without having to re-render on every streaming state change.
157
- let sendIcon: SVGElement | null = null;
158
- let stopIcon: SVGElement | null = null;
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
- // Set button foreground color from config or theme token
173
- if (textColor) {
174
- sendButton.style.color = textColor;
175
- } else {
176
- sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
177
- }
178
-
179
- const iconSize = parseFloat(buttonSize) || 24;
180
- const iconColor = textColor?.trim() || "currentColor";
181
-
182
- // Use Lucide icon if iconName is provided, otherwise fall back to iconText
183
- if (iconName) {
184
- sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
185
- if (sendIcon) {
186
- sendButton.appendChild(sendIcon);
187
- } else {
188
- sendButton.textContent = iconText;
189
- }
190
- } else {
191
- sendButton.textContent = iconText;
192
- }
193
-
194
- // Pre-render the stop icon so mode swaps are cheap; it starts detached.
195
- stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
196
-
197
- if (backgroundColor) {
198
- sendButton.style.backgroundColor = backgroundColor;
199
- } else {
200
- sendButton.classList.add("persona-bg-persona-primary");
201
- }
202
- } else {
203
- // Text mode: existing behavior
204
- sendButton.textContent = sendLabel;
205
- if (textColor) {
206
- sendButton.style.color = textColor;
207
- } else {
208
- sendButton.classList.add("persona-text-white");
209
- }
210
- }
211
-
212
- // Apply existing styling from config
213
- if (sendButtonConfig.borderWidth) {
214
- sendButton.style.borderWidth = sendButtonConfig.borderWidth;
215
- sendButton.style.borderStyle = "solid";
216
- }
217
- if (sendButtonConfig.borderColor) {
218
- sendButton.style.borderColor = sendButtonConfig.borderColor;
219
- }
220
-
221
- // Apply padding styling (works in both icon and text mode)
222
- if (sendButtonConfig.paddingX) {
223
- sendButton.style.paddingLeft = sendButtonConfig.paddingX;
224
- sendButton.style.paddingRight = sendButtonConfig.paddingX;
225
- } else {
226
- sendButton.style.paddingLeft = "";
227
- sendButton.style.paddingRight = "";
228
- }
229
- if (sendButtonConfig.paddingY) {
230
- sendButton.style.paddingTop = sendButtonConfig.paddingY;
231
- sendButton.style.paddingBottom = sendButtonConfig.paddingY;
232
- } else {
233
- sendButton.style.paddingTop = "";
234
- sendButton.style.paddingBottom = "";
235
- }
236
-
237
- // Add tooltip if enabled
238
- let sendTooltip: HTMLElement | null = null;
239
- if (showTooltip && tooltipText) {
240
- sendTooltip = createElement("div", "persona-send-button-tooltip");
241
- sendTooltip.textContent = tooltipText;
242
- sendButtonWrapper.appendChild(sendTooltip);
243
- }
244
-
245
- sendButton.setAttribute("aria-label", tooltipText);
246
-
247
- sendButtonWrapper.appendChild(sendButton);
248
-
249
- let currentMode: "send" | "stop" = "send";
250
- const setSendButtonMode = (mode: "send" | "stop") => {
251
- if (mode === currentMode) return;
252
- currentMode = mode;
253
- const label = mode === "stop" ? stopTooltipText : tooltipText;
254
- sendButton.setAttribute("aria-label", label);
255
- if (sendTooltip) {
256
- sendTooltip.textContent = label;
257
- }
258
-
259
- if (useIcon) {
260
- // Only swap icons if both were rendered successfully; otherwise the
261
- // button is using textContent fallback and there's nothing to swap.
262
- if (sendIcon && stopIcon) {
263
- const next = mode === "stop" ? stopIcon : sendIcon;
264
- const prev = mode === "stop" ? sendIcon : stopIcon;
265
- if (prev.parentNode === sendButton) {
266
- sendButton.replaceChild(next, prev);
267
- } else {
268
- sendButton.appendChild(next);
269
- }
270
- }
271
- } else {
272
- sendButton.textContent = mode === "stop" ? stopLabel : sendLabel;
273
- }
274
- };
275
-
276
- // Voice recognition mic button
277
- const voiceRecognitionConfig = config?.voiceRecognition ?? {};
278
- const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
279
- let micButton: HTMLButtonElement | null = null;
280
- let micButtonWrapper: HTMLElement | null = null;
281
-
282
- // Check browser support for speech recognition or Runtype provider
283
- const hasSpeechRecognition =
284
- typeof window !== "undefined" &&
285
- (typeof (window as any).webkitSpeechRecognition !== "undefined" ||
286
- typeof (window as any).SpeechRecognition !== "undefined");
287
- const hasRuntypeProvider =
288
- voiceRecognitionConfig.provider?.type === "runtype";
289
- const hasVoiceInput = hasSpeechRecognition || hasRuntypeProvider;
290
-
291
- if (voiceRecognitionEnabled && hasVoiceInput) {
292
- micButtonWrapper = createElement("div", "persona-send-button-wrapper");
293
- micButton = createElement(
294
- "button",
295
- "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer"
296
- ) as HTMLButtonElement;
297
-
298
- micButton.type = "button";
299
- micButton.setAttribute("data-persona-composer-mic", "");
300
- micButton.setAttribute("aria-label", "Start voice recognition");
301
-
302
- const micIconName = voiceRecognitionConfig.iconName ?? "mic";
303
- const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
304
- const micIconSizeNum = parseFloat(micIconSize) || 24;
305
-
306
- // Use dedicated colors from voice recognition config, fallback to send button colors
307
- const micBackgroundColor =
308
- voiceRecognitionConfig.backgroundColor ?? backgroundColor;
309
- const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
310
-
311
- micButton.style.width = micIconSize;
312
- micButton.style.height = micIconSize;
313
- micButton.style.minWidth = micIconSize;
314
- micButton.style.minHeight = micIconSize;
315
- micButton.style.fontSize = "18px";
316
- micButton.style.lineHeight = "1";
317
-
318
- // Set mic button foreground from config or theme token
319
- if (micIconColor) {
320
- micButton.style.color = micIconColor;
321
- } else {
322
- micButton.style.color = "var(--persona-text, #111827)";
323
- }
324
-
325
- // Use Lucide mic icon (stroke width 1.5 for minimalist outline style)
326
- const iconColorValue = micIconColor || "currentColor";
327
- const micIconSvg = renderLucideIcon(
328
- micIconName,
329
- micIconSizeNum,
330
- iconColorValue,
331
- 1.5
332
- );
333
- if (micIconSvg) {
334
- micButton.appendChild(micIconSvg);
335
- } else {
336
- micButton.textContent = "🎤";
337
- }
338
-
339
- // Apply background color
340
- if (micBackgroundColor) {
341
- micButton.style.backgroundColor = micBackgroundColor;
342
- }
343
-
344
- // Apply border styling
345
- if (voiceRecognitionConfig.borderWidth) {
346
- micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
347
- micButton.style.borderStyle = "solid";
348
- }
349
- if (voiceRecognitionConfig.borderColor) {
350
- micButton.style.borderColor = voiceRecognitionConfig.borderColor;
351
- }
352
-
353
- // Apply padding styling
354
- if (voiceRecognitionConfig.paddingX) {
355
- micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
356
- micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
357
- }
358
- if (voiceRecognitionConfig.paddingY) {
359
- micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
360
- micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
361
- }
362
-
363
- micButtonWrapper.appendChild(micButton);
364
-
365
- // Add tooltip if enabled
366
- const micTooltipText =
367
- voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
368
- const showMicTooltip = voiceRecognitionConfig.showTooltip ?? false;
369
- if (showMicTooltip && micTooltipText) {
370
- const tooltip = createElement("div", "persona-send-button-tooltip");
371
- tooltip.textContent = micTooltipText;
372
- micButtonWrapper.appendChild(tooltip);
373
- }
75
+ const { textarea, attachAutoResize } = createComposerTextarea(config);
76
+ attachAutoResize();
77
+
78
+ const send = createSendButton(config);
79
+ const mic = createMicButton(config);
80
+ const attachment = createAttachmentControls(config);
81
+ const statusText = createStatusText(config);
82
+
83
+ // Layout (column):
84
+ // row 1: attachment previews (above textarea, smaller)
85
+ // row 2: textarea (full width)
86
+ // row 3: actions (paperclip left, mic + send right)
87
+ if (attachment) {
88
+ attachment.previewsContainer.style.gap = "8px";
89
+ composerForm.append(attachment.previewsContainer, attachment.input);
374
90
  }
91
+ composerForm.append(textarea);
375
92
 
376
- // Attachment button and file input
377
- const attachmentsConfig = config?.attachments ?? {};
378
- const attachmentsEnabled = attachmentsConfig.enabled === true;
379
- let attachmentButton: HTMLButtonElement | null = null;
380
- let attachmentButtonWrapper: HTMLElement | null = null;
381
- let attachmentInput: HTMLInputElement | null = null;
382
- let attachmentPreviewsContainer: HTMLElement | null = null;
383
-
384
- if (attachmentsEnabled) {
385
- // Create previews container (shown above textarea when attachments are added)
386
- attachmentPreviewsContainer = createElement(
387
- "div",
388
- "persona-attachment-previews persona-flex persona-flex-wrap persona-gap-2 persona-mb-2"
389
- );
390
- attachmentPreviewsContainer.style.display = "none"; // Hidden until attachments added
391
-
392
- // Create hidden file input
393
- attachmentInput = createElement("input") as HTMLInputElement;
394
- attachmentInput.type = "file";
395
- attachmentInput.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
396
- attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
397
- attachmentInput.style.display = "none";
398
- attachmentInput.setAttribute("aria-label", "Attach files");
399
-
400
- // Create attachment button wrapper for tooltip
401
- attachmentButtonWrapper = createElement("div", "persona-send-button-wrapper");
402
-
403
- // Create attachment button
404
- attachmentButton = createElement(
405
- "button",
406
- "persona-rounded-button persona-flex persona-items-center persona-justify-center disabled:persona-opacity-50 persona-cursor-pointer persona-attachment-button"
407
- ) as HTMLButtonElement;
408
- attachmentButton.type = "button";
409
- attachmentButton.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
410
-
411
- // Default to paperclip icon
412
- const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
413
- const attachIconSize = buttonSize;
414
- const buttonSizeNum = parseFloat(attachIconSize) || 40;
415
- // Icon should be ~60% of button size to match other icons visually
416
- const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
417
-
418
- attachmentButton.style.width = attachIconSize;
419
- attachmentButton.style.height = attachIconSize;
420
- attachmentButton.style.minWidth = attachIconSize;
421
- attachmentButton.style.minHeight = attachIconSize;
422
- attachmentButton.style.fontSize = "18px";
423
- attachmentButton.style.lineHeight = "1";
424
- attachmentButton.style.backgroundColor = "transparent";
425
- attachmentButton.style.color = "var(--persona-primary, #111827)";
426
- attachmentButton.style.border = "none";
427
- attachmentButton.style.borderRadius = "6px";
428
- attachmentButton.style.transition = "background-color 0.15s ease";
429
-
430
- // Add hover effect via mouseenter/mouseleave
431
- attachmentButton.addEventListener("mouseenter", () => {
432
- attachmentButton!.style.backgroundColor = "var(--persona-palette-colors-black-alpha-50, rgba(0, 0, 0, 0.05))";
433
- });
434
- attachmentButton.addEventListener("mouseleave", () => {
435
- attachmentButton!.style.backgroundColor = "transparent";
436
- });
437
-
438
- // Render the icon
439
- const attachIconSvg = renderLucideIcon(
440
- attachIconName,
441
- attachIconSizeNum,
442
- "currentColor",
443
- 1.5
444
- );
445
- if (attachIconSvg) {
446
- attachmentButton.appendChild(attachIconSvg);
447
- } else {
448
- attachmentButton.textContent = "📎";
449
- }
450
-
451
- // Click handler to open file picker
452
- attachmentButton.addEventListener("click", (e) => {
453
- e.preventDefault();
454
- attachmentInput?.click();
455
- });
456
-
457
- attachmentButtonWrapper.appendChild(attachmentButton);
458
-
459
- // Add tooltip if configured
460
- const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
461
- const tooltip = createElement("div", "persona-send-button-tooltip");
462
- tooltip.textContent = attachTooltipText;
463
- attachmentButtonWrapper.appendChild(tooltip);
464
- }
93
+ // The bare class names (persona-widget-composer__actions / __left-actions /
94
+ // __right-actions) are stable CSS hooks. The pill composer reuses
95
+ // __left-actions / __right-actions as semantic markers in its grid.
96
+ const actionsRow = createElement(
97
+ "div",
98
+ "persona-widget-composer__actions persona-flex persona-items-center persona-justify-between persona-w-full"
99
+ );
100
+ const leftActions = createElement(
101
+ "div",
102
+ "persona-widget-composer__left-actions persona-flex persona-items-center persona-gap-2"
103
+ );
104
+ const rightActions = createElement(
105
+ "div",
106
+ "persona-widget-composer__right-actions persona-flex persona-items-center persona-gap-1"
107
+ );
108
+ if (attachment) leftActions.append(attachment.wrapper);
109
+ if (mic) rightActions.append(mic.wrapper);
110
+ rightActions.append(send.wrapper);
111
+ actionsRow.append(leftActions, rightActions);
112
+ composerForm.append(actionsRow);
465
113
 
466
- // Focus textarea when composer form container is clicked
114
+ // Click anywhere on the composer (other than the action buttons) → focus
115
+ // textarea so the click target feels like the whole input bar.
467
116
  composerForm.addEventListener("click", (e) => {
468
- // Don't focus if clicking on the send button, mic button, attachment button, or their wrappers
469
117
  if (
470
- e.target !== sendButton &&
471
- e.target !== sendButtonWrapper &&
472
- e.target !== micButton &&
473
- e.target !== micButtonWrapper &&
474
- e.target !== attachmentButton &&
475
- e.target !== attachmentButtonWrapper
118
+ e.target !== send.button &&
119
+ e.target !== send.wrapper &&
120
+ e.target !== mic?.button &&
121
+ e.target !== mic?.wrapper &&
122
+ e.target !== attachment?.button &&
123
+ e.target !== attachment?.wrapper
476
124
  ) {
477
125
  textarea.focus();
478
126
  }
479
127
  });
480
128
 
481
- // Layout structure:
482
- // - Row 1: Image previews (smaller, above textarea)
483
- // - Row 2: Textarea (full width)
484
- // - Row 3: Actions row (attachment left, mic/send right)
485
-
486
- // Add image previews first (above textarea)
487
- if (attachmentPreviewsContainer) {
488
- // Make previews smaller
489
- attachmentPreviewsContainer.style.gap = "8px";
490
- composerForm.append(attachmentPreviewsContainer);
491
- }
492
-
493
- // Hidden file input
494
- if (attachmentInput) {
495
- composerForm.append(attachmentInput);
496
- }
497
-
498
- // Textarea row (full width)
499
- composerForm.append(textarea);
500
-
501
- // Actions row: attachment on left, mic/send on right
502
- const actionsRow = createElement("div", "persona-flex persona-items-center persona-justify-between persona-w-full");
503
-
504
- // Left side: attachment button
505
- const leftActions = createElement("div", "persona-flex persona-items-center persona-gap-2");
506
- if (attachmentButtonWrapper) {
507
- leftActions.append(attachmentButtonWrapper);
508
- }
509
-
510
- // Right side: mic and send buttons
511
- const rightActions = createElement("div", "persona-flex persona-items-center persona-gap-1");
512
- if (micButtonWrapper) {
513
- rightActions.append(micButtonWrapper);
514
- }
515
- rightActions.append(sendButtonWrapper);
516
-
517
- actionsRow.append(leftActions, rightActions);
518
- composerForm.append(actionsRow);
519
-
520
- // Apply status indicator config
521
- const statusConfig = config?.statusIndicator ?? {};
522
- const alignClass =
523
- statusConfig.align === "left" ? "persona-text-left"
524
- : statusConfig.align === "center" ? "persona-text-center"
525
- : "persona-text-right";
526
- const statusText = createElement(
527
- "div",
528
- `persona-mt-2 ${alignClass} persona-text-xs persona-text-persona-muted`
529
- );
530
- statusText.setAttribute("data-persona-composer-status", "");
531
-
532
- const isVisible = statusConfig.visible ?? true;
533
- statusText.style.display = isVisible ? "" : "none";
534
- const idleLabel = statusConfig.idleText ?? "Online";
535
- if (statusConfig.idleLink) {
536
- const link = createElement("a");
537
- link.href = statusConfig.idleLink;
538
- link.target = "_blank";
539
- link.rel = "noopener noreferrer";
540
- link.textContent = idleLabel;
541
- link.style.color = "inherit";
542
- link.style.textDecoration = "none";
543
- statusText.appendChild(link);
544
- } else {
545
- statusText.textContent = idleLabel;
546
- }
547
-
548
129
  footer.append(suggestions, composerForm, statusText);
549
130
 
550
131
  return {
@@ -552,22 +133,18 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
552
133
  suggestions,
553
134
  composerForm,
554
135
  textarea,
555
- sendButton,
556
- sendButtonWrapper,
557
- micButton,
558
- micButtonWrapper,
136
+ sendButton: send.button,
137
+ sendButtonWrapper: send.wrapper,
138
+ micButton: mic?.button ?? null,
139
+ micButtonWrapper: mic?.wrapper ?? null,
559
140
  statusText,
560
- // Attachment elements
561
- attachmentButton,
562
- attachmentButtonWrapper,
563
- attachmentInput,
564
- attachmentPreviewsContainer,
565
- // Actions row layout elements
141
+ attachmentButton: attachment?.button ?? null,
142
+ attachmentButtonWrapper: attachment?.wrapper ?? null,
143
+ attachmentInput: attachment?.input ?? null,
144
+ attachmentPreviewsContainer: attachment?.previewsContainer ?? null,
566
145
  actionsRow,
567
146
  leftActions,
568
147
  rightActions,
569
- setSendButtonMode
148
+ setSendButtonMode: send.setMode,
570
149
  };
571
150
  };
572
-
573
-