vanilla-agent 1.10.0 → 1.11.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.
@@ -1,7 +1,9 @@
1
1
  import { createElement } from "../utils/dom";
2
- import { renderLucideIcon } from "../utils/icons";
3
2
  import { AgentWidgetConfig } from "../types";
4
3
  import { positionMap } from "../utils/positioning";
4
+ import { buildHeader, attachHeaderToContainer, HeaderElements } from "./header-builder";
5
+ import { buildHeaderWithLayout } from "./header-layouts";
6
+ import { buildComposer, ComposerElements } from "./composer-builder";
5
7
 
6
8
  export interface PanelWrapper {
7
9
  wrapper: HTMLElement;
@@ -12,13 +14,15 @@ export const createWrapper = (config?: AgentWidgetConfig): PanelWrapper => {
12
14
  const launcherEnabled = config?.launcher?.enabled ?? true;
13
15
 
14
16
  if (!launcherEnabled) {
17
+ // For inline embed mode, use flex layout to ensure the widget fills its container
18
+ // and only the chat messages area scrolls
15
19
  const wrapper = createElement(
16
20
  "div",
17
- "tvw-relative tvw-w-full tvw-h-full"
21
+ "tvw-relative tvw-w-full tvw-h-full tvw-flex tvw-flex-col tvw-flex-1 tvw-min-h-0"
18
22
  );
19
23
  const panel = createElement(
20
24
  "div",
21
- "tvw-relative tvw-w-full tvw-h-full tvw-min-h-[360px]"
25
+ "tvw-relative tvw-w-full tvw-flex-1 tvw-flex tvw-flex-col tvw-min-h-0"
22
26
  );
23
27
  wrapper.appendChild(panel);
24
28
  return { wrapper, panel };
@@ -69,383 +73,32 @@ export interface PanelElements {
69
73
  iconHolder: HTMLElement;
70
74
  headerTitle: HTMLElement;
71
75
  headerSubtitle: HTMLElement;
76
+ // Exposed for potential header replacement
77
+ header: HTMLElement;
78
+ footer: HTMLElement;
72
79
  }
73
80
 
74
81
  export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelElements => {
82
+ // Use flex-1 and min-h-0 to ensure the container fills its parent and allows
83
+ // the body (chat messages area) to scroll while header/footer stay fixed
75
84
  const container = createElement(
76
85
  "div",
77
- "tvw-flex tvw-h-full tvw-w-full tvw-flex-col tvw-bg-cw-surface tvw-text-cw-primary tvw-rounded-2xl tvw-overflow-hidden tvw-shadow-2xl tvw-border tvw-border-cw-border"
86
+ "tvw-flex tvw-h-full tvw-w-full tvw-flex-1 tvw-min-h-0 tvw-flex-col tvw-bg-cw-surface tvw-text-cw-primary tvw-rounded-2xl tvw-overflow-hidden tvw-shadow-2xl tvw-border tvw-border-cw-border"
78
87
  );
79
88
 
80
- const header = createElement(
81
- "div",
82
- "tvw-flex tvw-items-center tvw-gap-3 tvw-bg-cw-surface tvw-px-6 tvw-py-5 tvw-border-b-cw-divider"
83
- );
84
-
85
- const launcher = config?.launcher ?? {};
86
- const headerIconSize = launcher.headerIconSize ?? "48px";
87
- const closeButtonSize = launcher.closeButtonSize ?? "32px";
88
- const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
89
- const headerIconHidden = launcher.headerIconHidden ?? false;
90
- const headerIconName = launcher.headerIconName;
91
-
92
- const iconHolder = createElement(
93
- "div",
94
- "tvw-flex tvw-items-center tvw-justify-center tvw-rounded-xl tvw-bg-cw-primary tvw-text-white tvw-text-xl"
95
- );
96
- iconHolder.style.height = headerIconSize;
97
- iconHolder.style.width = headerIconSize;
98
-
99
- // Render icon based on priority: Lucide icon > iconUrl > agentIconText
100
- if (!headerIconHidden) {
101
- if (headerIconName) {
102
- // Use Lucide icon
103
- const iconSize = parseFloat(headerIconSize) || 24;
104
- const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
105
- if (iconSvg) {
106
- iconHolder.replaceChildren(iconSvg);
107
- } else {
108
- // Fallback to agentIconText if Lucide icon fails
109
- iconHolder.textContent = config?.launcher?.agentIconText ?? "💬";
110
- }
111
- } else if (config?.launcher?.iconUrl) {
112
- // Use image URL
113
- const img = createElement("img") as HTMLImageElement;
114
- img.src = config.launcher.iconUrl;
115
- img.alt = "";
116
- img.className = "tvw-rounded-xl tvw-object-cover";
117
- img.style.height = headerIconSize;
118
- img.style.width = headerIconSize;
119
- iconHolder.replaceChildren(img);
120
- } else {
121
- // Use text/emoji
122
- iconHolder.textContent = config?.launcher?.agentIconText ?? "💬";
123
- }
124
- }
125
-
126
- const headerCopy = createElement("div", "tvw-flex tvw-flex-col");
127
- const title = createElement(
128
- "span",
129
- "tvw-text-base tvw-font-semibold"
130
- );
131
- title.textContent =
132
- config?.launcher?.title ?? "Chat Assistant";
133
- const subtitle = createElement(
134
- "span",
135
- "tvw-text-xs tvw-text-cw-muted"
136
- );
137
- subtitle.textContent =
138
- config?.launcher?.subtitle ?? "Here to help you get answers fast";
139
-
140
- headerCopy.append(title, subtitle);
141
-
142
- // Only append iconHolder if not hidden
143
- if (!headerIconHidden) {
144
- header.append(iconHolder, headerCopy);
145
- } else {
146
- header.append(headerCopy);
147
- }
148
-
149
- // Create clear chat button if enabled
150
- const clearChatConfig = launcher.clearChat ?? {};
151
- const clearChatEnabled = clearChatConfig.enabled ?? true;
152
- let clearChatButton: HTMLButtonElement | null = null;
153
- let clearChatButtonWrapper: HTMLElement | null = null;
154
-
155
- if (clearChatEnabled) {
156
- const clearChatSize = clearChatConfig.size ?? "32px";
157
- const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
158
- const clearChatIconColor = clearChatConfig.iconColor ?? "";
159
- const clearChatBgColor = clearChatConfig.backgroundColor ?? "";
160
- const clearChatBorderWidth = clearChatConfig.borderWidth ?? "";
161
- const clearChatBorderColor = clearChatConfig.borderColor ?? "";
162
- const clearChatBorderRadius = clearChatConfig.borderRadius ?? "";
163
- const clearChatPaddingX = clearChatConfig.paddingX ?? "";
164
- const clearChatPaddingY = clearChatConfig.paddingY ?? "";
165
- const clearChatTooltipText = clearChatConfig.tooltipText ?? "Clear chat";
166
- const clearChatShowTooltip = clearChatConfig.showTooltip ?? true;
167
-
168
- // Create button wrapper for tooltip
169
- clearChatButtonWrapper = createElement(
170
- "div",
171
- "tvw-relative tvw-ml-auto tvw-clear-chat-button-wrapper"
172
- );
173
-
174
- clearChatButton = createElement(
175
- "button",
176
- "tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none"
177
- ) as HTMLButtonElement;
178
-
179
- clearChatButton.style.height = clearChatSize;
180
- clearChatButton.style.width = clearChatSize;
181
- clearChatButton.type = "button";
182
- clearChatButton.setAttribute("aria-label", clearChatTooltipText);
183
-
184
- // Add icon
185
- const iconSvg = renderLucideIcon(clearChatIconName, "20px", clearChatIconColor || "", 2);
186
- if (iconSvg) {
187
- clearChatButton.appendChild(iconSvg);
188
- }
189
-
190
- // Apply styling from config
191
- if (clearChatIconColor) {
192
- clearChatButton.style.color = clearChatIconColor;
193
- clearChatButton.classList.remove("tvw-text-cw-muted");
194
- }
195
-
196
- if (clearChatBgColor) {
197
- clearChatButton.style.backgroundColor = clearChatBgColor;
198
- clearChatButton.classList.remove("hover:tvw-bg-gray-100");
199
- }
200
-
201
- if (clearChatBorderWidth || clearChatBorderColor) {
202
- const borderWidth = clearChatBorderWidth || "0px";
203
- const borderColor = clearChatBorderColor || "transparent";
204
- clearChatButton.style.border = `${borderWidth} solid ${borderColor}`;
205
- clearChatButton.classList.remove("tvw-border-none");
206
- }
207
-
208
- if (clearChatBorderRadius) {
209
- clearChatButton.style.borderRadius = clearChatBorderRadius;
210
- clearChatButton.classList.remove("tvw-rounded-full");
211
- }
212
-
213
- // Apply padding styling
214
- if (clearChatPaddingX) {
215
- clearChatButton.style.paddingLeft = clearChatPaddingX;
216
- clearChatButton.style.paddingRight = clearChatPaddingX;
217
- } else {
218
- clearChatButton.style.paddingLeft = "";
219
- clearChatButton.style.paddingRight = "";
220
- }
221
- if (clearChatPaddingY) {
222
- clearChatButton.style.paddingTop = clearChatPaddingY;
223
- clearChatButton.style.paddingBottom = clearChatPaddingY;
224
- } else {
225
- clearChatButton.style.paddingTop = "";
226
- clearChatButton.style.paddingBottom = "";
227
- }
228
-
229
- clearChatButtonWrapper.appendChild(clearChatButton);
230
-
231
- // Add tooltip with portaling to document.body to escape overflow clipping
232
- if (clearChatShowTooltip && clearChatTooltipText && clearChatButton && clearChatButtonWrapper) {
233
- let portaledTooltip: HTMLElement | null = null;
234
-
235
- const showTooltip = () => {
236
- if (portaledTooltip || !clearChatButton) return; // Already showing or button doesn't exist
237
-
238
- // Create tooltip element
239
- portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
240
- portaledTooltip.textContent = clearChatTooltipText;
241
-
242
- // Add arrow
243
- const arrow = createElement("div");
244
- arrow.className = "tvw-clear-chat-tooltip-arrow";
245
- portaledTooltip.appendChild(arrow);
246
-
247
- // Get button position
248
- const buttonRect = clearChatButton.getBoundingClientRect();
249
-
250
- // Position tooltip above button
251
- portaledTooltip.style.position = "fixed";
252
- portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
253
- portaledTooltip.style.top = `${buttonRect.top - 8}px`;
254
- portaledTooltip.style.transform = "translate(-50%, -100%)";
255
-
256
- // Append to body
257
- document.body.appendChild(portaledTooltip);
258
- };
259
-
260
- const hideTooltip = () => {
261
- if (portaledTooltip && portaledTooltip.parentNode) {
262
- portaledTooltip.parentNode.removeChild(portaledTooltip);
263
- portaledTooltip = null;
264
- }
265
- };
266
-
267
- // Add event listeners
268
- clearChatButtonWrapper.addEventListener("mouseenter", showTooltip);
269
- clearChatButtonWrapper.addEventListener("mouseleave", hideTooltip);
270
- clearChatButton.addEventListener("focus", showTooltip);
271
- clearChatButton.addEventListener("blur", hideTooltip);
272
-
273
- // Store cleanup function on the button for later use
274
- (clearChatButtonWrapper as any)._cleanupTooltip = () => {
275
- hideTooltip();
276
- if (clearChatButtonWrapper) {
277
- clearChatButtonWrapper.removeEventListener("mouseenter", showTooltip);
278
- clearChatButtonWrapper.removeEventListener("mouseleave", hideTooltip);
279
- }
280
- if (clearChatButton) {
281
- clearChatButton.removeEventListener("focus", showTooltip);
282
- clearChatButton.removeEventListener("blur", hideTooltip);
283
- }
284
- };
285
- }
286
-
287
- header.appendChild(clearChatButtonWrapper);
288
- }
289
-
290
- // Create close button wrapper for tooltip positioning
291
- const closeButtonWrapper = createElement(
292
- "div",
293
- closeButtonPlacement === "top-right"
294
- ? "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50"
295
- : (clearChatEnabled
296
- ? ""
297
- : "tvw-ml-auto")
298
- );
299
-
300
- // Create close button with base classes
301
- const closeButton = createElement(
302
- "button",
303
- "tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none"
304
- ) as HTMLButtonElement;
305
- closeButton.style.height = closeButtonSize;
306
- closeButton.style.width = closeButtonSize;
307
- closeButton.type = "button";
308
-
309
- // Get tooltip config
310
- const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
311
- const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
312
-
313
- closeButton.setAttribute("aria-label", closeButtonTooltipText);
314
- closeButton.style.display = showClose ? "" : "none";
315
-
316
- // Add icon or fallback text
317
- const closeButtonIconName = launcher.closeButtonIconName ?? "x";
318
- const closeButtonIconText = launcher.closeButtonIconText ?? "×";
319
-
320
- // Try to render Lucide icon, fallback to text if not provided or fails
321
- const iconSvg = renderLucideIcon(closeButtonIconName, "20px", launcher.closeButtonColor || "", 2);
322
- if (iconSvg) {
323
- closeButton.appendChild(iconSvg);
324
- } else {
325
- closeButton.textContent = closeButtonIconText;
326
- }
327
-
328
- // Apply close button styling from config
329
- if (launcher.closeButtonColor) {
330
- closeButton.style.color = launcher.closeButtonColor;
331
- closeButton.classList.remove("tvw-text-cw-muted");
332
- } else {
333
- closeButton.style.color = "";
334
- closeButton.classList.add("tvw-text-cw-muted");
335
- }
336
-
337
- if (launcher.closeButtonBackgroundColor) {
338
- closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
339
- closeButton.classList.remove("hover:tvw-bg-gray-100");
340
- } else {
341
- closeButton.style.backgroundColor = "";
342
- closeButton.classList.add("hover:tvw-bg-gray-100");
343
- }
344
-
345
- // Apply border if width and/or color are provided
346
- if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
347
- const borderWidth = launcher.closeButtonBorderWidth || "0px";
348
- const borderColor = launcher.closeButtonBorderColor || "transparent";
349
- closeButton.style.border = `${borderWidth} solid ${borderColor}`;
350
- closeButton.classList.remove("tvw-border-none");
351
- } else {
352
- closeButton.style.border = "";
353
- closeButton.classList.add("tvw-border-none");
354
- }
355
-
356
- if (launcher.closeButtonBorderRadius) {
357
- closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
358
- closeButton.classList.remove("tvw-rounded-full");
359
- } else {
360
- closeButton.style.borderRadius = "";
361
- closeButton.classList.add("tvw-rounded-full");
362
- }
363
-
364
- // Apply padding styling
365
- if (launcher.closeButtonPaddingX) {
366
- closeButton.style.paddingLeft = launcher.closeButtonPaddingX;
367
- closeButton.style.paddingRight = launcher.closeButtonPaddingX;
368
- } else {
369
- closeButton.style.paddingLeft = "";
370
- closeButton.style.paddingRight = "";
371
- }
372
- if (launcher.closeButtonPaddingY) {
373
- closeButton.style.paddingTop = launcher.closeButtonPaddingY;
374
- closeButton.style.paddingBottom = launcher.closeButtonPaddingY;
375
- } else {
376
- closeButton.style.paddingTop = "";
377
- closeButton.style.paddingBottom = "";
378
- }
379
-
380
- closeButtonWrapper.appendChild(closeButton);
381
-
382
- // Add tooltip with portaling to document.body to escape overflow clipping
383
- if (closeButtonShowTooltip && closeButtonTooltipText) {
384
- let portaledTooltip: HTMLElement | null = null;
385
-
386
- const showTooltip = () => {
387
- if (portaledTooltip) return; // Already showing
388
-
389
- // Create tooltip element
390
- portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
391
- portaledTooltip.textContent = closeButtonTooltipText;
392
-
393
- // Add arrow
394
- const arrow = createElement("div");
395
- arrow.className = "tvw-clear-chat-tooltip-arrow";
396
- portaledTooltip.appendChild(arrow);
397
-
398
- // Get button position
399
- const buttonRect = closeButton.getBoundingClientRect();
400
-
401
- // Position tooltip above button
402
- portaledTooltip.style.position = "fixed";
403
- portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
404
- portaledTooltip.style.top = `${buttonRect.top - 8}px`;
405
- portaledTooltip.style.transform = "translate(-50%, -100%)";
406
-
407
- // Append to body
408
- document.body.appendChild(portaledTooltip);
409
- };
410
-
411
- const hideTooltip = () => {
412
- if (portaledTooltip && portaledTooltip.parentNode) {
413
- portaledTooltip.parentNode.removeChild(portaledTooltip);
414
- portaledTooltip = null;
415
- }
416
- };
417
-
418
- // Add event listeners
419
- closeButtonWrapper.addEventListener("mouseenter", showTooltip);
420
- closeButtonWrapper.addEventListener("mouseleave", hideTooltip);
421
- closeButton.addEventListener("focus", showTooltip);
422
- closeButton.addEventListener("blur", hideTooltip);
423
-
424
- // Store cleanup function on the wrapper for later use
425
- (closeButtonWrapper as any)._cleanupTooltip = () => {
426
- hideTooltip();
427
- closeButtonWrapper.removeEventListener("mouseenter", showTooltip);
428
- closeButtonWrapper.removeEventListener("mouseleave", hideTooltip);
429
- closeButton.removeEventListener("focus", showTooltip);
430
- closeButton.removeEventListener("blur", hideTooltip);
431
- };
432
- }
433
-
434
- // Position close button wrapper based on placement
435
- if (closeButtonPlacement === "top-right") {
436
- // Make container position relative for absolute positioning
437
- container.style.position = "relative";
438
- container.appendChild(closeButtonWrapper);
439
- } else {
440
- // Inline placement: append to header
441
- header.appendChild(closeButtonWrapper);
442
- }
89
+ // Build header using layout config if available, otherwise use standard builder
90
+ const headerLayoutConfig = config?.layout?.header;
91
+ const headerElements: HeaderElements = headerLayoutConfig
92
+ ? buildHeaderWithLayout(config!, headerLayoutConfig, { showClose })
93
+ : buildHeader({ config, showClose });
443
94
 
95
+ // Build body with intro card and messages wrapper
444
96
  const body = createElement(
445
97
  "div",
446
98
  "tvw-flex tvw-flex-1 tvw-min-h-0 tvw-flex-col tvw-gap-6 tvw-overflow-y-auto tvw-bg-cw-container tvw-px-6 tvw-py-6"
447
99
  );
448
100
  body.id = "vanilla-agent-scroll-container";
101
+
449
102
  const introCard = createElement(
450
103
  "div",
451
104
  "tvw-rounded-2xl tvw-bg-cw-surface tvw-p-6 tvw-shadow-sm"
@@ -471,331 +124,40 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
471
124
 
472
125
  body.append(introCard, messagesWrapper);
473
126
 
474
- const footer = createElement(
475
- "div",
476
- "tvw-border-t-cw-divider tvw-bg-cw-surface tvw-px-6 tvw-py-4"
477
- );
478
- const suggestions = createElement(
479
- "div",
480
- "tvw-mb-3 tvw-flex tvw-flex-wrap tvw-gap-2"
481
- );
482
- // Determine gap based on voice recognition
483
- const voiceRecognitionEnabledForGap = config?.voiceRecognition?.enabled === true;
484
- const hasSpeechRecognitionForGap =
485
- typeof window !== 'undefined' &&
486
- (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
487
- typeof (window as any).SpeechRecognition !== 'undefined');
488
- const shouldUseSmallGap = voiceRecognitionEnabledForGap && hasSpeechRecognitionForGap;
489
- const gapClass = shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3";
490
-
491
- const composerForm = createElement(
492
- "form",
493
- `tvw-flex tvw-items-end ${gapClass} tvw-rounded-2xl tvw-border tvw-border-gray-200 tvw-bg-cw-input-background tvw-px-4 tvw-py-3`
494
- ) as HTMLFormElement;
495
- // Prevent form from getting focus styles
496
- composerForm.style.outline = "none";
497
-
498
- const textarea = createElement("textarea") as HTMLTextAreaElement;
499
- textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
500
- textarea.className =
501
- "tvw-min-h-[48px] tvw-flex-1 tvw-resize-none tvw-border-none tvw-bg-transparent tvw-text-sm tvw-text-cw-primary focus:tvw-outline-none focus:tvw-border-none";
502
- textarea.rows = 1;
503
-
504
- // Apply font family and weight from config
505
- const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
506
- const fontWeight = config?.theme?.inputFontWeight ?? "400";
507
-
508
- const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
509
- switch (family) {
510
- case "serif":
511
- return 'Georgia, "Times New Roman", Times, serif';
512
- case "mono":
513
- return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
514
- case "sans-serif":
515
- default:
516
- return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
517
- }
518
- };
519
-
520
- textarea.style.fontFamily = getFontFamilyValue(fontFamily);
521
- textarea.style.fontWeight = fontWeight;
522
-
523
- // Explicitly remove border and outline on focus to prevent browser defaults
524
- textarea.style.border = "none";
525
- textarea.style.outline = "none";
526
- textarea.style.borderWidth = "0";
527
- textarea.style.borderStyle = "none";
528
- textarea.style.borderColor = "transparent";
529
- textarea.addEventListener("focus", () => {
530
- textarea.style.border = "none";
531
- textarea.style.outline = "none";
532
- textarea.style.borderWidth = "0";
533
- textarea.style.borderStyle = "none";
534
- textarea.style.borderColor = "transparent";
535
- textarea.style.boxShadow = "none";
536
- });
537
- textarea.addEventListener("blur", () => {
538
- textarea.style.border = "none";
539
- textarea.style.outline = "none";
540
- });
541
- // Send button configuration
542
- const sendButtonConfig = config?.sendButton ?? {};
543
- const useIcon = sendButtonConfig.useIcon ?? false;
544
- const iconText = sendButtonConfig.iconText ?? "↑";
545
- const iconName = sendButtonConfig.iconName;
546
- const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
547
- const showTooltip = sendButtonConfig.showTooltip ?? false;
548
- const buttonSize = sendButtonConfig.size ?? "40px";
549
- const backgroundColor = sendButtonConfig.backgroundColor;
550
- const textColor = sendButtonConfig.textColor;
127
+ // Build composer/footer using extracted builder
128
+ const composerElements: ComposerElements = buildComposer({ config });
551
129
 
552
- // Create wrapper for tooltip positioning
553
- const sendButtonWrapper = createElement("div", "tvw-send-button-wrapper");
554
-
555
- const sendButton = createElement(
556
- "button",
557
- useIcon
558
- ? "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
559
- : "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"
560
- ) as HTMLButtonElement;
561
-
562
- sendButton.type = "submit";
563
-
564
- if (useIcon) {
565
- // Icon mode: circular button
566
- sendButton.style.width = buttonSize;
567
- sendButton.style.height = buttonSize;
568
- sendButton.style.minWidth = buttonSize;
569
- sendButton.style.minHeight = buttonSize;
570
- sendButton.style.fontSize = "18px";
571
- sendButton.style.lineHeight = "1";
572
-
573
- // Clear any existing content
574
- sendButton.innerHTML = "";
575
-
576
- // Use Lucide icon if iconName is provided, otherwise fall back to iconText
577
- if (iconName) {
578
- const iconSize = parseFloat(buttonSize) || 24;
579
- const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
580
- const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
581
- if (iconSvg) {
582
- sendButton.appendChild(iconSvg);
583
- sendButton.style.color = iconColor;
584
- } else {
585
- // Fallback to text if icon fails to render
586
- sendButton.textContent = iconText;
587
- if (textColor) {
588
- sendButton.style.color = textColor;
589
- } else {
590
- sendButton.classList.add("tvw-text-white");
591
- }
592
- }
593
- } else {
594
- sendButton.textContent = iconText;
595
- if (textColor) {
596
- sendButton.style.color = textColor;
597
- } else {
598
- sendButton.classList.add("tvw-text-white");
599
- }
600
- }
601
-
602
- if (backgroundColor) {
603
- sendButton.style.backgroundColor = backgroundColor;
604
- } else {
605
- sendButton.classList.add("tvw-bg-cw-primary");
606
- }
607
- } else {
608
- // Text mode: existing behavior
609
- sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
610
- if (textColor) {
611
- sendButton.style.color = textColor;
612
- } else {
613
- sendButton.classList.add("tvw-text-white");
614
- }
615
- }
616
-
617
- // Apply existing styling from config
618
- if (sendButtonConfig.borderWidth) {
619
- sendButton.style.borderWidth = sendButtonConfig.borderWidth;
620
- sendButton.style.borderStyle = "solid";
621
- }
622
- if (sendButtonConfig.borderColor) {
623
- sendButton.style.borderColor = sendButtonConfig.borderColor;
624
- }
625
-
626
- // Apply padding styling (works in both icon and text mode)
627
- if (sendButtonConfig.paddingX) {
628
- sendButton.style.paddingLeft = sendButtonConfig.paddingX;
629
- sendButton.style.paddingRight = sendButtonConfig.paddingX;
630
- } else {
631
- sendButton.style.paddingLeft = "";
632
- sendButton.style.paddingRight = "";
633
- }
634
- if (sendButtonConfig.paddingY) {
635
- sendButton.style.paddingTop = sendButtonConfig.paddingY;
636
- sendButton.style.paddingBottom = sendButtonConfig.paddingY;
637
- } else {
638
- sendButton.style.paddingTop = "";
639
- sendButton.style.paddingBottom = "";
640
- }
641
-
642
- // Add tooltip if enabled
643
- if (showTooltip && tooltipText) {
644
- const tooltip = createElement("div", "tvw-send-button-tooltip");
645
- tooltip.textContent = tooltipText;
646
- sendButtonWrapper.appendChild(tooltip);
647
- }
648
-
649
- sendButtonWrapper.appendChild(sendButton);
650
-
651
- // Voice recognition mic button
652
- const voiceRecognitionConfig = config?.voiceRecognition ?? {};
653
- const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
654
- let micButton: HTMLButtonElement | null = null;
655
- let micButtonWrapper: HTMLElement | null = null;
656
-
657
- // Check browser support for speech recognition
658
- const hasSpeechRecognition =
659
- typeof window !== 'undefined' &&
660
- (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
661
- typeof (window as any).SpeechRecognition !== 'undefined');
662
-
663
- if (voiceRecognitionEnabled && hasSpeechRecognition) {
664
- micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
665
- micButton = createElement(
666
- "button",
667
- "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
668
- ) as HTMLButtonElement;
669
-
670
- micButton.type = "button";
671
- micButton.setAttribute("aria-label", "Start voice recognition");
672
-
673
- const micIconName = voiceRecognitionConfig.iconName ?? "mic";
674
- const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
675
- const micIconSizeNum = parseFloat(micIconSize) || 24;
676
-
677
- // Use dedicated colors from voice recognition config, fallback to send button colors
678
- const micBackgroundColor = voiceRecognitionConfig.backgroundColor ?? backgroundColor;
679
- const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
680
-
681
- micButton.style.width = micIconSize;
682
- micButton.style.height = micIconSize;
683
- micButton.style.minWidth = micIconSize;
684
- micButton.style.minHeight = micIconSize;
685
- micButton.style.fontSize = "18px";
686
- micButton.style.lineHeight = "1";
687
-
688
- // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
689
- const iconColorValue = micIconColor || "currentColor";
690
- const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
691
- if (micIconSvg) {
692
- micButton.appendChild(micIconSvg);
693
- micButton.style.color = iconColorValue;
694
- } else {
695
- // Fallback to text if icon fails
696
- micButton.textContent = "🎤";
697
- micButton.style.color = iconColorValue;
698
- }
699
-
700
- // Apply background color
701
- if (micBackgroundColor) {
702
- micButton.style.backgroundColor = micBackgroundColor;
703
- } else {
704
- micButton.classList.add("tvw-bg-cw-primary");
705
- }
706
-
707
- // Apply icon/text color
708
- if (micIconColor) {
709
- micButton.style.color = micIconColor;
710
- } else if (!micIconColor && !textColor) {
711
- micButton.classList.add("tvw-text-white");
712
- }
713
-
714
- // Apply border styling
715
- if (voiceRecognitionConfig.borderWidth) {
716
- micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
717
- micButton.style.borderStyle = "solid";
718
- }
719
- if (voiceRecognitionConfig.borderColor) {
720
- micButton.style.borderColor = voiceRecognitionConfig.borderColor;
721
- }
722
-
723
- // Apply padding styling
724
- if (voiceRecognitionConfig.paddingX) {
725
- micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
726
- micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
727
- }
728
- if (voiceRecognitionConfig.paddingY) {
729
- micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
730
- micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
731
- }
732
-
733
- micButtonWrapper.appendChild(micButton);
734
-
735
- // Add tooltip if enabled
736
- const tooltipText = voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
737
- const showTooltip = voiceRecognitionConfig.showTooltip ?? false;
738
- if (showTooltip && tooltipText) {
739
- const tooltip = createElement("div", "tvw-send-button-tooltip");
740
- tooltip.textContent = tooltipText;
741
- micButtonWrapper.appendChild(tooltip);
742
- }
743
- }
744
-
745
- // Focus textarea when composer form container is clicked
746
- composerForm.addEventListener("click", (e) => {
747
- // Don't focus if clicking on the send button, mic button, or their wrappers
748
- if (e.target !== sendButton && e.target !== sendButtonWrapper &&
749
- e.target !== micButton && e.target !== micButtonWrapper) {
750
- textarea.focus();
751
- }
752
- });
753
-
754
- // Append elements: textarea, mic button (if exists), send button
755
- composerForm.append(textarea);
756
- if (micButtonWrapper) {
757
- composerForm.append(micButtonWrapper);
758
- }
759
- composerForm.append(sendButtonWrapper);
760
-
761
- const statusText = createElement(
762
- "div",
763
- "tvw-mt-2 tvw-text-right tvw-text-xs tvw-text-cw-muted"
764
- );
765
-
766
- // Apply status indicator config
767
- const statusConfig = config?.statusIndicator ?? {};
768
- const isVisible = statusConfig.visible ?? true;
769
- statusText.style.display = isVisible ? "" : "none";
770
- statusText.textContent = statusConfig.idleText ?? "Online";
771
-
772
- footer.append(suggestions, composerForm, statusText);
773
-
774
- container.append(header, body, footer);
130
+ // Assemble container with header, body, and footer
131
+ attachHeaderToContainer(container, headerElements, config);
132
+ container.append(body, composerElements.footer);
775
133
 
776
134
  return {
777
135
  container,
778
136
  body,
779
137
  messagesWrapper,
780
- suggestions,
781
- textarea,
782
- sendButton,
783
- sendButtonWrapper,
784
- micButton,
785
- micButtonWrapper,
786
- composerForm,
787
- statusText,
138
+ suggestions: composerElements.suggestions,
139
+ textarea: composerElements.textarea,
140
+ sendButton: composerElements.sendButton,
141
+ sendButtonWrapper: composerElements.sendButtonWrapper,
142
+ micButton: composerElements.micButton,
143
+ micButtonWrapper: composerElements.micButtonWrapper,
144
+ composerForm: composerElements.composerForm,
145
+ statusText: composerElements.statusText,
788
146
  introTitle,
789
147
  introSubtitle,
790
- closeButton,
791
- closeButtonWrapper,
792
- clearChatButton,
793
- clearChatButtonWrapper,
794
- iconHolder,
795
- headerTitle: title,
796
- headerSubtitle: subtitle
148
+ closeButton: headerElements.closeButton,
149
+ closeButtonWrapper: headerElements.closeButtonWrapper,
150
+ clearChatButton: headerElements.clearChatButton,
151
+ clearChatButtonWrapper: headerElements.clearChatButtonWrapper,
152
+ iconHolder: headerElements.iconHolder,
153
+ headerTitle: headerElements.headerTitle,
154
+ headerSubtitle: headerElements.headerSubtitle,
155
+ header: headerElements.header,
156
+ footer: composerElements.footer
797
157
  };
798
158
  };
799
159
 
800
-
801
-
160
+ // Re-export builder types and functions for plugin use
161
+ export { buildHeader, buildComposer, attachHeaderToContainer };
162
+ export type { HeaderElements, HeaderBuildContext } from "./header-builder";
163
+ export type { ComposerElements, ComposerBuildContext } from "./composer-builder";