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.
@@ -0,0 +1,555 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { ChatWidgetConfig } from "../types";
4
+ import { positionMap } from "../utils/positioning";
5
+
6
+ export interface PanelWrapper {
7
+ wrapper: HTMLElement;
8
+ panel: HTMLElement;
9
+ }
10
+
11
+ export const createWrapper = (config?: ChatWidgetConfig): PanelWrapper => {
12
+ const launcherEnabled = config?.launcher?.enabled ?? true;
13
+
14
+ if (!launcherEnabled) {
15
+ const wrapper = createElement(
16
+ "div",
17
+ "tvw-relative tvw-w-full tvw-h-full"
18
+ );
19
+ const panel = createElement(
20
+ "div",
21
+ "tvw-relative tvw-w-full tvw-h-full tvw-min-h-[360px]"
22
+ );
23
+ wrapper.appendChild(panel);
24
+ return { wrapper, panel };
25
+ }
26
+
27
+ const launcher = config?.launcher ?? {};
28
+ const position =
29
+ launcher.position && positionMap[launcher.position]
30
+ ? positionMap[launcher.position]
31
+ : positionMap["bottom-right"];
32
+
33
+ const wrapper = createElement(
34
+ "div",
35
+ `tvw-fixed ${position} tvw-z-50 tvw-transition`
36
+ );
37
+
38
+ const panel = createElement(
39
+ "div",
40
+ "tvw-relative tvw-min-h-[320px]"
41
+ );
42
+ const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
43
+ const width = launcherWidth ?? "min(360px, calc(100vw - 24px))";
44
+ panel.style.width = width;
45
+ panel.style.maxWidth = width;
46
+
47
+ wrapper.appendChild(panel);
48
+ return { wrapper, panel };
49
+ };
50
+
51
+ export interface PanelElements {
52
+ container: HTMLElement;
53
+ body: HTMLElement;
54
+ messagesWrapper: HTMLElement;
55
+ suggestions: HTMLElement;
56
+ textarea: HTMLTextAreaElement;
57
+ sendButton: HTMLButtonElement;
58
+ sendButtonWrapper: HTMLElement;
59
+ micButton: HTMLButtonElement | null;
60
+ micButtonWrapper: HTMLElement | null;
61
+ composerForm: HTMLFormElement;
62
+ statusText: HTMLElement;
63
+ introTitle: HTMLElement;
64
+ introSubtitle: HTMLElement;
65
+ closeButton: HTMLButtonElement;
66
+ iconHolder: HTMLElement;
67
+ }
68
+
69
+ export const buildPanel = (config?: ChatWidgetConfig, showClose = true): PanelElements => {
70
+ const container = createElement(
71
+ "div",
72
+ "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"
73
+ );
74
+
75
+ const header = createElement(
76
+ "div",
77
+ "tvw-flex tvw-items-center tvw-gap-3 tvw-bg-cw-surface tvw-px-6 tvw-py-5 tvw-border-b-cw-divider"
78
+ );
79
+
80
+ const launcher = config?.launcher ?? {};
81
+ const headerIconSize = launcher.headerIconSize ?? "48px";
82
+ const closeButtonSize = launcher.closeButtonSize ?? "32px";
83
+ const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
84
+ const headerIconHidden = launcher.headerIconHidden ?? false;
85
+ const headerIconName = launcher.headerIconName;
86
+
87
+ const iconHolder = createElement(
88
+ "div",
89
+ "tvw-flex tvw-items-center tvw-justify-center tvw-rounded-xl tvw-bg-cw-primary tvw-text-white tvw-text-xl"
90
+ );
91
+ iconHolder.style.height = headerIconSize;
92
+ iconHolder.style.width = headerIconSize;
93
+
94
+ // Render icon based on priority: Lucide icon > iconUrl > agentIconText
95
+ if (!headerIconHidden) {
96
+ if (headerIconName) {
97
+ // Use Lucide icon
98
+ const iconSize = parseFloat(headerIconSize) || 24;
99
+ const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
100
+ if (iconSvg) {
101
+ iconHolder.replaceChildren(iconSvg);
102
+ } else {
103
+ // Fallback to agentIconText if Lucide icon fails
104
+ iconHolder.textContent = config?.launcher?.agentIconText ?? "đź’¬";
105
+ }
106
+ } else if (config?.launcher?.iconUrl) {
107
+ // Use image URL
108
+ const img = createElement("img") as HTMLImageElement;
109
+ img.src = config.launcher.iconUrl;
110
+ img.alt = "";
111
+ img.className = "tvw-rounded-xl tvw-object-cover";
112
+ img.style.height = headerIconSize;
113
+ img.style.width = headerIconSize;
114
+ iconHolder.replaceChildren(img);
115
+ } else {
116
+ // Use text/emoji
117
+ iconHolder.textContent = config?.launcher?.agentIconText ?? "đź’¬";
118
+ }
119
+ }
120
+
121
+ const headerCopy = createElement("div", "tvw-flex tvw-flex-col");
122
+ const title = createElement(
123
+ "span",
124
+ "tvw-text-base tvw-font-semibold"
125
+ );
126
+ title.textContent =
127
+ config?.launcher?.title ?? "Chat Assistant";
128
+ const subtitle = createElement(
129
+ "span",
130
+ "tvw-text-xs tvw-text-cw-muted"
131
+ );
132
+ subtitle.textContent =
133
+ config?.launcher?.subtitle ?? "Here to help you get answers fast";
134
+
135
+ headerCopy.append(title, subtitle);
136
+
137
+ // Only append iconHolder if not hidden
138
+ if (!headerIconHidden) {
139
+ header.append(iconHolder, headerCopy);
140
+ } else {
141
+ header.append(headerCopy);
142
+ }
143
+
144
+ // Create close button with base classes
145
+ const closeButton = createElement(
146
+ "button",
147
+ closeButtonPlacement === "top-right"
148
+ ? "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"
149
+ : "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"
150
+ ) as HTMLButtonElement;
151
+ closeButton.style.height = closeButtonSize;
152
+ closeButton.style.width = closeButtonSize;
153
+ closeButton.type = "button";
154
+ closeButton.setAttribute("aria-label", "Close chat");
155
+ closeButton.textContent = "Ă—";
156
+ closeButton.style.display = showClose ? "" : "none";
157
+
158
+ // Apply close button styling from config
159
+ if (launcher.closeButtonColor) {
160
+ closeButton.style.color = launcher.closeButtonColor;
161
+ closeButton.classList.remove("tvw-text-cw-muted");
162
+ } else {
163
+ closeButton.style.color = "";
164
+ closeButton.classList.add("tvw-text-cw-muted");
165
+ }
166
+
167
+ if (launcher.closeButtonBackgroundColor) {
168
+ closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
169
+ closeButton.classList.remove("hover:tvw-bg-gray-100");
170
+ } else {
171
+ closeButton.style.backgroundColor = "";
172
+ closeButton.classList.add("hover:tvw-bg-gray-100");
173
+ }
174
+
175
+ // Apply border if width and/or color are provided
176
+ if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
177
+ const borderWidth = launcher.closeButtonBorderWidth || "0px";
178
+ const borderColor = launcher.closeButtonBorderColor || "transparent";
179
+ closeButton.style.border = `${borderWidth} solid ${borderColor}`;
180
+ closeButton.classList.remove("tvw-border-none");
181
+ } else {
182
+ closeButton.style.border = "";
183
+ closeButton.classList.add("tvw-border-none");
184
+ }
185
+
186
+ if (launcher.closeButtonBorderRadius) {
187
+ closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
188
+ closeButton.classList.remove("tvw-rounded-full");
189
+ } else {
190
+ closeButton.style.borderRadius = "";
191
+ closeButton.classList.add("tvw-rounded-full");
192
+ }
193
+
194
+ // Position close button based on placement
195
+ if (closeButtonPlacement === "top-right") {
196
+ // Make container position relative for absolute positioning
197
+ container.style.position = "relative";
198
+ container.appendChild(closeButton);
199
+ } else {
200
+ // Inline placement: append to header
201
+ header.appendChild(closeButton);
202
+ }
203
+
204
+ const body = createElement(
205
+ "div",
206
+ "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"
207
+ );
208
+ const introCard = createElement(
209
+ "div",
210
+ "tvw-rounded-2xl tvw-bg-cw-surface tvw-p-6 tvw-shadow-sm"
211
+ );
212
+ const introTitle = createElement(
213
+ "h2",
214
+ "tvw-text-lg tvw-font-semibold tvw-text-cw-primary"
215
+ );
216
+ introTitle.textContent = config?.copy?.welcomeTitle ?? "Hello đź‘‹";
217
+ const introSubtitle = createElement(
218
+ "p",
219
+ "tvw-mt-2 tvw-text-sm tvw-text-cw-muted"
220
+ );
221
+ introSubtitle.textContent =
222
+ config?.copy?.welcomeSubtitle ??
223
+ "Ask anything about your account or products.";
224
+ introCard.append(introTitle, introSubtitle);
225
+
226
+ const messagesWrapper = createElement(
227
+ "div",
228
+ "tvw-flex tvw-flex-col tvw-gap-3"
229
+ );
230
+
231
+ body.append(introCard, messagesWrapper);
232
+
233
+ const footer = createElement(
234
+ "div",
235
+ "tvw-border-t-cw-divider tvw-bg-cw-surface tvw-px-6 tvw-py-4"
236
+ );
237
+ const suggestions = createElement(
238
+ "div",
239
+ "tvw-mb-3 tvw-flex tvw-flex-wrap tvw-gap-2"
240
+ );
241
+ // Determine gap based on voice recognition
242
+ const voiceRecognitionEnabledForGap = config?.voiceRecognition?.enabled === true;
243
+ const hasSpeechRecognitionForGap =
244
+ typeof window !== 'undefined' &&
245
+ (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
246
+ typeof (window as any).SpeechRecognition !== 'undefined');
247
+ const shouldUseSmallGap = voiceRecognitionEnabledForGap && hasSpeechRecognitionForGap;
248
+ const gapClass = shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3";
249
+
250
+ const composerForm = createElement(
251
+ "form",
252
+ `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`
253
+ ) as HTMLFormElement;
254
+ // Prevent form from getting focus styles
255
+ composerForm.style.outline = "none";
256
+
257
+ const textarea = createElement("textarea") as HTMLTextAreaElement;
258
+ textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
259
+ textarea.className =
260
+ "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";
261
+ textarea.rows = 1;
262
+
263
+ // Apply font family and weight from config
264
+ const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
265
+ const fontWeight = config?.theme?.inputFontWeight ?? "400";
266
+
267
+ const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
268
+ switch (family) {
269
+ case "serif":
270
+ return 'Georgia, "Times New Roman", Times, serif';
271
+ case "mono":
272
+ return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
273
+ case "sans-serif":
274
+ default:
275
+ return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
276
+ }
277
+ };
278
+
279
+ textarea.style.fontFamily = getFontFamilyValue(fontFamily);
280
+ textarea.style.fontWeight = fontWeight;
281
+
282
+ // Explicitly remove border and outline on focus to prevent browser defaults
283
+ textarea.style.border = "none";
284
+ textarea.style.outline = "none";
285
+ textarea.style.borderWidth = "0";
286
+ textarea.style.borderStyle = "none";
287
+ textarea.style.borderColor = "transparent";
288
+ textarea.addEventListener("focus", () => {
289
+ textarea.style.border = "none";
290
+ textarea.style.outline = "none";
291
+ textarea.style.borderWidth = "0";
292
+ textarea.style.borderStyle = "none";
293
+ textarea.style.borderColor = "transparent";
294
+ textarea.style.boxShadow = "none";
295
+ });
296
+ textarea.addEventListener("blur", () => {
297
+ textarea.style.border = "none";
298
+ textarea.style.outline = "none";
299
+ });
300
+ // Send button configuration
301
+ const sendButtonConfig = config?.sendButton ?? {};
302
+ const useIcon = sendButtonConfig.useIcon ?? false;
303
+ const iconText = sendButtonConfig.iconText ?? "↑";
304
+ const iconName = sendButtonConfig.iconName;
305
+ const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
306
+ const showTooltip = sendButtonConfig.showTooltip ?? false;
307
+ const buttonSize = sendButtonConfig.size ?? "40px";
308
+ const backgroundColor = sendButtonConfig.backgroundColor;
309
+ const textColor = sendButtonConfig.textColor;
310
+
311
+ // Create wrapper for tooltip positioning
312
+ const sendButtonWrapper = createElement("div", "tvw-send-button-wrapper");
313
+
314
+ const sendButton = createElement(
315
+ "button",
316
+ useIcon
317
+ ? "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
318
+ : "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"
319
+ ) as HTMLButtonElement;
320
+
321
+ sendButton.type = "submit";
322
+
323
+ if (useIcon) {
324
+ // Icon mode: circular button
325
+ sendButton.style.width = buttonSize;
326
+ sendButton.style.height = buttonSize;
327
+ sendButton.style.minWidth = buttonSize;
328
+ sendButton.style.minHeight = buttonSize;
329
+ sendButton.style.fontSize = "18px";
330
+ sendButton.style.lineHeight = "1";
331
+
332
+ // Clear any existing content
333
+ sendButton.innerHTML = "";
334
+
335
+ // Use Lucide icon if iconName is provided, otherwise fall back to iconText
336
+ if (iconName) {
337
+ const iconSize = parseFloat(buttonSize) || 24;
338
+ const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
339
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
340
+ if (iconSvg) {
341
+ sendButton.appendChild(iconSvg);
342
+ sendButton.style.color = iconColor;
343
+ } else {
344
+ // Fallback to text if icon fails to render
345
+ sendButton.textContent = iconText;
346
+ if (textColor) {
347
+ sendButton.style.color = textColor;
348
+ } else {
349
+ sendButton.classList.add("tvw-text-white");
350
+ }
351
+ }
352
+ } else {
353
+ sendButton.textContent = iconText;
354
+ if (textColor) {
355
+ sendButton.style.color = textColor;
356
+ } else {
357
+ sendButton.classList.add("tvw-text-white");
358
+ }
359
+ }
360
+
361
+ if (backgroundColor) {
362
+ sendButton.style.backgroundColor = backgroundColor;
363
+ } else {
364
+ sendButton.classList.add("tvw-bg-cw-primary");
365
+ }
366
+ } else {
367
+ // Text mode: existing behavior
368
+ sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
369
+ if (textColor) {
370
+ sendButton.style.color = textColor;
371
+ } else {
372
+ sendButton.classList.add("tvw-text-white");
373
+ }
374
+ }
375
+
376
+ // Apply existing styling from config
377
+ if (sendButtonConfig.borderWidth) {
378
+ sendButton.style.borderWidth = sendButtonConfig.borderWidth;
379
+ sendButton.style.borderStyle = "solid";
380
+ }
381
+ if (sendButtonConfig.borderColor) {
382
+ sendButton.style.borderColor = sendButtonConfig.borderColor;
383
+ }
384
+
385
+ // Apply padding styling (works in both icon and text mode)
386
+ if (sendButtonConfig.paddingX) {
387
+ sendButton.style.paddingLeft = sendButtonConfig.paddingX;
388
+ sendButton.style.paddingRight = sendButtonConfig.paddingX;
389
+ } else {
390
+ sendButton.style.paddingLeft = "";
391
+ sendButton.style.paddingRight = "";
392
+ }
393
+ if (sendButtonConfig.paddingY) {
394
+ sendButton.style.paddingTop = sendButtonConfig.paddingY;
395
+ sendButton.style.paddingBottom = sendButtonConfig.paddingY;
396
+ } else {
397
+ sendButton.style.paddingTop = "";
398
+ sendButton.style.paddingBottom = "";
399
+ }
400
+
401
+ // Add tooltip if enabled
402
+ if (showTooltip && tooltipText) {
403
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
404
+ tooltip.textContent = tooltipText;
405
+ sendButtonWrapper.appendChild(tooltip);
406
+ }
407
+
408
+ sendButtonWrapper.appendChild(sendButton);
409
+
410
+ // Voice recognition mic button
411
+ const voiceRecognitionConfig = config?.voiceRecognition ?? {};
412
+ const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
413
+ let micButton: HTMLButtonElement | null = null;
414
+ let micButtonWrapper: HTMLElement | null = null;
415
+
416
+ // Check browser support for speech recognition
417
+ const hasSpeechRecognition =
418
+ typeof window !== 'undefined' &&
419
+ (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
420
+ typeof (window as any).SpeechRecognition !== 'undefined');
421
+
422
+ if (voiceRecognitionEnabled && hasSpeechRecognition) {
423
+ micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
424
+ micButton = createElement(
425
+ "button",
426
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
427
+ ) as HTMLButtonElement;
428
+
429
+ micButton.type = "button";
430
+ micButton.setAttribute("aria-label", "Start voice recognition");
431
+
432
+ const micIconName = voiceRecognitionConfig.iconName ?? "mic";
433
+ const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
434
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
435
+
436
+ // Use dedicated colors from voice recognition config, fallback to send button colors
437
+ const micBackgroundColor = voiceRecognitionConfig.backgroundColor ?? backgroundColor;
438
+ const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
439
+
440
+ micButton.style.width = micIconSize;
441
+ micButton.style.height = micIconSize;
442
+ micButton.style.minWidth = micIconSize;
443
+ micButton.style.minHeight = micIconSize;
444
+ micButton.style.fontSize = "18px";
445
+ micButton.style.lineHeight = "1";
446
+
447
+ // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
448
+ const iconColorValue = micIconColor || "currentColor";
449
+ const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
450
+ if (micIconSvg) {
451
+ micButton.appendChild(micIconSvg);
452
+ micButton.style.color = iconColorValue;
453
+ } else {
454
+ // Fallback to text if icon fails
455
+ micButton.textContent = "🎤";
456
+ micButton.style.color = iconColorValue;
457
+ }
458
+
459
+ // Apply background color
460
+ if (micBackgroundColor) {
461
+ micButton.style.backgroundColor = micBackgroundColor;
462
+ } else {
463
+ micButton.classList.add("tvw-bg-cw-primary");
464
+ }
465
+
466
+ // Apply icon/text color
467
+ if (micIconColor) {
468
+ micButton.style.color = micIconColor;
469
+ } else if (!micIconColor && !textColor) {
470
+ micButton.classList.add("tvw-text-white");
471
+ }
472
+
473
+ // Apply border styling
474
+ if (voiceRecognitionConfig.borderWidth) {
475
+ micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
476
+ micButton.style.borderStyle = "solid";
477
+ }
478
+ if (voiceRecognitionConfig.borderColor) {
479
+ micButton.style.borderColor = voiceRecognitionConfig.borderColor;
480
+ }
481
+
482
+ // Apply padding styling
483
+ if (voiceRecognitionConfig.paddingX) {
484
+ micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
485
+ micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
486
+ }
487
+ if (voiceRecognitionConfig.paddingY) {
488
+ micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
489
+ micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
490
+ }
491
+
492
+ micButtonWrapper.appendChild(micButton);
493
+
494
+ // Add tooltip if enabled
495
+ const tooltipText = voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
496
+ const showTooltip = voiceRecognitionConfig.showTooltip ?? false;
497
+ if (showTooltip && tooltipText) {
498
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
499
+ tooltip.textContent = tooltipText;
500
+ micButtonWrapper.appendChild(tooltip);
501
+ }
502
+ }
503
+
504
+ // Focus textarea when composer form container is clicked
505
+ composerForm.addEventListener("click", (e) => {
506
+ // Don't focus if clicking on the send button, mic button, or their wrappers
507
+ if (e.target !== sendButton && e.target !== sendButtonWrapper &&
508
+ e.target !== micButton && e.target !== micButtonWrapper) {
509
+ textarea.focus();
510
+ }
511
+ });
512
+
513
+ // Append elements: textarea, mic button (if exists), send button
514
+ composerForm.append(textarea);
515
+ if (micButtonWrapper) {
516
+ composerForm.append(micButtonWrapper);
517
+ }
518
+ composerForm.append(sendButtonWrapper);
519
+
520
+ const statusText = createElement(
521
+ "div",
522
+ "tvw-mt-2 tvw-text-right tvw-text-xs tvw-text-cw-muted"
523
+ );
524
+
525
+ // Apply status indicator config
526
+ const statusConfig = config?.statusIndicator ?? {};
527
+ const isVisible = statusConfig.visible ?? true;
528
+ statusText.style.display = isVisible ? "" : "none";
529
+ statusText.textContent = statusConfig.idleText ?? "Online";
530
+
531
+ footer.append(suggestions, composerForm, statusText);
532
+
533
+ container.append(header, body, footer);
534
+
535
+ return {
536
+ container,
537
+ body,
538
+ messagesWrapper,
539
+ suggestions,
540
+ textarea,
541
+ sendButton,
542
+ sendButtonWrapper,
543
+ micButton,
544
+ micButtonWrapper,
545
+ composerForm,
546
+ statusText,
547
+ introTitle,
548
+ introSubtitle,
549
+ closeButton,
550
+ iconHolder
551
+ };
552
+ };
553
+
554
+
555
+
@@ -0,0 +1,114 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { ChatWidgetMessage } from "../types";
3
+ import { describeReasonStatus } from "../utils/formatting";
4
+
5
+ // Expansion state per widget instance
6
+ const reasoningExpansionState = new Set<string>();
7
+
8
+ export const createReasoningBubble = (message: ChatWidgetMessage): HTMLElement => {
9
+ const reasoning = message.reasoning;
10
+ const bubble = createElement(
11
+ "div",
12
+ [
13
+ "tvw-max-w-[85%]",
14
+ "tvw-rounded-2xl",
15
+ "tvw-bg-cw-surface",
16
+ "tvw-border",
17
+ "tvw-border-cw-message-border",
18
+ "tvw-text-cw-primary",
19
+ "tvw-shadow-sm",
20
+ "tvw-overflow-hidden",
21
+ "tvw-px-0",
22
+ "tvw-py-0"
23
+ ].join(" ")
24
+ );
25
+
26
+ if (!reasoning) {
27
+ return bubble;
28
+ }
29
+
30
+ let expanded = reasoningExpansionState.has(message.id);
31
+ const header = createElement(
32
+ "button",
33
+ "tvw-flex tvw-w-full tvw-items-center tvw-justify-between tvw-gap-3 tvw-bg-transparent tvw-px-4 tvw-py-3 tvw-text-left tvw-cursor-pointer tvw-border-none"
34
+ ) as HTMLButtonElement;
35
+ header.type = "button";
36
+ header.setAttribute("aria-expanded", expanded ? "true" : "false");
37
+
38
+ const headerContent = createElement("div", "tvw-flex tvw-flex-col tvw-text-left");
39
+ const title = createElement("span", "tvw-text-xs tvw-font-semibold tvw-text-cw-primary");
40
+ title.textContent = "Thinking...";
41
+ headerContent.appendChild(title);
42
+
43
+ const status = createElement("span", "tvw-text-xs tvw-text-cw-primary");
44
+ status.textContent = describeReasonStatus(reasoning);
45
+ headerContent.appendChild(status);
46
+
47
+ if (reasoning.status === "complete") {
48
+ title.style.display = "none";
49
+ } else {
50
+ title.style.display = "";
51
+ }
52
+
53
+ const toggleLabel = createElement(
54
+ "span",
55
+ "tvw-text-xs tvw-text-cw-primary"
56
+ );
57
+ toggleLabel.textContent = expanded ? "Hide" : "Show";
58
+
59
+ header.append(headerContent, toggleLabel);
60
+
61
+ const content = createElement(
62
+ "div",
63
+ "tvw-border-t tvw-border-gray-200 tvw-bg-gray-50 tvw-px-4 tvw-py-3"
64
+ );
65
+ content.style.display = expanded ? "" : "none";
66
+
67
+ const text = reasoning.chunks.join("");
68
+ const body = createElement(
69
+ "div",
70
+ "tvw-whitespace-pre-wrap tvw-text-xs tvw-leading-snug tvw-text-cw-muted"
71
+ );
72
+ body.textContent =
73
+ text ||
74
+ (reasoning.status === "complete"
75
+ ? "No additional context was shared."
76
+ : "Waiting for details…");
77
+ content.appendChild(body);
78
+
79
+ const applyExpansionState = () => {
80
+ header.setAttribute("aria-expanded", expanded ? "true" : "false");
81
+ toggleLabel.textContent = expanded ? "Hide" : "Show";
82
+ content.style.display = expanded ? "" : "none";
83
+ };
84
+
85
+ const toggleExpansion = () => {
86
+ expanded = !expanded;
87
+ if (expanded) {
88
+ reasoningExpansionState.add(message.id);
89
+ } else {
90
+ reasoningExpansionState.delete(message.id);
91
+ }
92
+ applyExpansionState();
93
+ };
94
+
95
+ header.addEventListener("pointerdown", (event) => {
96
+ event.preventDefault();
97
+ toggleExpansion();
98
+ });
99
+
100
+ header.addEventListener("keydown", (event) => {
101
+ if (event.key === "Enter" || event.key === " ") {
102
+ event.preventDefault();
103
+ toggleExpansion();
104
+ }
105
+ });
106
+
107
+ applyExpansionState();
108
+
109
+ bubble.append(header, content);
110
+ return bubble;
111
+ };
112
+
113
+
114
+