vanilla-agent 0.1.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.
package/src/ui.ts ADDED
@@ -0,0 +1,1325 @@
1
+ import { escapeHtml } from "./postprocessors";
2
+ import { ChatWidgetSession, ChatWidgetSessionStatus } from "./session";
3
+ import { ChatWidgetConfig, ChatWidgetMessage } from "./types";
4
+ import { applyThemeVariables } from "./utils/theme";
5
+ import { renderLucideIcon } from "./utils/icons";
6
+ import { createElement } from "./utils/dom";
7
+ import { statusCopy } from "./utils/constants";
8
+ import { createLauncherButton } from "./components/launcher";
9
+ import { createWrapper, buildPanel } from "./components/panel";
10
+ import { MessageTransform } from "./components/message-bubble";
11
+ import { createStandardBubble } from "./components/message-bubble";
12
+ import { createReasoningBubble } from "./components/reasoning-bubble";
13
+ import { createToolBubble } from "./components/tool-bubble";
14
+ import { createSuggestions } from "./components/suggestions";
15
+ import { enhanceWithForms } from "./components/forms";
16
+ import { pluginRegistry } from "./plugins/registry";
17
+
18
+ type Controller = {
19
+ update: (config: ChatWidgetConfig) => void;
20
+ destroy: () => void;
21
+ open: () => void;
22
+ close: () => void;
23
+ toggle: () => void;
24
+ };
25
+
26
+ const buildPostprocessor = (cfg?: ChatWidgetConfig): MessageTransform => {
27
+ if (cfg?.postprocessMessage) {
28
+ return (context) =>
29
+ cfg.postprocessMessage!({
30
+ text: context.text,
31
+ message: context.message,
32
+ streaming: context.streaming
33
+ });
34
+ }
35
+ return ({ text }) => escapeHtml(text);
36
+ };
37
+
38
+ export const createChatExperience = (
39
+ mount: HTMLElement,
40
+ initialConfig?: ChatWidgetConfig
41
+ ): Controller => {
42
+ // Tailwind config uses important: "#vanilla-agent-root", so ensure mount has this ID
43
+ if (!mount.id || mount.id !== "vanilla-agent-root") {
44
+ mount.id = "vanilla-agent-root";
45
+ }
46
+
47
+ let config = { ...initialConfig };
48
+ applyThemeVariables(mount, config);
49
+
50
+ // Get plugins for this instance
51
+ const plugins = pluginRegistry.getForInstance(config.plugins);
52
+
53
+ let launcherEnabled = config.launcher?.enabled ?? true;
54
+ let autoExpand = config.launcher?.autoExpand ?? false;
55
+ let prevAutoExpand = autoExpand;
56
+ let prevLauncherEnabled = launcherEnabled;
57
+ let open = launcherEnabled ? autoExpand : true;
58
+ let postprocess = buildPostprocessor(config);
59
+ let showReasoning = config.features?.showReasoning ?? true;
60
+ let showToolCalls = config.features?.showToolCalls ?? true;
61
+
62
+ // Get status indicator config
63
+ const statusConfig = config.statusIndicator ?? {};
64
+ const getStatusText = (status: ChatWidgetSessionStatus): string => {
65
+ if (status === "idle") return statusConfig.idleText ?? statusCopy.idle;
66
+ if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
67
+ if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
68
+ if (status === "error") return statusConfig.errorText ?? statusCopy.error;
69
+ return statusCopy[status];
70
+ };
71
+
72
+ const { wrapper, panel } = createWrapper(config);
73
+ const panelElements = buildPanel(config, launcherEnabled);
74
+ const {
75
+ container,
76
+ body,
77
+ messagesWrapper,
78
+ suggestions,
79
+ textarea,
80
+ sendButton,
81
+ sendButtonWrapper,
82
+ composerForm,
83
+ statusText,
84
+ introTitle,
85
+ introSubtitle,
86
+ closeButton,
87
+ iconHolder
88
+ } = panelElements;
89
+
90
+ // Use mutable references for mic button so we can update them dynamically
91
+ let micButton: HTMLButtonElement | null = panelElements.micButton;
92
+ let micButtonWrapper: HTMLElement | null = panelElements.micButtonWrapper;
93
+
94
+ panel.appendChild(container);
95
+ mount.appendChild(wrapper);
96
+
97
+ const destroyCallbacks: Array<() => void> = [];
98
+ const suggestionsManager = createSuggestions(suggestions);
99
+ let closeHandler: (() => void) | null = null;
100
+ let session: ChatWidgetSession;
101
+ let isStreaming = false;
102
+ let shouldAutoScroll = true;
103
+ let lastScrollTop = 0;
104
+ let lastAutoScrollTime = 0;
105
+ let scrollRAF: number | null = null;
106
+ let isAutoScrollBlocked = false;
107
+ let blockUntilTime = 0;
108
+ let isAutoScrolling = false;
109
+
110
+ const AUTO_SCROLL_THROTTLE = 125;
111
+ const AUTO_SCROLL_BLOCK_TIME = 2000;
112
+ const USER_SCROLL_THRESHOLD = 5;
113
+ const BOTTOM_THRESHOLD = 50;
114
+
115
+ const scheduleAutoScroll = (force = false) => {
116
+ if (!shouldAutoScroll) return;
117
+
118
+ const now = Date.now();
119
+
120
+ if (isAutoScrollBlocked && now < blockUntilTime) {
121
+ if (!force) return;
122
+ }
123
+
124
+ if (isAutoScrollBlocked && now >= blockUntilTime) {
125
+ isAutoScrollBlocked = false;
126
+ }
127
+
128
+ if (!force && !isStreaming) return;
129
+
130
+ if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
131
+ lastAutoScrollTime = now;
132
+
133
+ if (scrollRAF) {
134
+ cancelAnimationFrame(scrollRAF);
135
+ }
136
+
137
+ scrollRAF = requestAnimationFrame(() => {
138
+ if (isAutoScrollBlocked || !shouldAutoScroll) return;
139
+ isAutoScrolling = true;
140
+ body.scrollTop = body.scrollHeight;
141
+ lastScrollTop = body.scrollTop;
142
+ requestAnimationFrame(() => {
143
+ isAutoScrolling = false;
144
+ });
145
+ scrollRAF = null;
146
+ });
147
+ };
148
+
149
+ // Create typing indicator element
150
+ const createTypingIndicator = (): HTMLElement => {
151
+ const container = document.createElement("div");
152
+ container.className = "tvw-flex tvw-items-center tvw-space-x-1 tvw-h-5";
153
+
154
+ const dot1 = document.createElement("div");
155
+ dot1.className = "tvw-bg-cw-primary tvw-animate-typing tvw-rounded-full tvw-h-1.5 tvw-w-1.5";
156
+ dot1.style.animationDelay = "0ms";
157
+
158
+ const dot2 = document.createElement("div");
159
+ dot2.className = "tvw-bg-cw-primary tvw-animate-typing tvw-rounded-full tvw-h-1.5 tvw-w-1.5";
160
+ dot2.style.animationDelay = "250ms";
161
+
162
+ const dot3 = document.createElement("div");
163
+ dot3.className = "tvw-bg-cw-primary tvw-animate-typing tvw-rounded-full tvw-h-1.5 tvw-w-1.5";
164
+ dot3.style.animationDelay = "500ms";
165
+
166
+ const srOnly = document.createElement("span");
167
+ srOnly.className = "tvw-sr-only";
168
+ srOnly.textContent = "Loading";
169
+
170
+ container.appendChild(dot1);
171
+ container.appendChild(dot2);
172
+ container.appendChild(dot3);
173
+ container.appendChild(srOnly);
174
+
175
+ return container;
176
+ };
177
+
178
+ // Message rendering with plugin support
179
+ const renderMessagesWithPlugins = (
180
+ container: HTMLElement,
181
+ messages: ChatWidgetMessage[],
182
+ transform: MessageTransform
183
+ ) => {
184
+ container.innerHTML = "";
185
+ const fragment = document.createDocumentFragment();
186
+
187
+ messages.forEach((message) => {
188
+ let bubble: HTMLElement | null = null;
189
+
190
+ // Try plugins first
191
+ const matchingPlugin = plugins.find((p) => {
192
+ if (message.variant === "reasoning" && p.renderReasoning) {
193
+ return true;
194
+ }
195
+ if (message.variant === "tool" && p.renderToolCall) {
196
+ return true;
197
+ }
198
+ if (!message.variant && p.renderMessage) {
199
+ return true;
200
+ }
201
+ return false;
202
+ });
203
+
204
+ if (matchingPlugin) {
205
+ if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
206
+ if (!showReasoning) return;
207
+ bubble = matchingPlugin.renderReasoning({
208
+ message,
209
+ defaultRenderer: () => createReasoningBubble(message),
210
+ config
211
+ });
212
+ } else if (message.variant === "tool" && message.toolCall && matchingPlugin.renderToolCall) {
213
+ if (!showToolCalls) return;
214
+ bubble = matchingPlugin.renderToolCall({
215
+ message,
216
+ defaultRenderer: () => createToolBubble(message),
217
+ config
218
+ });
219
+ } else if (matchingPlugin.renderMessage) {
220
+ bubble = matchingPlugin.renderMessage({
221
+ message,
222
+ defaultRenderer: () => {
223
+ const b = createStandardBubble(message, transform);
224
+ if (message.role !== "user") {
225
+ enhanceWithForms(b, message, config, session);
226
+ }
227
+ return b;
228
+ },
229
+ config
230
+ });
231
+ }
232
+ }
233
+
234
+ // Fallback to default rendering if plugin returned null or no plugin matched
235
+ if (!bubble) {
236
+ if (message.variant === "reasoning" && message.reasoning) {
237
+ if (!showReasoning) return;
238
+ bubble = createReasoningBubble(message);
239
+ } else if (message.variant === "tool" && message.toolCall) {
240
+ if (!showToolCalls) return;
241
+ bubble = createToolBubble(message);
242
+ } else {
243
+ bubble = createStandardBubble(message, transform);
244
+ if (message.role !== "user") {
245
+ enhanceWithForms(bubble, message, config, session);
246
+ }
247
+ }
248
+ }
249
+
250
+ const wrapper = document.createElement("div");
251
+ wrapper.className = "tvw-flex";
252
+ if (message.role === "user") {
253
+ wrapper.classList.add("tvw-justify-end");
254
+ }
255
+ wrapper.appendChild(bubble);
256
+ fragment.appendChild(wrapper);
257
+ });
258
+
259
+ // Add typing indicator if streaming and there's at least one user message
260
+ if (isStreaming && messages.some((msg) => msg.role === "user")) {
261
+ const typingIndicator = createTypingIndicator();
262
+ const typingWrapper = document.createElement("div");
263
+ typingWrapper.className = "tvw-flex tvw-justify-end";
264
+ typingWrapper.appendChild(typingIndicator);
265
+ fragment.appendChild(typingWrapper);
266
+ }
267
+
268
+ container.appendChild(fragment);
269
+ container.scrollTop = container.scrollHeight;
270
+ };
271
+
272
+ const updateOpenState = () => {
273
+ if (!launcherEnabled) return;
274
+ if (open) {
275
+ wrapper.classList.remove("tvw-pointer-events-none", "tvw-opacity-0");
276
+ panel.classList.remove("tvw-scale-95", "tvw-opacity-0");
277
+ panel.classList.add("tvw-scale-100", "tvw-opacity-100");
278
+ // Hide launcher button when widget is open
279
+ if (launcherButtonInstance) {
280
+ launcherButtonInstance.element.style.display = "none";
281
+ }
282
+ } else {
283
+ wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
284
+ panel.classList.remove("tvw-scale-100", "tvw-opacity-100");
285
+ panel.classList.add("tvw-scale-95", "tvw-opacity-0");
286
+ // Show launcher button when widget is closed
287
+ if (launcherButtonInstance) {
288
+ launcherButtonInstance.element.style.display = "";
289
+ }
290
+ }
291
+ };
292
+
293
+ const setOpenState = (nextOpen: boolean) => {
294
+ if (!launcherEnabled) return;
295
+ if (open === nextOpen) return;
296
+ open = nextOpen;
297
+ updateOpenState();
298
+ if (open) {
299
+ recalcPanelHeight();
300
+ scheduleAutoScroll(true);
301
+ }
302
+ };
303
+
304
+ const setComposerDisabled = (disabled: boolean) => {
305
+ textarea.disabled = disabled;
306
+ sendButton.disabled = disabled;
307
+ if (micButton) {
308
+ micButton.disabled = disabled;
309
+ }
310
+ suggestionsManager.buttons.forEach((btn) => {
311
+ btn.disabled = disabled;
312
+ });
313
+ };
314
+
315
+ const updateCopy = () => {
316
+ introTitle.textContent = config.copy?.welcomeTitle ?? "Hello 👋";
317
+ introSubtitle.textContent =
318
+ config.copy?.welcomeSubtitle ??
319
+ "Ask anything about your account or products.";
320
+ textarea.placeholder = config.copy?.inputPlaceholder ?? "Type your message…";
321
+ sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
322
+
323
+ // Update textarea font family and weight
324
+ const fontFamily = config.theme?.inputFontFamily ?? "sans-serif";
325
+ const fontWeight = config.theme?.inputFontWeight ?? "400";
326
+
327
+ const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
328
+ switch (family) {
329
+ case "serif":
330
+ return 'Georgia, "Times New Roman", Times, serif';
331
+ case "mono":
332
+ return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
333
+ case "sans-serif":
334
+ default:
335
+ return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
336
+ }
337
+ };
338
+
339
+ textarea.style.fontFamily = getFontFamilyValue(fontFamily);
340
+ textarea.style.fontWeight = fontWeight;
341
+ };
342
+
343
+ session = new ChatWidgetSession(config, {
344
+ onMessagesChanged(messages) {
345
+ renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
346
+ // Re-render suggestions to hide them after first user message
347
+ // Pass messages directly to avoid calling session.getMessages() during construction
348
+ if (session) {
349
+ const hasUserMessage = messages.some((msg) => msg.role === "user");
350
+ if (hasUserMessage) {
351
+ // Hide suggestions if user message exists
352
+ suggestionsManager.render([], session, textarea, messages);
353
+ } else {
354
+ // Show suggestions if no user message yet
355
+ suggestionsManager.render(config.suggestionChips, session, textarea, messages);
356
+ }
357
+ }
358
+ scheduleAutoScroll(!isStreaming);
359
+ },
360
+ onStatusChanged(status) {
361
+ const currentStatusConfig = config.statusIndicator ?? {};
362
+ const getCurrentStatusText = (status: ChatWidgetSessionStatus): string => {
363
+ if (status === "idle") return currentStatusConfig.idleText ?? statusCopy.idle;
364
+ if (status === "connecting") return currentStatusConfig.connectingText ?? statusCopy.connecting;
365
+ if (status === "connected") return currentStatusConfig.connectedText ?? statusCopy.connected;
366
+ if (status === "error") return currentStatusConfig.errorText ?? statusCopy.error;
367
+ return statusCopy[status];
368
+ };
369
+ statusText.textContent = getCurrentStatusText(status);
370
+ },
371
+ onStreamingChanged(streaming) {
372
+ isStreaming = streaming;
373
+ setComposerDisabled(streaming);
374
+ // Re-render messages to show/hide typing indicator
375
+ if (session) {
376
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
377
+ }
378
+ if (!streaming) {
379
+ scheduleAutoScroll(true);
380
+ }
381
+ }
382
+ });
383
+
384
+ const handleSubmit = (event: Event) => {
385
+ event.preventDefault();
386
+ const value = textarea.value.trim();
387
+ if (!value) return;
388
+ textarea.value = "";
389
+ session.sendMessage(value);
390
+ };
391
+
392
+ const handleInputEnter = (event: KeyboardEvent) => {
393
+ if (event.key === "Enter" && !event.shiftKey) {
394
+ event.preventDefault();
395
+ sendButton.click();
396
+ }
397
+ };
398
+
399
+ // Voice recognition state and logic
400
+ let speechRecognition: any = null;
401
+ let isRecording = false;
402
+ let pauseTimer: number | null = null;
403
+ let originalMicStyles: {
404
+ backgroundColor: string;
405
+ color: string;
406
+ borderColor: string;
407
+ } | null = null;
408
+
409
+ const getSpeechRecognitionClass = (): any => {
410
+ if (typeof window === 'undefined') return null;
411
+ return (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition || null;
412
+ };
413
+
414
+ const startVoiceRecognition = () => {
415
+ if (isRecording || session.isStreaming()) return;
416
+
417
+ const SpeechRecognitionClass = getSpeechRecognitionClass();
418
+ if (!SpeechRecognitionClass) return;
419
+
420
+ speechRecognition = new SpeechRecognitionClass();
421
+ const voiceConfig = config.voiceRecognition ?? {};
422
+ const pauseDuration = voiceConfig.pauseDuration ?? 2000;
423
+
424
+ speechRecognition.continuous = true;
425
+ speechRecognition.interimResults = true;
426
+ speechRecognition.lang = 'en-US';
427
+
428
+ // Store the initial text that was in the textarea
429
+ const initialText = textarea.value;
430
+
431
+ speechRecognition.onresult = (event: any) => {
432
+ // Build the complete transcript from all results
433
+ let fullTranscript = "";
434
+ let interimTranscript = "";
435
+
436
+ // Process all results from the beginning
437
+ for (let i = 0; i < event.results.length; i++) {
438
+ const result = event.results[i];
439
+ const transcript = result[0].transcript;
440
+
441
+ if (result.isFinal) {
442
+ fullTranscript += transcript + " ";
443
+ } else {
444
+ // Only take the last interim result
445
+ interimTranscript = transcript;
446
+ }
447
+ }
448
+
449
+ // Update textarea with initial text + full transcript + interim
450
+ const newValue = initialText + fullTranscript + interimTranscript;
451
+ textarea.value = newValue;
452
+
453
+ // Reset pause timer on each result
454
+ if (pauseTimer) {
455
+ clearTimeout(pauseTimer);
456
+ }
457
+
458
+ // Set timer to auto-submit after pause when we have any speech
459
+ if (fullTranscript || interimTranscript) {
460
+ pauseTimer = window.setTimeout(() => {
461
+ const finalValue = textarea.value.trim();
462
+ if (finalValue && speechRecognition && isRecording) {
463
+ stopVoiceRecognition();
464
+ textarea.value = "";
465
+ session.sendMessage(finalValue);
466
+ }
467
+ }, pauseDuration);
468
+ }
469
+ };
470
+
471
+ speechRecognition.onerror = (event: any) => {
472
+ // Don't stop on "no-speech" error, just ignore it
473
+ if (event.error !== 'no-speech') {
474
+ stopVoiceRecognition();
475
+ }
476
+ };
477
+
478
+ speechRecognition.onend = () => {
479
+ // If recognition ended naturally (not manually stopped), submit if there's text
480
+ if (isRecording) {
481
+ const finalValue = textarea.value.trim();
482
+ if (finalValue && finalValue !== initialText.trim()) {
483
+ textarea.value = "";
484
+ session.sendMessage(finalValue);
485
+ }
486
+ stopVoiceRecognition();
487
+ }
488
+ };
489
+
490
+ try {
491
+ speechRecognition.start();
492
+ isRecording = true;
493
+ if (micButton) {
494
+ // Store original styles
495
+ originalMicStyles = {
496
+ backgroundColor: micButton.style.backgroundColor,
497
+ color: micButton.style.color,
498
+ borderColor: micButton.style.borderColor
499
+ };
500
+
501
+ // Apply recording state styles from config
502
+ const voiceConfig = config.voiceRecognition ?? {};
503
+ const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
504
+ const recordingIconColor = voiceConfig.recordingIconColor;
505
+ const recordingBorderColor = voiceConfig.recordingBorderColor;
506
+
507
+ micButton.classList.add("tvw-voice-recording");
508
+ micButton.style.backgroundColor = recordingBackgroundColor;
509
+
510
+ if (recordingIconColor) {
511
+ micButton.style.color = recordingIconColor;
512
+ // Update SVG stroke color if present
513
+ const svg = micButton.querySelector("svg");
514
+ if (svg) {
515
+ svg.setAttribute("stroke", recordingIconColor);
516
+ }
517
+ }
518
+
519
+ if (recordingBorderColor) {
520
+ micButton.style.borderColor = recordingBorderColor;
521
+ }
522
+
523
+ micButton.setAttribute("aria-label", "Stop voice recognition");
524
+ }
525
+ } catch (error) {
526
+ stopVoiceRecognition();
527
+ }
528
+ };
529
+
530
+ const stopVoiceRecognition = () => {
531
+ if (!isRecording) return;
532
+
533
+ isRecording = false;
534
+ if (pauseTimer) {
535
+ clearTimeout(pauseTimer);
536
+ pauseTimer = null;
537
+ }
538
+
539
+ if (speechRecognition) {
540
+ try {
541
+ speechRecognition.stop();
542
+ } catch (error) {
543
+ // Ignore errors when stopping
544
+ }
545
+ speechRecognition = null;
546
+ }
547
+
548
+ if (micButton) {
549
+ micButton.classList.remove("tvw-voice-recording");
550
+
551
+ // Restore original styles
552
+ if (originalMicStyles) {
553
+ micButton.style.backgroundColor = originalMicStyles.backgroundColor;
554
+ micButton.style.color = originalMicStyles.color;
555
+ micButton.style.borderColor = originalMicStyles.borderColor;
556
+
557
+ // Restore SVG stroke color if present
558
+ const svg = micButton.querySelector("svg");
559
+ if (svg) {
560
+ svg.setAttribute("stroke", originalMicStyles.color || "currentColor");
561
+ }
562
+
563
+ originalMicStyles = null;
564
+ }
565
+
566
+ micButton.setAttribute("aria-label", "Start voice recognition");
567
+ }
568
+ };
569
+
570
+ // Function to create mic button dynamically
571
+ const createMicButton = (voiceConfig: ChatWidgetConfig['voiceRecognition'], sendButtonConfig: ChatWidgetConfig['sendButton']): { micButton: HTMLButtonElement; micButtonWrapper: HTMLElement } | null => {
572
+ const hasSpeechRecognition =
573
+ typeof window !== 'undefined' &&
574
+ (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
575
+ typeof (window as any).SpeechRecognition !== 'undefined');
576
+
577
+ if (!hasSpeechRecognition) return null;
578
+
579
+ const micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
580
+ const micButton = createElement(
581
+ "button",
582
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
583
+ ) as HTMLButtonElement;
584
+
585
+ micButton.type = "button";
586
+ micButton.setAttribute("aria-label", "Start voice recognition");
587
+
588
+ const micIconName = voiceConfig?.iconName ?? "mic";
589
+ const buttonSize = sendButtonConfig?.size ?? "40px";
590
+ const micIconSize = voiceConfig?.iconSize ?? buttonSize;
591
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
592
+
593
+ // Use dedicated colors from voice recognition config, fallback to send button colors
594
+ const backgroundColor = voiceConfig?.backgroundColor ?? sendButtonConfig?.backgroundColor;
595
+ const iconColor = voiceConfig?.iconColor ?? sendButtonConfig?.textColor;
596
+
597
+ micButton.style.width = micIconSize;
598
+ micButton.style.height = micIconSize;
599
+ micButton.style.minWidth = micIconSize;
600
+ micButton.style.minHeight = micIconSize;
601
+ micButton.style.fontSize = "18px";
602
+ micButton.style.lineHeight = "1";
603
+
604
+ // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
605
+ const iconColorValue = iconColor || "currentColor";
606
+ const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
607
+ if (micIconSvg) {
608
+ micButton.appendChild(micIconSvg);
609
+ micButton.style.color = iconColorValue;
610
+ } else {
611
+ // Fallback to text if icon fails
612
+ micButton.textContent = "🎤";
613
+ micButton.style.color = iconColorValue;
614
+ }
615
+
616
+ // Apply background color
617
+ if (backgroundColor) {
618
+ micButton.style.backgroundColor = backgroundColor;
619
+ } else {
620
+ micButton.classList.add("tvw-bg-cw-primary");
621
+ }
622
+
623
+ // Apply icon/text color
624
+ if (iconColor) {
625
+ micButton.style.color = iconColor;
626
+ } else if (!iconColor && !sendButtonConfig?.textColor) {
627
+ micButton.classList.add("tvw-text-white");
628
+ }
629
+
630
+ // Apply border styling
631
+ if (voiceConfig?.borderWidth) {
632
+ micButton.style.borderWidth = voiceConfig.borderWidth;
633
+ micButton.style.borderStyle = "solid";
634
+ }
635
+ if (voiceConfig?.borderColor) {
636
+ micButton.style.borderColor = voiceConfig.borderColor;
637
+ }
638
+
639
+ // Apply padding styling
640
+ if (voiceConfig?.paddingX) {
641
+ micButton.style.paddingLeft = voiceConfig.paddingX;
642
+ micButton.style.paddingRight = voiceConfig.paddingX;
643
+ }
644
+ if (voiceConfig?.paddingY) {
645
+ micButton.style.paddingTop = voiceConfig.paddingY;
646
+ micButton.style.paddingBottom = voiceConfig.paddingY;
647
+ }
648
+
649
+ micButtonWrapper.appendChild(micButton);
650
+
651
+ // Add tooltip if enabled
652
+ const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
653
+ const showTooltip = voiceConfig?.showTooltip ?? false;
654
+ if (showTooltip && tooltipText) {
655
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
656
+ tooltip.textContent = tooltipText;
657
+ micButtonWrapper.appendChild(tooltip);
658
+ }
659
+
660
+ return { micButton, micButtonWrapper };
661
+ };
662
+
663
+ // Wire up mic button click handler
664
+ const handleMicButtonClick = () => {
665
+ if (isRecording) {
666
+ // Stop recording and submit
667
+ const finalValue = textarea.value.trim();
668
+ stopVoiceRecognition();
669
+ if (finalValue) {
670
+ textarea.value = "";
671
+ session.sendMessage(finalValue);
672
+ }
673
+ } else {
674
+ // Start recording
675
+ startVoiceRecognition();
676
+ }
677
+ };
678
+
679
+ if (micButton) {
680
+ micButton.addEventListener("click", handleMicButtonClick);
681
+
682
+ destroyCallbacks.push(() => {
683
+ stopVoiceRecognition();
684
+ if (micButton) {
685
+ micButton.removeEventListener("click", handleMicButtonClick);
686
+ }
687
+ });
688
+ }
689
+
690
+ const toggleOpen = () => {
691
+ setOpenState(!open);
692
+ };
693
+
694
+ let launcherButtonInstance = launcherEnabled
695
+ ? createLauncherButton(config, toggleOpen)
696
+ : null;
697
+
698
+ if (launcherButtonInstance) {
699
+ mount.appendChild(launcherButtonInstance.element);
700
+ }
701
+ updateOpenState();
702
+ suggestionsManager.render(config.suggestionChips, session, textarea);
703
+ updateCopy();
704
+ setComposerDisabled(session.isStreaming());
705
+ scheduleAutoScroll(true);
706
+
707
+ const recalcPanelHeight = () => {
708
+ if (!launcherEnabled) {
709
+ panel.style.height = "";
710
+ panel.style.width = "";
711
+ return;
712
+ }
713
+ const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
714
+ const width = launcherWidth ?? "min(360px, calc(100vw - 24px))";
715
+ panel.style.width = width;
716
+ panel.style.maxWidth = width;
717
+ const viewportHeight = window.innerHeight;
718
+ const verticalMargin = 64; // leave space for launcher's offset
719
+ const available = Math.max(200, viewportHeight - verticalMargin);
720
+ const clamped = Math.min(640, available);
721
+ panel.style.height = `${clamped}px`;
722
+ };
723
+
724
+ recalcPanelHeight();
725
+ window.addEventListener("resize", recalcPanelHeight);
726
+ destroyCallbacks.push(() => window.removeEventListener("resize", recalcPanelHeight));
727
+
728
+ lastScrollTop = body.scrollTop;
729
+
730
+ const handleScroll = () => {
731
+ const scrollTop = body.scrollTop;
732
+ const scrollHeight = body.scrollHeight;
733
+ const clientHeight = body.clientHeight;
734
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
735
+ const delta = Math.abs(scrollTop - lastScrollTop);
736
+ lastScrollTop = scrollTop;
737
+
738
+ if (isAutoScrolling) return;
739
+ if (delta <= USER_SCROLL_THRESHOLD) return;
740
+
741
+ if (!shouldAutoScroll && distanceFromBottom < BOTTOM_THRESHOLD) {
742
+ isAutoScrollBlocked = false;
743
+ shouldAutoScroll = true;
744
+ return;
745
+ }
746
+
747
+ if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
748
+ isAutoScrollBlocked = true;
749
+ blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
750
+ shouldAutoScroll = false;
751
+ }
752
+ };
753
+
754
+ body.addEventListener("scroll", handleScroll, { passive: true });
755
+ destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
756
+ destroyCallbacks.push(() => {
757
+ if (scrollRAF) cancelAnimationFrame(scrollRAF);
758
+ });
759
+
760
+ const refreshCloseButton = () => {
761
+ if (!closeButton) return;
762
+ if (closeHandler) {
763
+ closeButton.removeEventListener("click", closeHandler);
764
+ closeHandler = null;
765
+ }
766
+ if (launcherEnabled) {
767
+ closeButton.style.display = "";
768
+ closeHandler = () => {
769
+ open = false;
770
+ updateOpenState();
771
+ };
772
+ closeButton.addEventListener("click", closeHandler);
773
+ } else {
774
+ closeButton.style.display = "none";
775
+ }
776
+ };
777
+
778
+ refreshCloseButton();
779
+
780
+ composerForm.addEventListener("submit", handleSubmit);
781
+ textarea.addEventListener("keydown", handleInputEnter);
782
+
783
+ destroyCallbacks.push(() => {
784
+ composerForm.removeEventListener("submit", handleSubmit);
785
+ textarea.removeEventListener("keydown", handleInputEnter);
786
+ });
787
+
788
+ destroyCallbacks.push(() => {
789
+ session.cancel();
790
+ });
791
+
792
+ if (launcherButtonInstance) {
793
+ destroyCallbacks.push(() => {
794
+ launcherButtonInstance?.destroy();
795
+ });
796
+ }
797
+
798
+ return {
799
+ update(nextConfig: ChatWidgetConfig) {
800
+ config = { ...config, ...nextConfig };
801
+ applyThemeVariables(mount, config);
802
+
803
+ // Update plugins
804
+ const newPlugins = pluginRegistry.getForInstance(config.plugins);
805
+ plugins.length = 0;
806
+ plugins.push(...newPlugins);
807
+
808
+ launcherEnabled = config.launcher?.enabled ?? true;
809
+ autoExpand = config.launcher?.autoExpand ?? false;
810
+ showReasoning = config.features?.showReasoning ?? true;
811
+ showToolCalls = config.features?.showToolCalls ?? true;
812
+
813
+ if (config.launcher?.enabled === false && launcherButtonInstance) {
814
+ launcherButtonInstance.destroy();
815
+ launcherButtonInstance = null;
816
+ }
817
+
818
+ if (config.launcher?.enabled !== false && !launcherButtonInstance) {
819
+ launcherButtonInstance = createLauncherButton(config, toggleOpen);
820
+ mount.appendChild(launcherButtonInstance.element);
821
+ }
822
+
823
+ if (launcherButtonInstance) {
824
+ launcherButtonInstance.update(config);
825
+ }
826
+
827
+ // Only update open state if launcher enabled state changed or autoExpand value changed
828
+ const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
829
+ const autoExpandChanged = autoExpand !== prevAutoExpand;
830
+
831
+ if (launcherEnabledChanged) {
832
+ // Launcher was enabled/disabled - update state accordingly
833
+ if (!launcherEnabled) {
834
+ // When launcher is disabled, always keep panel open
835
+ open = true;
836
+ updateOpenState();
837
+ } else {
838
+ // Launcher was just enabled - respect autoExpand setting
839
+ setOpenState(autoExpand);
840
+ }
841
+ } else if (autoExpandChanged) {
842
+ // autoExpand value changed - update state to match
843
+ setOpenState(autoExpand);
844
+ }
845
+ // Otherwise, preserve current open state (user may have manually opened/closed)
846
+
847
+ // Update previous values for next comparison
848
+ prevAutoExpand = autoExpand;
849
+ prevLauncherEnabled = launcherEnabled;
850
+ recalcPanelHeight();
851
+ refreshCloseButton();
852
+
853
+ // Update panel icon sizes
854
+ const launcher = config.launcher ?? {};
855
+ const headerIconHidden = launcher.headerIconHidden ?? false;
856
+ const headerIconName = launcher.headerIconName;
857
+ const headerIconSize = launcher.headerIconSize ?? "48px";
858
+
859
+ if (iconHolder) {
860
+ const header = container.querySelector(".tvw-border-b-cw-divider");
861
+ const headerCopy = header?.querySelector(".tvw-flex-col");
862
+
863
+ // Handle hide/show
864
+ if (headerIconHidden) {
865
+ // Hide iconHolder
866
+ iconHolder.style.display = "none";
867
+ // Ensure headerCopy is still in header
868
+ if (header && headerCopy && !header.contains(headerCopy)) {
869
+ header.insertBefore(headerCopy, header.firstChild);
870
+ }
871
+ } else {
872
+ // Show iconHolder
873
+ iconHolder.style.display = "";
874
+ iconHolder.style.height = headerIconSize;
875
+ iconHolder.style.width = headerIconSize;
876
+
877
+ // Ensure iconHolder is before headerCopy in header
878
+ if (header && headerCopy) {
879
+ if (!header.contains(iconHolder)) {
880
+ header.insertBefore(iconHolder, headerCopy);
881
+ } else if (iconHolder.nextSibling !== headerCopy) {
882
+ // Reorder if needed
883
+ iconHolder.remove();
884
+ header.insertBefore(iconHolder, headerCopy);
885
+ }
886
+ }
887
+
888
+ // Update icon content based on priority: Lucide icon > iconUrl > agentIconText
889
+ if (headerIconName) {
890
+ // Use Lucide icon
891
+ const iconSize = parseFloat(headerIconSize) || 24;
892
+ const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
893
+ if (iconSvg) {
894
+ iconHolder.replaceChildren(iconSvg);
895
+ } else {
896
+ // Fallback to agentIconText if Lucide icon fails
897
+ iconHolder.textContent = launcher.agentIconText ?? "💬";
898
+ }
899
+ } else if (launcher.iconUrl) {
900
+ // Use image URL
901
+ const img = iconHolder.querySelector("img");
902
+ if (img) {
903
+ img.src = launcher.iconUrl;
904
+ img.style.height = headerIconSize;
905
+ img.style.width = headerIconSize;
906
+ } else {
907
+ // Create new img if it doesn't exist
908
+ const newImg = document.createElement("img");
909
+ newImg.src = launcher.iconUrl;
910
+ newImg.alt = "";
911
+ newImg.className = "tvw-rounded-xl tvw-object-cover";
912
+ newImg.style.height = headerIconSize;
913
+ newImg.style.width = headerIconSize;
914
+ iconHolder.replaceChildren(newImg);
915
+ }
916
+ } else {
917
+ // Use text/emoji - clear any SVG or img first
918
+ const existingSvg = iconHolder.querySelector("svg");
919
+ const existingImg = iconHolder.querySelector("img");
920
+ if (existingSvg || existingImg) {
921
+ iconHolder.replaceChildren();
922
+ }
923
+ iconHolder.textContent = launcher.agentIconText ?? "💬";
924
+ }
925
+
926
+ // Update image size if present
927
+ const img = iconHolder.querySelector("img");
928
+ if (img) {
929
+ img.style.height = headerIconSize;
930
+ img.style.width = headerIconSize;
931
+ }
932
+ }
933
+ }
934
+ if (closeButton) {
935
+ const closeButtonSize = launcher.closeButtonSize ?? "32px";
936
+ const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
937
+ closeButton.style.height = closeButtonSize;
938
+ closeButton.style.width = closeButtonSize;
939
+
940
+ // Update placement if changed
941
+ const isTopRight = closeButtonPlacement === "top-right";
942
+ const hasTopRightClasses = closeButton.classList.contains("tvw-absolute");
943
+
944
+ if (isTopRight !== hasTopRightClasses) {
945
+ // Placement changed - need to move button and update classes
946
+ closeButton.remove();
947
+
948
+ // Update classes
949
+ if (isTopRight) {
950
+ closeButton.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50 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";
951
+ container.style.position = "relative";
952
+ container.appendChild(closeButton);
953
+ } else {
954
+ closeButton.className = "tvw-ml-auto 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";
955
+ // Find header element (first child of container)
956
+ const header = container.querySelector(".tvw-border-b-cw-divider");
957
+ if (header) {
958
+ header.appendChild(closeButton);
959
+ }
960
+ }
961
+ }
962
+
963
+ // Apply close button styling from config
964
+ if (launcher.closeButtonColor) {
965
+ closeButton.style.color = launcher.closeButtonColor;
966
+ closeButton.classList.remove("tvw-text-cw-muted");
967
+ } else {
968
+ closeButton.style.color = "";
969
+ closeButton.classList.add("tvw-text-cw-muted");
970
+ }
971
+
972
+ if (launcher.closeButtonBackgroundColor) {
973
+ closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
974
+ closeButton.classList.remove("hover:tvw-bg-gray-100");
975
+ } else {
976
+ closeButton.style.backgroundColor = "";
977
+ closeButton.classList.add("hover:tvw-bg-gray-100");
978
+ }
979
+
980
+ // Apply border if width and/or color are provided
981
+ if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
982
+ const borderWidth = launcher.closeButtonBorderWidth || "0px";
983
+ const borderColor = launcher.closeButtonBorderColor || "transparent";
984
+ closeButton.style.border = `${borderWidth} solid ${borderColor}`;
985
+ closeButton.classList.remove("tvw-border-none");
986
+ } else {
987
+ closeButton.style.border = "";
988
+ closeButton.classList.add("tvw-border-none");
989
+ }
990
+
991
+ if (launcher.closeButtonBorderRadius) {
992
+ closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
993
+ closeButton.classList.remove("tvw-rounded-full");
994
+ } else {
995
+ closeButton.style.borderRadius = "";
996
+ closeButton.classList.add("tvw-rounded-full");
997
+ }
998
+ }
999
+
1000
+ postprocess = buildPostprocessor(config);
1001
+ session.updateConfig(config);
1002
+ renderMessagesWithPlugins(
1003
+ messagesWrapper,
1004
+ session.getMessages(),
1005
+ postprocess
1006
+ );
1007
+ suggestionsManager.render(config.suggestionChips, session, textarea);
1008
+ updateCopy();
1009
+ setComposerDisabled(session.isStreaming());
1010
+
1011
+ // Update voice recognition mic button visibility
1012
+ const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
1013
+ const hasSpeechRecognition =
1014
+ typeof window !== 'undefined' &&
1015
+ (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
1016
+ typeof (window as any).SpeechRecognition !== 'undefined');
1017
+
1018
+ // Update composer form gap based on voice recognition
1019
+ const shouldUseSmallGap = voiceRecognitionEnabled && hasSpeechRecognition;
1020
+ composerForm.classList.remove("tvw-gap-1", "tvw-gap-3");
1021
+ composerForm.classList.add(shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3");
1022
+
1023
+ if (voiceRecognitionEnabled && hasSpeechRecognition) {
1024
+ // Create or update mic button
1025
+ if (!micButton || !micButtonWrapper) {
1026
+ // Create new mic button
1027
+ const micButtonResult = createMicButton(config.voiceRecognition, config.sendButton);
1028
+ if (micButtonResult) {
1029
+ // Update the mutable references
1030
+ micButton = micButtonResult.micButton;
1031
+ micButtonWrapper = micButtonResult.micButtonWrapper;
1032
+
1033
+ // Insert before send button wrapper
1034
+ composerForm.insertBefore(micButtonWrapper, sendButtonWrapper);
1035
+
1036
+ // Wire up click handler
1037
+ micButton.addEventListener("click", handleMicButtonClick);
1038
+
1039
+ // Set disabled state
1040
+ micButton.disabled = session.isStreaming();
1041
+ }
1042
+ } else {
1043
+ // Update existing mic button with new config
1044
+ const voiceConfig = config.voiceRecognition ?? {};
1045
+ const sendButtonConfig = config.sendButton ?? {};
1046
+
1047
+ // Update icon name and size
1048
+ const micIconName = voiceConfig.iconName ?? "mic";
1049
+ const buttonSize = sendButtonConfig.size ?? "40px";
1050
+ const micIconSize = voiceConfig.iconSize ?? buttonSize;
1051
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
1052
+
1053
+ micButton.style.width = micIconSize;
1054
+ micButton.style.height = micIconSize;
1055
+ micButton.style.minWidth = micIconSize;
1056
+ micButton.style.minHeight = micIconSize;
1057
+
1058
+ // Update icon
1059
+ const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor ?? "currentColor";
1060
+ micButton.innerHTML = "";
1061
+ const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColor, 2);
1062
+ if (micIconSvg) {
1063
+ micButton.appendChild(micIconSvg);
1064
+ } else {
1065
+ micButton.textContent = "🎤";
1066
+ }
1067
+
1068
+ // Update colors
1069
+ const backgroundColor = voiceConfig.backgroundColor ?? sendButtonConfig.backgroundColor;
1070
+ if (backgroundColor) {
1071
+ micButton.style.backgroundColor = backgroundColor;
1072
+ micButton.classList.remove("tvw-bg-cw-primary");
1073
+ } else {
1074
+ micButton.style.backgroundColor = "";
1075
+ micButton.classList.add("tvw-bg-cw-primary");
1076
+ }
1077
+
1078
+ if (iconColor) {
1079
+ micButton.style.color = iconColor;
1080
+ micButton.classList.remove("tvw-text-white");
1081
+ } else if (!iconColor && !sendButtonConfig.textColor) {
1082
+ micButton.style.color = "";
1083
+ micButton.classList.add("tvw-text-white");
1084
+ }
1085
+
1086
+ // Update border styling
1087
+ if (voiceConfig.borderWidth) {
1088
+ micButton.style.borderWidth = voiceConfig.borderWidth;
1089
+ micButton.style.borderStyle = "solid";
1090
+ } else {
1091
+ micButton.style.borderWidth = "";
1092
+ micButton.style.borderStyle = "";
1093
+ }
1094
+ if (voiceConfig.borderColor) {
1095
+ micButton.style.borderColor = voiceConfig.borderColor;
1096
+ } else {
1097
+ micButton.style.borderColor = "";
1098
+ }
1099
+
1100
+ // Update padding styling
1101
+ if (voiceConfig.paddingX) {
1102
+ micButton.style.paddingLeft = voiceConfig.paddingX;
1103
+ micButton.style.paddingRight = voiceConfig.paddingX;
1104
+ } else {
1105
+ micButton.style.paddingLeft = "";
1106
+ micButton.style.paddingRight = "";
1107
+ }
1108
+ if (voiceConfig.paddingY) {
1109
+ micButton.style.paddingTop = voiceConfig.paddingY;
1110
+ micButton.style.paddingBottom = voiceConfig.paddingY;
1111
+ } else {
1112
+ micButton.style.paddingTop = "";
1113
+ micButton.style.paddingBottom = "";
1114
+ }
1115
+
1116
+ // Update tooltip
1117
+ const tooltip = micButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
1118
+ const tooltipText = voiceConfig.tooltipText ?? "Start voice recognition";
1119
+ const showTooltip = voiceConfig.showTooltip ?? false;
1120
+ if (showTooltip && tooltipText) {
1121
+ if (!tooltip) {
1122
+ // Create tooltip if it doesn't exist
1123
+ const newTooltip = document.createElement("div");
1124
+ newTooltip.className = "tvw-send-button-tooltip";
1125
+ newTooltip.textContent = tooltipText;
1126
+ micButtonWrapper?.insertBefore(newTooltip, micButton);
1127
+ } else {
1128
+ tooltip.textContent = tooltipText;
1129
+ tooltip.style.display = "";
1130
+ }
1131
+ } else if (tooltip) {
1132
+ // Hide tooltip if disabled
1133
+ tooltip.style.display = "none";
1134
+ }
1135
+
1136
+ // Show and update disabled state
1137
+ micButtonWrapper.style.display = "";
1138
+ micButton.disabled = session.isStreaming();
1139
+ }
1140
+ } else {
1141
+ // Hide mic button
1142
+ if (micButton && micButtonWrapper) {
1143
+ micButtonWrapper.style.display = "none";
1144
+ // Stop any active recording if disabling
1145
+ if (isRecording) {
1146
+ stopVoiceRecognition();
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ // Update send button styling
1152
+ const sendButtonConfig = config.sendButton ?? {};
1153
+ const useIcon = sendButtonConfig.useIcon ?? false;
1154
+ const iconText = sendButtonConfig.iconText ?? "↑";
1155
+ const iconName = sendButtonConfig.iconName;
1156
+ const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
1157
+ const showTooltip = sendButtonConfig.showTooltip ?? false;
1158
+ const buttonSize = sendButtonConfig.size ?? "40px";
1159
+ const backgroundColor = sendButtonConfig.backgroundColor;
1160
+ const textColor = sendButtonConfig.textColor;
1161
+
1162
+ // Update button content and styling based on mode
1163
+ if (useIcon) {
1164
+ // Icon mode: circular button
1165
+ sendButton.style.width = buttonSize;
1166
+ sendButton.style.height = buttonSize;
1167
+ sendButton.style.minWidth = buttonSize;
1168
+ sendButton.style.minHeight = buttonSize;
1169
+ sendButton.style.fontSize = "18px";
1170
+ sendButton.style.lineHeight = "1";
1171
+
1172
+ // Clear existing content
1173
+ sendButton.innerHTML = "";
1174
+
1175
+ // Use Lucide icon if iconName is provided, otherwise fall back to iconText
1176
+ if (iconName) {
1177
+ const iconSize = parseFloat(buttonSize) || 24;
1178
+ const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
1179
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
1180
+ if (iconSvg) {
1181
+ sendButton.appendChild(iconSvg);
1182
+ sendButton.style.color = iconColor;
1183
+ } else {
1184
+ // Fallback to text if icon fails to render
1185
+ sendButton.textContent = iconText;
1186
+ if (textColor) {
1187
+ sendButton.style.color = textColor;
1188
+ } else {
1189
+ sendButton.classList.add("tvw-text-white");
1190
+ }
1191
+ }
1192
+ } else {
1193
+ sendButton.textContent = iconText;
1194
+ if (textColor) {
1195
+ sendButton.style.color = textColor;
1196
+ } else {
1197
+ sendButton.classList.add("tvw-text-white");
1198
+ }
1199
+ }
1200
+
1201
+ // Update classes
1202
+ sendButton.className = "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer";
1203
+
1204
+ if (backgroundColor) {
1205
+ sendButton.style.backgroundColor = backgroundColor;
1206
+ sendButton.classList.remove("tvw-bg-cw-primary");
1207
+ } else {
1208
+ sendButton.classList.add("tvw-bg-cw-primary");
1209
+ }
1210
+ } else {
1211
+ // Text mode: existing behavior
1212
+ sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
1213
+ sendButton.style.width = "";
1214
+ sendButton.style.height = "";
1215
+ sendButton.style.minWidth = "";
1216
+ sendButton.style.minHeight = "";
1217
+ sendButton.style.fontSize = "";
1218
+ sendButton.style.lineHeight = "";
1219
+
1220
+ // Update classes
1221
+ sendButton.className = "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold tvw-text-white disabled:tvw-opacity-50 tvw-cursor-pointer";
1222
+
1223
+ if (backgroundColor) {
1224
+ sendButton.style.backgroundColor = backgroundColor;
1225
+ sendButton.classList.remove("tvw-bg-cw-accent");
1226
+ } else {
1227
+ sendButton.classList.add("tvw-bg-cw-accent");
1228
+ }
1229
+
1230
+ if (textColor) {
1231
+ sendButton.style.color = textColor;
1232
+ } else {
1233
+ sendButton.classList.add("tvw-text-white");
1234
+ }
1235
+ }
1236
+
1237
+ // Apply border styling
1238
+ if (sendButtonConfig.borderWidth) {
1239
+ sendButton.style.borderWidth = sendButtonConfig.borderWidth;
1240
+ sendButton.style.borderStyle = "solid";
1241
+ } else {
1242
+ sendButton.style.borderWidth = "";
1243
+ sendButton.style.borderStyle = "";
1244
+ }
1245
+ if (sendButtonConfig.borderColor) {
1246
+ sendButton.style.borderColor = sendButtonConfig.borderColor;
1247
+ } else {
1248
+ sendButton.style.borderColor = "";
1249
+ }
1250
+
1251
+ // Apply padding styling (works in both icon and text mode)
1252
+ if (sendButtonConfig.paddingX) {
1253
+ sendButton.style.paddingLeft = sendButtonConfig.paddingX;
1254
+ sendButton.style.paddingRight = sendButtonConfig.paddingX;
1255
+ } else {
1256
+ sendButton.style.paddingLeft = "";
1257
+ sendButton.style.paddingRight = "";
1258
+ }
1259
+ if (sendButtonConfig.paddingY) {
1260
+ sendButton.style.paddingTop = sendButtonConfig.paddingY;
1261
+ sendButton.style.paddingBottom = sendButtonConfig.paddingY;
1262
+ } else {
1263
+ sendButton.style.paddingTop = "";
1264
+ sendButton.style.paddingBottom = "";
1265
+ }
1266
+
1267
+ // Update tooltip
1268
+ const tooltip = sendButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
1269
+ if (showTooltip && tooltipText) {
1270
+ if (!tooltip) {
1271
+ // Create tooltip if it doesn't exist
1272
+ const newTooltip = document.createElement("div");
1273
+ newTooltip.className = "tvw-send-button-tooltip";
1274
+ newTooltip.textContent = tooltipText;
1275
+ sendButtonWrapper?.insertBefore(newTooltip, sendButton);
1276
+ } else {
1277
+ tooltip.textContent = tooltipText;
1278
+ tooltip.style.display = "";
1279
+ }
1280
+ } else if (tooltip) {
1281
+ tooltip.style.display = "none";
1282
+ }
1283
+
1284
+ // Update status indicator visibility and text
1285
+ const statusIndicatorConfig = config.statusIndicator ?? {};
1286
+ const isVisible = statusIndicatorConfig.visible ?? true;
1287
+ statusText.style.display = isVisible ? "" : "none";
1288
+
1289
+ // Update status text if status is currently set
1290
+ if (session) {
1291
+ const currentStatus = session.getStatus();
1292
+ const getCurrentStatusText = (status: ChatWidgetSessionStatus): string => {
1293
+ if (status === "idle") return statusIndicatorConfig.idleText ?? statusCopy.idle;
1294
+ if (status === "connecting") return statusIndicatorConfig.connectingText ?? statusCopy.connecting;
1295
+ if (status === "connected") return statusIndicatorConfig.connectedText ?? statusCopy.connected;
1296
+ if (status === "error") return statusIndicatorConfig.errorText ?? statusCopy.error;
1297
+ return statusCopy[status];
1298
+ };
1299
+ statusText.textContent = getCurrentStatusText(currentStatus);
1300
+ }
1301
+ },
1302
+ open() {
1303
+ if (!launcherEnabled) return;
1304
+ setOpenState(true);
1305
+ },
1306
+ close() {
1307
+ if (!launcherEnabled) return;
1308
+ setOpenState(false);
1309
+ },
1310
+ toggle() {
1311
+ if (!launcherEnabled) return;
1312
+ setOpenState(!open);
1313
+ },
1314
+ destroy() {
1315
+ destroyCallbacks.forEach((cb) => cb());
1316
+ wrapper.remove();
1317
+ launcherButtonInstance?.destroy();
1318
+ if (closeHandler) {
1319
+ closeButton.removeEventListener("click", closeHandler);
1320
+ }
1321
+ }
1322
+ };
1323
+ };
1324
+
1325
+ export type ChatWidgetController = Controller;