@runtypelabs/persona 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +1080 -0
  2. package/dist/index.cjs +140 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2626 -0
  5. package/dist/index.d.ts +2626 -0
  6. package/dist/index.global.js +1843 -0
  7. package/dist/index.global.js.map +1 -0
  8. package/dist/index.js +140 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/install.global.js +2 -0
  11. package/dist/install.global.js.map +1 -0
  12. package/dist/widget.css +1627 -0
  13. package/package.json +79 -0
  14. package/src/@types/idiomorph.d.ts +37 -0
  15. package/src/client.test.ts +387 -0
  16. package/src/client.ts +1589 -0
  17. package/src/components/composer-builder.ts +530 -0
  18. package/src/components/feedback.ts +379 -0
  19. package/src/components/forms.ts +170 -0
  20. package/src/components/header-builder.ts +455 -0
  21. package/src/components/header-layouts.ts +303 -0
  22. package/src/components/launcher.ts +193 -0
  23. package/src/components/message-bubble.ts +528 -0
  24. package/src/components/messages.ts +54 -0
  25. package/src/components/panel.ts +204 -0
  26. package/src/components/reasoning-bubble.ts +144 -0
  27. package/src/components/registry.ts +87 -0
  28. package/src/components/suggestions.ts +97 -0
  29. package/src/components/tool-bubble.ts +288 -0
  30. package/src/defaults.ts +321 -0
  31. package/src/index.ts +175 -0
  32. package/src/install.ts +284 -0
  33. package/src/plugins/registry.ts +77 -0
  34. package/src/plugins/types.ts +95 -0
  35. package/src/postprocessors.ts +194 -0
  36. package/src/runtime/init.ts +162 -0
  37. package/src/session.ts +376 -0
  38. package/src/styles/tailwind.css +20 -0
  39. package/src/styles/widget.css +1627 -0
  40. package/src/types.ts +1635 -0
  41. package/src/ui.ts +3341 -0
  42. package/src/utils/actions.ts +227 -0
  43. package/src/utils/attachment-manager.ts +384 -0
  44. package/src/utils/code-generators.test.ts +500 -0
  45. package/src/utils/code-generators.ts +1806 -0
  46. package/src/utils/component-middleware.ts +137 -0
  47. package/src/utils/component-parser.ts +119 -0
  48. package/src/utils/constants.ts +16 -0
  49. package/src/utils/content.ts +306 -0
  50. package/src/utils/dom.ts +25 -0
  51. package/src/utils/events.ts +41 -0
  52. package/src/utils/formatting.test.ts +166 -0
  53. package/src/utils/formatting.ts +470 -0
  54. package/src/utils/icons.ts +92 -0
  55. package/src/utils/message-id.ts +37 -0
  56. package/src/utils/morph.ts +36 -0
  57. package/src/utils/positioning.ts +17 -0
  58. package/src/utils/storage.ts +72 -0
  59. package/src/utils/theme.ts +105 -0
  60. package/src/widget.css +1 -0
  61. package/widget.css +1 -0
package/src/ui.ts ADDED
@@ -0,0 +1,3341 @@
1
+ import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
2
+ import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
3
+ import {
4
+ AgentWidgetConfig,
5
+ AgentWidgetMessage,
6
+ AgentWidgetEvent,
7
+ AgentWidgetStorageAdapter,
8
+ AgentWidgetStoredState,
9
+ AgentWidgetControllerEventMap,
10
+ AgentWidgetVoiceStateEvent,
11
+ AgentWidgetStateEvent,
12
+ AgentWidgetStateSnapshot,
13
+ WidgetLayoutSlot,
14
+ SlotRenderer,
15
+ AgentWidgetMessageFeedback,
16
+ ContentPart
17
+ } from "./types";
18
+ import { AttachmentManager } from "./utils/attachment-manager";
19
+ import { createTextPart, ALL_SUPPORTED_MIME_TYPES } from "./utils/content";
20
+ import { applyThemeVariables, createThemeObserver } from "./utils/theme";
21
+ import { renderLucideIcon } from "./utils/icons";
22
+ import { createElement } from "./utils/dom";
23
+ import { morphMessages } from "./utils/morph";
24
+ import { statusCopy } from "./utils/constants";
25
+ import { createLauncherButton } from "./components/launcher";
26
+ import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
27
+ import { buildHeaderWithLayout } from "./components/header-layouts";
28
+ import { positionMap } from "./utils/positioning";
29
+ import type { HeaderElements, ComposerElements } from "./components/panel";
30
+ import { MessageTransform, MessageActionCallbacks } from "./components/message-bubble";
31
+ import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
32
+ import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
33
+ import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
34
+ import { createSuggestions } from "./components/suggestions";
35
+ import { enhanceWithForms } from "./components/forms";
36
+ import { pluginRegistry } from "./plugins/registry";
37
+ import { mergeWithDefaults } from "./defaults";
38
+ import { createEventBus } from "./utils/events";
39
+ import {
40
+ createActionManager,
41
+ defaultActionHandlers,
42
+ defaultJsonActionParser
43
+ } from "./utils/actions";
44
+ import { createLocalStorageAdapter } from "./utils/storage";
45
+ import { componentRegistry } from "./components/registry";
46
+ import {
47
+ renderComponentDirective,
48
+ extractComponentDirectiveFromMessage,
49
+ hasComponentDirective
50
+ } from "./utils/component-middleware";
51
+ import {
52
+ createCSATFeedback,
53
+ createNPSFeedback,
54
+ type CSATFeedbackOptions,
55
+ type NPSFeedbackOptions
56
+ } from "./components/feedback";
57
+
58
+ // Default localStorage key for chat history (automatically cleared on clear chat)
59
+ const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "persona-chat-history";
60
+ const VOICE_STATE_RESTORE_WINDOW = 30 * 1000;
61
+
62
+ const ensureRecord = (value: unknown): Record<string, unknown> => {
63
+ if (!value || typeof value !== "object") {
64
+ return {};
65
+ }
66
+ return { ...(value as Record<string, unknown>) };
67
+ };
68
+
69
+ const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
70
+ messages.map((message) => ({
71
+ ...message,
72
+ streaming: false
73
+ }));
74
+
75
+ type Controller = {
76
+ update: (config: AgentWidgetConfig) => void;
77
+ destroy: () => void;
78
+ open: () => void;
79
+ close: () => void;
80
+ toggle: () => void;
81
+ clearChat: () => void;
82
+ setMessage: (message: string) => boolean;
83
+ submitMessage: (message?: string) => boolean;
84
+ startVoiceRecognition: () => boolean;
85
+ stopVoiceRecognition: () => boolean;
86
+ injectTestMessage: (event: AgentWidgetEvent) => void;
87
+ getMessages: () => AgentWidgetMessage[];
88
+ getStatus: () => AgentWidgetSessionStatus;
89
+ getPersistentMetadata: () => Record<string, unknown>;
90
+ updatePersistentMetadata: (
91
+ updater: (prev: Record<string, unknown>) => Record<string, unknown>
92
+ ) => void;
93
+ on: <K extends keyof AgentWidgetControllerEventMap>(
94
+ event: K,
95
+ handler: (payload: AgentWidgetControllerEventMap[K]) => void
96
+ ) => () => void;
97
+ off: <K extends keyof AgentWidgetControllerEventMap>(
98
+ event: K,
99
+ handler: (payload: AgentWidgetControllerEventMap[K]) => void
100
+ ) => void;
101
+ // State query methods
102
+ isOpen: () => boolean;
103
+ isVoiceActive: () => boolean;
104
+ getState: () => AgentWidgetStateSnapshot;
105
+ // Feedback methods (CSAT/NPS)
106
+ showCSATFeedback: (options?: Partial<CSATFeedbackOptions>) => void;
107
+ showNPSFeedback: (options?: Partial<NPSFeedbackOptions>) => void;
108
+ submitCSATFeedback: (rating: number, comment?: string) => Promise<void>;
109
+ submitNPSFeedback: (rating: number, comment?: string) => Promise<void>;
110
+ };
111
+
112
+ const buildPostprocessor = (
113
+ cfg: AgentWidgetConfig | undefined,
114
+ actionManager?: ReturnType<typeof createActionManager>
115
+ ): MessageTransform => {
116
+ // Create markdown processor from config if markdown config is provided
117
+ // This allows users to enable markdown rendering via config.markdown
118
+ const markdownProcessor = cfg?.markdown
119
+ ? createMarkdownProcessorFromConfig(cfg.markdown)
120
+ : null;
121
+
122
+ return (context) => {
123
+ let nextText = context.text ?? "";
124
+ const rawPayload = context.message.rawContent ?? null;
125
+
126
+ if (actionManager) {
127
+ const actionResult = actionManager.process({
128
+ text: nextText,
129
+ raw: rawPayload ?? nextText,
130
+ message: context.message,
131
+ streaming: context.streaming
132
+ });
133
+ if (actionResult !== null) {
134
+ nextText = actionResult.text;
135
+ // Mark message as non-persistable if persist is false
136
+ if (!actionResult.persist) {
137
+ (context.message as any).__skipPersist = true;
138
+ }
139
+ }
140
+ }
141
+
142
+ // Priority: postprocessMessage > markdown config > escapeHtml
143
+ if (cfg?.postprocessMessage) {
144
+ return cfg.postprocessMessage({
145
+ ...context,
146
+ text: nextText,
147
+ raw: rawPayload ?? context.text ?? ""
148
+ });
149
+ }
150
+
151
+ // Use markdown processor if markdown config is provided
152
+ if (markdownProcessor) {
153
+ return markdownProcessor(nextText);
154
+ }
155
+
156
+ return escapeHtml(nextText);
157
+ };
158
+ };
159
+
160
+ export const createAgentExperience = (
161
+ mount: HTMLElement,
162
+ initialConfig?: AgentWidgetConfig,
163
+ runtimeOptions?: { debugTools?: boolean }
164
+ ): Controller => {
165
+ // Tailwind config uses important: "#persona-root", so ensure mount has this ID
166
+ if (!mount.id || mount.id !== "persona-root") {
167
+ mount.id = "persona-root";
168
+ }
169
+
170
+ let config = mergeWithDefaults(initialConfig) as AgentWidgetConfig;
171
+ // Note: applyThemeVariables is called after applyFullHeightStyles() below
172
+ // because applyFullHeightStyles resets mount.style.cssText
173
+
174
+ // Get plugins for this instance
175
+ const plugins = pluginRegistry.getForInstance(config.plugins);
176
+
177
+ // Register components from config
178
+ if (config.components) {
179
+ componentRegistry.registerAll(config.components);
180
+ }
181
+ const eventBus = createEventBus<AgentWidgetControllerEventMap>();
182
+
183
+ const storageAdapter: AgentWidgetStorageAdapter =
184
+ config.storageAdapter ?? createLocalStorageAdapter();
185
+ let persistentMetadata: Record<string, unknown> = {};
186
+ let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
187
+
188
+ if (storageAdapter?.load) {
189
+ try {
190
+ const storedState = storageAdapter.load();
191
+ if (storedState && typeof (storedState as Promise<any>).then === "function") {
192
+ pendingStoredState = storedState as Promise<AgentWidgetStoredState | null>;
193
+ } else if (storedState) {
194
+ const immediateState = storedState as AgentWidgetStoredState;
195
+ if (immediateState.metadata) {
196
+ persistentMetadata = ensureRecord(immediateState.metadata);
197
+ }
198
+ if (immediateState.messages?.length) {
199
+ config = { ...config, initialMessages: immediateState.messages };
200
+ }
201
+ }
202
+ } catch (error) {
203
+ if (typeof console !== "undefined") {
204
+ // eslint-disable-next-line no-console
205
+ console.error("[AgentWidget] Failed to load stored state:", error);
206
+ }
207
+ }
208
+ }
209
+
210
+ const getSessionMetadata = () => persistentMetadata;
211
+ const updateSessionMetadata = (
212
+ updater: (prev: Record<string, unknown>) => Record<string, unknown>
213
+ ) => {
214
+ const next = updater({ ...persistentMetadata }) ?? {};
215
+ persistentMetadata = next;
216
+ persistState();
217
+ };
218
+
219
+ const resolvedActionParsers =
220
+ config.actionParsers && config.actionParsers.length
221
+ ? config.actionParsers
222
+ : [defaultJsonActionParser];
223
+
224
+ const resolvedActionHandlers =
225
+ config.actionHandlers && config.actionHandlers.length
226
+ ? config.actionHandlers
227
+ : [defaultActionHandlers.message, defaultActionHandlers.messageAndClick];
228
+
229
+ let actionManager = createActionManager({
230
+ parsers: resolvedActionParsers,
231
+ handlers: resolvedActionHandlers,
232
+ getSessionMetadata,
233
+ updateSessionMetadata,
234
+ emit: eventBus.emit,
235
+ documentRef: typeof document !== "undefined" ? document : null
236
+ });
237
+ actionManager.syncFromMetadata();
238
+
239
+ let launcherEnabled = config.launcher?.enabled ?? true;
240
+ let autoExpand = config.launcher?.autoExpand ?? false;
241
+ let prevAutoExpand = autoExpand;
242
+ let prevLauncherEnabled = launcherEnabled;
243
+ let prevHeaderLayout = config.layout?.header?.layout;
244
+ let open = launcherEnabled ? autoExpand : true;
245
+ let postprocess = buildPostprocessor(config, actionManager);
246
+ let showReasoning = config.features?.showReasoning ?? true;
247
+ let showToolCalls = config.features?.showToolCalls ?? true;
248
+
249
+ // Create message action callbacks that emit events and optionally send to API
250
+ const messageActionCallbacks: MessageActionCallbacks = {
251
+ onCopy: (message: AgentWidgetMessage) => {
252
+ eventBus.emit("message:copy", message);
253
+ // Send copy feedback to API if in client token mode
254
+ if (session?.isClientTokenMode()) {
255
+ session.submitMessageFeedback(message.id, 'copy').catch((error) => {
256
+ if (config.debug) {
257
+ // eslint-disable-next-line no-console
258
+ console.error("[AgentWidget] Failed to submit copy feedback:", error);
259
+ }
260
+ });
261
+ }
262
+ // Call user-provided callback
263
+ config.messageActions?.onCopy?.(message);
264
+ },
265
+ onFeedback: (feedback: AgentWidgetMessageFeedback) => {
266
+ eventBus.emit("message:feedback", feedback);
267
+ // Send feedback to API if in client token mode
268
+ if (session?.isClientTokenMode()) {
269
+ session.submitMessageFeedback(feedback.messageId, feedback.type).catch((error) => {
270
+ if (config.debug) {
271
+ // eslint-disable-next-line no-console
272
+ console.error("[AgentWidget] Failed to submit feedback:", error);
273
+ }
274
+ });
275
+ }
276
+ // Call user-provided callback
277
+ config.messageActions?.onFeedback?.(feedback);
278
+ }
279
+ };
280
+
281
+ // Get status indicator config
282
+ const statusConfig = config.statusIndicator ?? {};
283
+ const getStatusText = (status: AgentWidgetSessionStatus): string => {
284
+ if (status === "idle") return statusConfig.idleText ?? statusCopy.idle;
285
+ if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
286
+ if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
287
+ if (status === "error") return statusConfig.errorText ?? statusCopy.error;
288
+ return statusCopy[status];
289
+ };
290
+
291
+ const { wrapper, panel } = createWrapper(config);
292
+ const panelElements = buildPanel(config, launcherEnabled);
293
+ let {
294
+ container,
295
+ body,
296
+ messagesWrapper,
297
+ suggestions,
298
+ textarea,
299
+ sendButton,
300
+ sendButtonWrapper,
301
+ composerForm,
302
+ statusText,
303
+ introTitle,
304
+ introSubtitle,
305
+ closeButton,
306
+ iconHolder,
307
+ headerTitle,
308
+ headerSubtitle,
309
+ header,
310
+ footer,
311
+ actionsRow,
312
+ leftActions,
313
+ rightActions
314
+ } = panelElements;
315
+
316
+ // Use mutable references for mic button so we can update them dynamically
317
+ let micButton: HTMLButtonElement | null = panelElements.micButton;
318
+ let micButtonWrapper: HTMLElement | null = panelElements.micButtonWrapper;
319
+
320
+ // Use mutable references for attachment elements so we can create them dynamically
321
+ let attachmentButton: HTMLButtonElement | null = panelElements.attachmentButton;
322
+ let attachmentButtonWrapper: HTMLElement | null = panelElements.attachmentButtonWrapper;
323
+ let attachmentInput: HTMLInputElement | null = panelElements.attachmentInput;
324
+ let attachmentPreviewsContainer: HTMLElement | null = panelElements.attachmentPreviewsContainer;
325
+
326
+ // Initialize attachment manager if attachments are enabled
327
+ let attachmentManager: AttachmentManager | null = null;
328
+ if (config.attachments?.enabled && attachmentInput && attachmentPreviewsContainer) {
329
+ attachmentManager = AttachmentManager.fromConfig(config.attachments);
330
+ attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
331
+
332
+ // Wire up file input change event
333
+ attachmentInput.addEventListener("change", (e) => {
334
+ const target = e.target as HTMLInputElement;
335
+ attachmentManager?.handleFileSelect(target.files);
336
+ // Reset input so same file can be selected again
337
+ target.value = "";
338
+ });
339
+ }
340
+
341
+ // Plugin hook: renderHeader - allow plugins to provide custom header
342
+ const headerPlugin = plugins.find(p => p.renderHeader);
343
+ if (headerPlugin?.renderHeader) {
344
+ const customHeader = headerPlugin.renderHeader({
345
+ config,
346
+ defaultRenderer: () => {
347
+ const headerElements = buildHeader({ config, showClose: launcherEnabled });
348
+ attachHeaderToContainer(container, headerElements, config);
349
+ return headerElements.header;
350
+ },
351
+ onClose: () => setOpenState(false, "user")
352
+ });
353
+ if (customHeader) {
354
+ // Replace the default header with custom header
355
+ const existingHeader = container.querySelector('.tvw-border-b-cw-divider');
356
+ if (existingHeader) {
357
+ existingHeader.replaceWith(customHeader);
358
+ header = customHeader;
359
+ }
360
+ }
361
+ }
362
+
363
+ // Plugin hook: renderComposer - allow plugins to provide custom composer
364
+ const composerPlugin = plugins.find(p => p.renderComposer);
365
+ if (composerPlugin?.renderComposer) {
366
+ const customComposer = composerPlugin.renderComposer({
367
+ config,
368
+ defaultRenderer: () => {
369
+ const composerElements = buildComposer({ config });
370
+ return composerElements.footer;
371
+ },
372
+ onSubmit: (text: string) => {
373
+ if (session && !session.isStreaming()) {
374
+ session.sendMessage(text);
375
+ }
376
+ },
377
+ disabled: false
378
+ });
379
+ if (customComposer) {
380
+ // Replace the default footer with custom composer
381
+ footer.replaceWith(customComposer);
382
+ footer = customComposer;
383
+ // Note: When using custom composer, textarea/sendButton/etc may not exist
384
+ // The plugin is responsible for providing its own submit handling
385
+ }
386
+ }
387
+
388
+ // Slot system: allow custom content injection into specific regions
389
+ const renderSlots = () => {
390
+ const slots = config.layout?.slots ?? {};
391
+
392
+ // Helper to get default slot content
393
+ const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
394
+ switch (slot) {
395
+ case "body-top":
396
+ // Default: the intro card
397
+ return container.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6") as HTMLElement || null;
398
+ case "messages":
399
+ return messagesWrapper;
400
+ case "footer-top":
401
+ return suggestions;
402
+ case "composer":
403
+ return composerForm;
404
+ case "footer-bottom":
405
+ return statusText;
406
+ default:
407
+ return null;
408
+ }
409
+ };
410
+
411
+ // Helper to insert content into slot region
412
+ const insertSlotContent = (slot: WidgetLayoutSlot, element: HTMLElement) => {
413
+ switch (slot) {
414
+ case "header-left":
415
+ case "header-center":
416
+ case "header-right":
417
+ // Header slots - prepend/append to header
418
+ if (slot === "header-left") {
419
+ header.insertBefore(element, header.firstChild);
420
+ } else if (slot === "header-right") {
421
+ header.appendChild(element);
422
+ } else {
423
+ // header-center: insert after icon/title
424
+ const titleSection = header.querySelector(".tvw-flex-col");
425
+ if (titleSection) {
426
+ titleSection.parentNode?.insertBefore(element, titleSection.nextSibling);
427
+ } else {
428
+ header.appendChild(element);
429
+ }
430
+ }
431
+ break;
432
+ case "body-top":
433
+ // Replace or prepend to body
434
+ const introCard = body.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6");
435
+ if (introCard) {
436
+ introCard.replaceWith(element);
437
+ } else {
438
+ body.insertBefore(element, body.firstChild);
439
+ }
440
+ break;
441
+ case "body-bottom":
442
+ // Append after messages wrapper
443
+ body.appendChild(element);
444
+ break;
445
+ case "footer-top":
446
+ // Replace suggestions area
447
+ suggestions.replaceWith(element);
448
+ break;
449
+ case "footer-bottom":
450
+ // Replace or append after status text
451
+ statusText.replaceWith(element);
452
+ break;
453
+ default:
454
+ // For other slots, just append to appropriate container
455
+ break;
456
+ }
457
+ };
458
+
459
+ // Process each configured slot
460
+ for (const [slotName, renderer] of Object.entries(slots) as [WidgetLayoutSlot, SlotRenderer][]) {
461
+ if (renderer) {
462
+ try {
463
+ const slotElement = renderer({
464
+ config,
465
+ defaultContent: () => getDefaultSlotContent(slotName)
466
+ });
467
+ if (slotElement) {
468
+ insertSlotContent(slotName, slotElement);
469
+ }
470
+ } catch (error) {
471
+ if (typeof console !== "undefined") {
472
+ // eslint-disable-next-line no-console
473
+ console.error(`[AgentWidget] Error rendering slot "${slotName}":`, error);
474
+ }
475
+ }
476
+ }
477
+ }
478
+ };
479
+
480
+ // Render custom slots
481
+ renderSlots();
482
+
483
+ // Add event delegation for reasoning and tool bubble expansion
484
+ // This handles clicks even after idiomorph morphs the DOM
485
+ const handleBubbleExpansion = (event: Event) => {
486
+ const target = event.target as HTMLElement;
487
+
488
+ // Check if the click/keypress is on an expand header button
489
+ const headerButton = target.closest('button[data-expand-header="true"]') as HTMLElement;
490
+ if (!headerButton) return;
491
+
492
+ // Find the parent bubble element
493
+ const bubble = headerButton.closest('.vanilla-reasoning-bubble, .vanilla-tool-bubble') as HTMLElement;
494
+ if (!bubble) return;
495
+
496
+ // Get message ID from bubble
497
+ const messageId = bubble.getAttribute('data-message-id');
498
+ if (!messageId) return;
499
+
500
+ const bubbleType = headerButton.getAttribute('data-bubble-type');
501
+
502
+ // Toggle expansion state
503
+ if (bubbleType === 'reasoning') {
504
+ if (reasoningExpansionState.has(messageId)) {
505
+ reasoningExpansionState.delete(messageId);
506
+ } else {
507
+ reasoningExpansionState.add(messageId);
508
+ }
509
+ updateReasoningBubbleUI(messageId, bubble);
510
+ } else if (bubbleType === 'tool') {
511
+ if (toolExpansionState.has(messageId)) {
512
+ toolExpansionState.delete(messageId);
513
+ } else {
514
+ toolExpansionState.add(messageId);
515
+ }
516
+ updateToolBubbleUI(messageId, bubble, config);
517
+ }
518
+ };
519
+
520
+ // Attach event listeners to messagesWrapper for event delegation
521
+ messagesWrapper.addEventListener('pointerdown', (event) => {
522
+ const target = event.target as HTMLElement;
523
+ if (target.closest('button[data-expand-header="true"]')) {
524
+ event.preventDefault();
525
+ handleBubbleExpansion(event);
526
+ }
527
+ });
528
+
529
+ messagesWrapper.addEventListener('keydown', (event) => {
530
+ const target = event.target as HTMLElement;
531
+ if ((event.key === 'Enter' || event.key === ' ') && target.closest('button[data-expand-header="true"]')) {
532
+ event.preventDefault();
533
+ handleBubbleExpansion(event);
534
+ }
535
+ });
536
+
537
+ panel.appendChild(container);
538
+ mount.appendChild(wrapper);
539
+
540
+ // Apply full-height and sidebar styles if enabled
541
+ // This ensures the widget fills its container height with proper flex layout
542
+ const applyFullHeightStyles = () => {
543
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
544
+ const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
545
+ const theme = config.theme ?? {};
546
+
547
+ // Determine panel styling based on mode, with theme overrides
548
+ const position = config.launcher?.position ?? 'bottom-left';
549
+ const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
550
+
551
+ // Default values based on mode
552
+ const defaultPanelBorder = sidebarMode ? 'none' : '1px solid var(--tvw-cw-border)';
553
+ const defaultPanelShadow = sidebarMode
554
+ ? (isLeftSidebar ? '2px 0 12px rgba(0, 0, 0, 0.08)' : '-2px 0 12px rgba(0, 0, 0, 0.08)')
555
+ : '0 25px 50px -12px rgba(0, 0, 0, 0.25)';
556
+ const defaultPanelBorderRadius = sidebarMode ? '0' : '16px';
557
+
558
+ // Apply theme overrides or defaults
559
+ const panelBorder = theme.panelBorder ?? defaultPanelBorder;
560
+ const panelShadow = theme.panelShadow ?? defaultPanelShadow;
561
+ const panelBorderRadius = theme.panelBorderRadius ?? defaultPanelBorderRadius;
562
+
563
+ // Reset all inline styles first to handle mode toggling
564
+ // This ensures styles don't persist when switching between modes
565
+ mount.style.cssText = '';
566
+ wrapper.style.cssText = '';
567
+ panel.style.cssText = '';
568
+ container.style.cssText = '';
569
+ body.style.cssText = '';
570
+ footer.style.cssText = '';
571
+
572
+ // Re-apply panel width/maxWidth from initial setup
573
+ const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
574
+ const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
575
+ if (!sidebarMode) {
576
+ panel.style.width = width;
577
+ panel.style.maxWidth = width;
578
+ }
579
+
580
+ // Apply panel styling
581
+ // Box-shadow is applied to panel (parent) instead of container to avoid
582
+ // rendering artifacts when container has overflow:hidden + border-radius
583
+ // Panel also gets border-radius to make the shadow follow the rounded corners
584
+ panel.style.boxShadow = panelShadow;
585
+ panel.style.borderRadius = panelBorderRadius;
586
+ container.style.border = panelBorder;
587
+ container.style.borderRadius = panelBorderRadius;
588
+
589
+ // Check if this is inline embed mode (launcher disabled) vs launcher mode
590
+ const isInlineEmbed = config.launcher?.enabled === false;
591
+
592
+ if (fullHeight) {
593
+ // Mount container
594
+ mount.style.display = 'flex';
595
+ mount.style.flexDirection = 'column';
596
+ mount.style.height = '100%';
597
+ mount.style.minHeight = '0';
598
+
599
+ // Wrapper
600
+ // - Inline embed: needs overflow:hidden to contain the flex layout
601
+ // - Launcher mode: no overflow:hidden to allow panel's box-shadow to render fully
602
+ wrapper.style.display = 'flex';
603
+ wrapper.style.flexDirection = 'column';
604
+ wrapper.style.flex = '1 1 0%';
605
+ wrapper.style.minHeight = '0';
606
+ wrapper.style.maxHeight = '100%';
607
+ wrapper.style.height = '100%';
608
+ if (isInlineEmbed) {
609
+ wrapper.style.overflow = 'hidden';
610
+ }
611
+
612
+ // Panel
613
+ panel.style.display = 'flex';
614
+ panel.style.flexDirection = 'column';
615
+ panel.style.flex = '1 1 0%';
616
+ panel.style.minHeight = '0';
617
+ panel.style.maxHeight = '100%';
618
+ panel.style.height = '100%';
619
+ panel.style.overflow = 'hidden';
620
+
621
+ // Main container
622
+ container.style.display = 'flex';
623
+ container.style.flexDirection = 'column';
624
+ container.style.flex = '1 1 0%';
625
+ container.style.minHeight = '0';
626
+ container.style.maxHeight = '100%';
627
+ container.style.overflow = 'hidden';
628
+
629
+ // Body (scrollable messages area)
630
+ body.style.flex = '1 1 0%';
631
+ body.style.minHeight = '0';
632
+ body.style.overflowY = 'auto';
633
+
634
+ // Footer (composer) - should not shrink
635
+ footer.style.flexShrink = '0';
636
+ }
637
+
638
+ // Handle positioning classes based on mode
639
+ // First remove all position classes to reset state
640
+ wrapper.classList.remove(
641
+ 'tvw-bottom-6', 'tvw-right-6', 'tvw-left-6', 'tvw-top-6',
642
+ 'tvw-bottom-4', 'tvw-right-4', 'tvw-left-4', 'tvw-top-4'
643
+ );
644
+
645
+ if (!sidebarMode && !isInlineEmbed) {
646
+ // Restore positioning classes when not in sidebar mode (launcher mode only)
647
+ const positionClasses = positionMap[position as keyof typeof positionMap] ?? positionMap['bottom-right'];
648
+ positionClasses.split(' ').forEach(cls => wrapper.classList.add(cls));
649
+ }
650
+
651
+ // Apply sidebar-specific styles
652
+ if (sidebarMode) {
653
+ const sidebarWidth = config.launcher?.sidebarWidth ?? '420px';
654
+
655
+ // Wrapper - fixed position, flush with edges
656
+ wrapper.style.cssText = `
657
+ position: fixed !important;
658
+ top: 0 !important;
659
+ bottom: 0 !important;
660
+ width: ${sidebarWidth} !important;
661
+ height: 100vh !important;
662
+ max-height: 100vh !important;
663
+ margin: 0 !important;
664
+ padding: 0 !important;
665
+ display: flex !important;
666
+ flex-direction: column !important;
667
+ ${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
668
+ `;
669
+
670
+ // Panel - fill wrapper (override inline width/max-width from panel.ts)
671
+ // Box-shadow is on panel to avoid rendering artifacts with container's overflow:hidden
672
+ // Border-radius on panel ensures shadow follows rounded corners
673
+ panel.style.cssText = `
674
+ position: relative !important;
675
+ display: flex !important;
676
+ flex-direction: column !important;
677
+ flex: 1 1 0% !important;
678
+ width: 100% !important;
679
+ max-width: 100% !important;
680
+ height: 100% !important;
681
+ min-height: 0 !important;
682
+ margin: 0 !important;
683
+ padding: 0 !important;
684
+ box-shadow: ${panelShadow} !important;
685
+ border-radius: ${panelBorderRadius} !important;
686
+ `;
687
+ // Force override any inline width/maxWidth that may be set elsewhere
688
+ panel.style.setProperty('width', '100%', 'important');
689
+ panel.style.setProperty('max-width', '100%', 'important');
690
+
691
+ // Container - apply configurable styles with sidebar layout
692
+ // Note: box-shadow is on panel, not container
693
+ container.style.cssText = `
694
+ display: flex !important;
695
+ flex-direction: column !important;
696
+ flex: 1 1 0% !important;
697
+ width: 100% !important;
698
+ height: 100% !important;
699
+ min-height: 0 !important;
700
+ max-height: 100% !important;
701
+ overflow: hidden !important;
702
+ border-radius: ${panelBorderRadius} !important;
703
+ border: ${panelBorder} !important;
704
+ `;
705
+
706
+ // Remove footer border in sidebar mode
707
+ footer.style.cssText = `
708
+ flex-shrink: 0 !important;
709
+ border-top: none !important;
710
+ padding: 8px 16px 12px 16px !important;
711
+ `;
712
+ }
713
+
714
+ // Apply max-height constraints to wrapper to prevent expanding past viewport top
715
+ // Use both -moz-available (Firefox) and stretch (standard) for cross-browser support
716
+ // Append to cssText to allow multiple fallback values for the same property
717
+ // Only apply to launcher mode (not sidebar or inline embed)
718
+ if (!isInlineEmbed) {
719
+ const maxHeightStyles = 'max-height: -moz-available !important; max-height: stretch !important;';
720
+ const paddingStyles = sidebarMode ? '' : 'padding-top: 1.25em !important;';
721
+ wrapper.style.cssText += maxHeightStyles + paddingStyles;
722
+ }
723
+ };
724
+ applyFullHeightStyles();
725
+ // Apply theme variables after applyFullHeightStyles since it resets mount.style.cssText
726
+ applyThemeVariables(mount, config);
727
+
728
+ const destroyCallbacks: Array<() => void> = [];
729
+
730
+ // Set up theme observer for auto color scheme detection
731
+ let cleanupThemeObserver: (() => void) | null = null;
732
+ const setupThemeObserver = () => {
733
+ // Clean up existing observer if any
734
+ if (cleanupThemeObserver) {
735
+ cleanupThemeObserver();
736
+ cleanupThemeObserver = null;
737
+ }
738
+ // Set up new observer if colorScheme is 'auto'
739
+ if (config.colorScheme === 'auto') {
740
+ cleanupThemeObserver = createThemeObserver(() => {
741
+ // Re-apply theme when color scheme changes
742
+ applyThemeVariables(mount, config);
743
+ });
744
+ }
745
+ };
746
+ setupThemeObserver();
747
+ destroyCallbacks.push(() => {
748
+ if (cleanupThemeObserver) {
749
+ cleanupThemeObserver();
750
+ cleanupThemeObserver = null;
751
+ }
752
+ });
753
+
754
+ const suggestionsManager = createSuggestions(suggestions);
755
+ let closeHandler: (() => void) | null = null;
756
+ let session: AgentWidgetSession;
757
+ let isStreaming = false;
758
+ let shouldAutoScroll = true;
759
+ let lastScrollTop = 0;
760
+ let lastAutoScrollTime = 0;
761
+ let scrollRAF: number | null = null;
762
+ let isAutoScrollBlocked = false;
763
+ let blockUntilTime = 0;
764
+ let isAutoScrolling = false;
765
+
766
+ const AUTO_SCROLL_THROTTLE = 125;
767
+ const AUTO_SCROLL_BLOCK_TIME = 2000;
768
+ const USER_SCROLL_THRESHOLD = 5;
769
+ const BOTTOM_THRESHOLD = 50;
770
+ const messageState = new Map<
771
+ string,
772
+ { streaming?: boolean; role: AgentWidgetMessage["role"] }
773
+ >();
774
+ const voiceState = {
775
+ active: false,
776
+ manuallyDeactivated: false,
777
+ lastUserMessageWasVoice: false
778
+ };
779
+ const voiceAutoResumeMode = config.voiceRecognition?.autoResume ?? false;
780
+ const emitVoiceState = (source: AgentWidgetVoiceStateEvent["source"]) => {
781
+ eventBus.emit("voice:state", {
782
+ active: voiceState.active,
783
+ source,
784
+ timestamp: Date.now()
785
+ });
786
+ };
787
+ const persistVoiceMetadata = () => {
788
+ updateSessionMetadata((prev) => ({
789
+ ...prev,
790
+ voiceState: {
791
+ active: voiceState.active,
792
+ timestamp: Date.now(),
793
+ manuallyDeactivated: voiceState.manuallyDeactivated
794
+ }
795
+ }));
796
+ };
797
+ const maybeRestoreVoiceFromMetadata = () => {
798
+ if (config.voiceRecognition?.enabled === false) return;
799
+ const rawVoiceState = ensureRecord((persistentMetadata as any).voiceState);
800
+ const wasActive = Boolean(rawVoiceState.active);
801
+ const timestamp = Number(rawVoiceState.timestamp ?? 0);
802
+ voiceState.manuallyDeactivated = Boolean(rawVoiceState.manuallyDeactivated);
803
+ if (wasActive && Date.now() - timestamp < VOICE_STATE_RESTORE_WINDOW) {
804
+ setTimeout(() => {
805
+ if (!voiceState.active) {
806
+ voiceState.manuallyDeactivated = false;
807
+ startVoiceRecognition("restore");
808
+ }
809
+ }, 1000);
810
+ }
811
+ };
812
+
813
+ const getMessagesForPersistence = () =>
814
+ session
815
+ ? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
816
+ : [];
817
+
818
+ function persistState(messagesOverride?: AgentWidgetMessage[]) {
819
+ if (!storageAdapter?.save) return;
820
+
821
+ // Allow saving even if session doesn't exist yet (for metadata during init)
822
+ const messages = messagesOverride
823
+ ? stripStreamingFromMessages(messagesOverride)
824
+ : session
825
+ ? getMessagesForPersistence()
826
+ : [];
827
+
828
+ const payload = {
829
+ messages,
830
+ metadata: persistentMetadata
831
+ };
832
+ try {
833
+ const result = storageAdapter.save(payload);
834
+ if (result instanceof Promise) {
835
+ result.catch((error) => {
836
+ if (typeof console !== "undefined") {
837
+ // eslint-disable-next-line no-console
838
+ console.error("[AgentWidget] Failed to persist state:", error);
839
+ }
840
+ });
841
+ }
842
+ } catch (error) {
843
+ if (typeof console !== "undefined") {
844
+ // eslint-disable-next-line no-console
845
+ console.error("[AgentWidget] Failed to persist state:", error);
846
+ }
847
+ }
848
+ }
849
+
850
+ const scheduleAutoScroll = (force = false) => {
851
+ if (!shouldAutoScroll) return;
852
+
853
+ const now = Date.now();
854
+
855
+ if (isAutoScrollBlocked && now < blockUntilTime) {
856
+ if (!force) return;
857
+ }
858
+
859
+ if (isAutoScrollBlocked && now >= blockUntilTime) {
860
+ isAutoScrollBlocked = false;
861
+ }
862
+
863
+ if (!force && !isStreaming) return;
864
+
865
+ if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
866
+ lastAutoScrollTime = now;
867
+
868
+ if (scrollRAF) {
869
+ cancelAnimationFrame(scrollRAF);
870
+ }
871
+
872
+ scrollRAF = requestAnimationFrame(() => {
873
+ if (isAutoScrollBlocked || !shouldAutoScroll) return;
874
+ isAutoScrolling = true;
875
+ body.scrollTop = body.scrollHeight;
876
+ lastScrollTop = body.scrollTop;
877
+ requestAnimationFrame(() => {
878
+ isAutoScrolling = false;
879
+ });
880
+ scrollRAF = null;
881
+ });
882
+ };
883
+
884
+ // Track ongoing smooth scroll animation
885
+ let smoothScrollRAF: number | null = null;
886
+
887
+ // Get the scrollable container using its unique ID
888
+ const getScrollableContainer = (): HTMLElement => {
889
+ // Use the unique ID for reliable selection
890
+ const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
891
+ // Fallback to body if ID not found (shouldn't happen, but safe fallback)
892
+ return scrollable || body;
893
+ };
894
+
895
+ // Custom smooth scroll animation with easing
896
+ const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
897
+ const start = element.scrollTop;
898
+ const clientHeight = element.clientHeight;
899
+ // Recalculate target dynamically to handle layout changes
900
+ let target = element.scrollHeight;
901
+ let distance = target - start;
902
+
903
+ // Check if already at bottom: scrollTop + clientHeight should be >= scrollHeight
904
+ // Add a small threshold (2px) to account for rounding/subpixel differences
905
+ const isAtBottom = start + clientHeight >= target - 2;
906
+
907
+ // If already at bottom or very close, skip animation to prevent glitch
908
+ if (isAtBottom || Math.abs(distance) < 5) {
909
+ return;
910
+ }
911
+
912
+ // Cancel any ongoing smooth scroll animation
913
+ if (smoothScrollRAF !== null) {
914
+ cancelAnimationFrame(smoothScrollRAF);
915
+ smoothScrollRAF = null;
916
+ }
917
+
918
+ const startTime = performance.now();
919
+
920
+ // Easing function: ease-out cubic for smooth deceleration
921
+ const easeOutCubic = (t: number): number => {
922
+ return 1 - Math.pow(1 - t, 3);
923
+ };
924
+
925
+ const animate = (currentTime: number) => {
926
+ // Recalculate target each frame in case scrollHeight changed
927
+ const currentTarget = element.scrollHeight;
928
+ if (currentTarget !== target) {
929
+ target = currentTarget;
930
+ distance = target - start;
931
+ }
932
+
933
+ const elapsed = currentTime - startTime;
934
+ const progress = Math.min(elapsed / duration, 1);
935
+ const eased = easeOutCubic(progress);
936
+
937
+ const currentScroll = start + distance * eased;
938
+ element.scrollTop = currentScroll;
939
+
940
+ if (progress < 1) {
941
+ smoothScrollRAF = requestAnimationFrame(animate);
942
+ } else {
943
+ // Ensure we end exactly at the target
944
+ element.scrollTop = element.scrollHeight;
945
+ smoothScrollRAF = null;
946
+ }
947
+ };
948
+
949
+ smoothScrollRAF = requestAnimationFrame(animate);
950
+ };
951
+
952
+ const trackMessages = (messages: AgentWidgetMessage[]) => {
953
+ const nextState = new Map<
954
+ string,
955
+ { streaming?: boolean; role: AgentWidgetMessage["role"] }
956
+ >();
957
+
958
+ messages.forEach((message) => {
959
+ const previous = messageState.get(message.id);
960
+ nextState.set(message.id, {
961
+ streaming: message.streaming,
962
+ role: message.role
963
+ });
964
+
965
+ if (!previous && message.role === "assistant") {
966
+ eventBus.emit("assistant:message", message);
967
+ }
968
+
969
+ if (
970
+ message.role === "assistant" &&
971
+ previous?.streaming &&
972
+ message.streaming === false
973
+ ) {
974
+ eventBus.emit("assistant:complete", message);
975
+ }
976
+ });
977
+
978
+ messageState.clear();
979
+ nextState.forEach((value, key) => {
980
+ messageState.set(key, value);
981
+ });
982
+ };
983
+
984
+
985
+ // Message rendering with plugin support (implementation)
986
+ const renderMessagesWithPluginsImpl = (
987
+ container: HTMLElement,
988
+ messages: AgentWidgetMessage[],
989
+ transform: MessageTransform
990
+ ) => {
991
+ // Build new content in a temporary container for morphing
992
+ const tempContainer = document.createElement("div");
993
+
994
+ messages.forEach((message) => {
995
+ let bubble: HTMLElement | null = null;
996
+
997
+ // Try plugins first
998
+ const matchingPlugin = plugins.find((p) => {
999
+ if (message.variant === "reasoning" && p.renderReasoning) {
1000
+ return true;
1001
+ }
1002
+ if (message.variant === "tool" && p.renderToolCall) {
1003
+ return true;
1004
+ }
1005
+ if (!message.variant && p.renderMessage) {
1006
+ return true;
1007
+ }
1008
+ return false;
1009
+ });
1010
+
1011
+ // Get message layout config
1012
+ const messageLayoutConfig = config.layout?.messages;
1013
+
1014
+ if (matchingPlugin) {
1015
+ if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
1016
+ if (!showReasoning) return;
1017
+ bubble = matchingPlugin.renderReasoning({
1018
+ message,
1019
+ defaultRenderer: () => createReasoningBubble(message),
1020
+ config
1021
+ });
1022
+ } else if (message.variant === "tool" && message.toolCall && matchingPlugin.renderToolCall) {
1023
+ if (!showToolCalls) return;
1024
+ bubble = matchingPlugin.renderToolCall({
1025
+ message,
1026
+ defaultRenderer: () => createToolBubble(message, config),
1027
+ config
1028
+ });
1029
+ } else if (matchingPlugin.renderMessage) {
1030
+ bubble = matchingPlugin.renderMessage({
1031
+ message,
1032
+ defaultRenderer: () => {
1033
+ const b = createStandardBubble(
1034
+ message,
1035
+ transform,
1036
+ messageLayoutConfig,
1037
+ config.messageActions,
1038
+ messageActionCallbacks
1039
+ );
1040
+ if (message.role !== "user") {
1041
+ enhanceWithForms(b, message, config, session);
1042
+ }
1043
+ return b;
1044
+ },
1045
+ config
1046
+ });
1047
+ }
1048
+ }
1049
+
1050
+ // Check for component directive if no plugin handled it
1051
+ if (!bubble && message.role === "assistant" && !message.variant) {
1052
+ const enableComponentStreaming = config.enableComponentStreaming !== false; // Default to true
1053
+ if (enableComponentStreaming && hasComponentDirective(message)) {
1054
+ const directive = extractComponentDirectiveFromMessage(message);
1055
+ if (directive) {
1056
+ const componentBubble = renderComponentDirective(directive, {
1057
+ config,
1058
+ message,
1059
+ transform
1060
+ });
1061
+ if (componentBubble) {
1062
+ // Wrap component in standard bubble styling
1063
+ const componentWrapper = document.createElement("div");
1064
+ componentWrapper.className = [
1065
+ "vanilla-message-bubble",
1066
+ "tvw-max-w-[85%]",
1067
+ "tvw-rounded-2xl",
1068
+ "tvw-bg-cw-surface",
1069
+ "tvw-border",
1070
+ "tvw-border-cw-message-border",
1071
+ "tvw-p-4"
1072
+ ].join(" ");
1073
+ // Set id for idiomorph matching
1074
+ componentWrapper.id = `bubble-${message.id}`;
1075
+ componentWrapper.setAttribute("data-message-id", message.id);
1076
+
1077
+ // Add text content above component if present (combined text+component response)
1078
+ if (message.content && message.content.trim()) {
1079
+ const textDiv = document.createElement("div");
1080
+ textDiv.className = "tvw-mb-3 tvw-text-sm tvw-leading-relaxed";
1081
+ textDiv.innerHTML = transform({
1082
+ text: message.content,
1083
+ message,
1084
+ streaming: Boolean(message.streaming),
1085
+ raw: message.rawContent
1086
+ });
1087
+ componentWrapper.appendChild(textDiv);
1088
+ }
1089
+
1090
+ componentWrapper.appendChild(componentBubble);
1091
+ bubble = componentWrapper;
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ // Fallback to default rendering if plugin returned null or no plugin matched
1098
+ if (!bubble) {
1099
+ if (message.variant === "reasoning" && message.reasoning) {
1100
+ if (!showReasoning) return;
1101
+ bubble = createReasoningBubble(message);
1102
+ } else if (message.variant === "tool" && message.toolCall) {
1103
+ if (!showToolCalls) return;
1104
+ bubble = createToolBubble(message, config);
1105
+ } else {
1106
+ // Check for custom message renderers in layout config
1107
+ const messageLayoutConfig = config.layout?.messages;
1108
+ if (messageLayoutConfig?.renderUserMessage && message.role === "user") {
1109
+ bubble = messageLayoutConfig.renderUserMessage({
1110
+ message,
1111
+ config,
1112
+ streaming: Boolean(message.streaming)
1113
+ });
1114
+ } else if (messageLayoutConfig?.renderAssistantMessage && message.role === "assistant") {
1115
+ bubble = messageLayoutConfig.renderAssistantMessage({
1116
+ message,
1117
+ config,
1118
+ streaming: Boolean(message.streaming)
1119
+ });
1120
+ } else {
1121
+ bubble = createStandardBubble(
1122
+ message,
1123
+ transform,
1124
+ messageLayoutConfig,
1125
+ config.messageActions,
1126
+ messageActionCallbacks
1127
+ );
1128
+ }
1129
+ if (message.role !== "user" && bubble) {
1130
+ enhanceWithForms(bubble, message, config, session);
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ const wrapper = document.createElement("div");
1136
+ wrapper.className = "tvw-flex";
1137
+ // Set id for idiomorph matching
1138
+ wrapper.id = `wrapper-${message.id}`;
1139
+ wrapper.setAttribute("data-wrapper-id", message.id);
1140
+ if (message.role === "user") {
1141
+ wrapper.classList.add("tvw-justify-end");
1142
+ }
1143
+ wrapper.appendChild(bubble);
1144
+ tempContainer.appendChild(wrapper);
1145
+ });
1146
+
1147
+ // Add standalone typing indicator only if streaming but no assistant message is streaming yet
1148
+ // (This shows while waiting for the stream to start)
1149
+ // Check for ANY streaming assistant message, even if empty (to avoid duplicate bubbles)
1150
+ const hasStreamingAssistantMessage = messages.some(
1151
+ (msg) => msg.role === "assistant" && msg.streaming
1152
+ );
1153
+
1154
+ // Also check if there's a recently completed assistant message (streaming just ended)
1155
+ // This prevents flicker when the message completes but isStreaming hasn't updated yet
1156
+ const lastMessage = messages[messages.length - 1];
1157
+ const hasRecentAssistantResponse = lastMessage?.role === "assistant" && !lastMessage.streaming;
1158
+
1159
+ if (isStreaming && messages.some((msg) => msg.role === "user") && !hasStreamingAssistantMessage && !hasRecentAssistantResponse) {
1160
+ const typingIndicator = createTypingIndicator();
1161
+
1162
+ // Create a bubble wrapper for the typing indicator (similar to assistant messages)
1163
+ const typingBubble = document.createElement("div");
1164
+ typingBubble.className = [
1165
+ "tvw-max-w-[85%]",
1166
+ "tvw-rounded-2xl",
1167
+ "tvw-text-sm",
1168
+ "tvw-leading-relaxed",
1169
+ "tvw-shadow-sm",
1170
+ "tvw-bg-cw-surface",
1171
+ "tvw-border",
1172
+ "tvw-border-cw-message-border",
1173
+ "tvw-text-cw-primary",
1174
+ "tvw-px-5",
1175
+ "tvw-py-3"
1176
+ ].join(" ");
1177
+ typingBubble.setAttribute("data-typing-indicator", "true");
1178
+
1179
+ typingBubble.appendChild(typingIndicator);
1180
+
1181
+ const typingWrapper = document.createElement("div");
1182
+ typingWrapper.className = "tvw-flex";
1183
+ // Set id for idiomorph matching
1184
+ typingWrapper.id = "wrapper-typing-indicator";
1185
+ typingWrapper.setAttribute("data-wrapper-id", "typing-indicator");
1186
+ typingWrapper.appendChild(typingBubble);
1187
+ tempContainer.appendChild(typingWrapper);
1188
+ }
1189
+
1190
+ // Use idiomorph to morph the container contents
1191
+ morphMessages(container, tempContainer);
1192
+ // Defer scroll to next frame for smoother animation and to prevent jolt
1193
+ // This allows the browser to update layout (e.g., typing indicator removal) before scrolling
1194
+ // Use double RAF to ensure layout has fully settled before starting scroll animation
1195
+ // Get the scrollable container using its unique ID (#persona-scroll-container)
1196
+ requestAnimationFrame(() => {
1197
+ requestAnimationFrame(() => {
1198
+ const scrollableContainer = getScrollableContainer();
1199
+ smoothScrollToBottom(scrollableContainer);
1200
+ });
1201
+ });
1202
+ };
1203
+
1204
+ // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
1205
+ const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
1206
+
1207
+ const updateOpenState = () => {
1208
+ if (!launcherEnabled) return;
1209
+ if (open) {
1210
+ wrapper.classList.remove("tvw-pointer-events-none", "tvw-opacity-0");
1211
+ panel.classList.remove("tvw-scale-95", "tvw-opacity-0");
1212
+ panel.classList.add("tvw-scale-100", "tvw-opacity-100");
1213
+ // Hide launcher button when widget is open
1214
+ if (launcherButtonInstance) {
1215
+ launcherButtonInstance.element.style.display = "none";
1216
+ } else if (customLauncherElement) {
1217
+ customLauncherElement.style.display = "none";
1218
+ }
1219
+ } else {
1220
+ wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
1221
+ panel.classList.remove("tvw-scale-100", "tvw-opacity-100");
1222
+ panel.classList.add("tvw-scale-95", "tvw-opacity-0");
1223
+ // Show launcher button when widget is closed
1224
+ if (launcherButtonInstance) {
1225
+ launcherButtonInstance.element.style.display = "";
1226
+ } else if (customLauncherElement) {
1227
+ customLauncherElement.style.display = "";
1228
+ }
1229
+ }
1230
+ };
1231
+
1232
+ const setOpenState = (nextOpen: boolean, source: "user" | "auto" | "api" | "system" = "user") => {
1233
+ if (!launcherEnabled) return;
1234
+ if (open === nextOpen) return;
1235
+
1236
+ const prevOpen = open;
1237
+ open = nextOpen;
1238
+ updateOpenState();
1239
+
1240
+ if (open) {
1241
+ recalcPanelHeight();
1242
+ scheduleAutoScroll(true);
1243
+ }
1244
+
1245
+ // Emit widget state events
1246
+ const stateEvent: AgentWidgetStateEvent = {
1247
+ open,
1248
+ source,
1249
+ timestamp: Date.now()
1250
+ };
1251
+
1252
+ if (open && !prevOpen) {
1253
+ eventBus.emit("widget:opened", stateEvent);
1254
+ } else if (!open && prevOpen) {
1255
+ eventBus.emit("widget:closed", stateEvent);
1256
+ }
1257
+
1258
+ // Emit general state snapshot
1259
+ eventBus.emit("widget:state", {
1260
+ open,
1261
+ launcherEnabled,
1262
+ voiceActive: voiceState.active,
1263
+ streaming: session.isStreaming()
1264
+ });
1265
+ };
1266
+
1267
+ const setComposerDisabled = (disabled: boolean) => {
1268
+ // Keep textarea always enabled so users can type while streaming
1269
+ // Only disable submit controls to prevent sending during streaming
1270
+ sendButton.disabled = disabled;
1271
+ if (micButton) {
1272
+ micButton.disabled = disabled;
1273
+ }
1274
+ suggestionsManager.buttons.forEach((btn) => {
1275
+ btn.disabled = disabled;
1276
+ });
1277
+ };
1278
+
1279
+ const updateCopy = () => {
1280
+ introTitle.textContent = config.copy?.welcomeTitle ?? "Hello 👋";
1281
+ introSubtitle.textContent =
1282
+ config.copy?.welcomeSubtitle ??
1283
+ "Ask anything about your account or products.";
1284
+ textarea.placeholder = config.copy?.inputPlaceholder ?? "How can I help...";
1285
+
1286
+ // Only update send button text if NOT using icon mode
1287
+ const useIcon = config.sendButton?.useIcon ?? false;
1288
+ if (!useIcon) {
1289
+ sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
1290
+ }
1291
+
1292
+ // Update textarea font family and weight
1293
+ const fontFamily = config.theme?.inputFontFamily ?? "sans-serif";
1294
+ const fontWeight = config.theme?.inputFontWeight ?? "400";
1295
+
1296
+ const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
1297
+ switch (family) {
1298
+ case "serif":
1299
+ return 'Georgia, "Times New Roman", Times, serif';
1300
+ case "mono":
1301
+ return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
1302
+ case "sans-serif":
1303
+ default:
1304
+ return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
1305
+ }
1306
+ };
1307
+
1308
+ textarea.style.fontFamily = getFontFamilyValue(fontFamily);
1309
+ textarea.style.fontWeight = fontWeight;
1310
+ };
1311
+
1312
+ // Add session ID persistence callbacks for client token mode
1313
+ // These allow the widget to resume conversations by passing session_id to /client/init
1314
+ if (config.clientToken) {
1315
+ config = {
1316
+ ...config,
1317
+ getStoredSessionId: () => {
1318
+ const storedId = persistentMetadata['session_id'];
1319
+ return typeof storedId === 'string' ? storedId : null;
1320
+ },
1321
+ setStoredSessionId: (sessionId: string) => {
1322
+ updateSessionMetadata((prev) => ({
1323
+ ...prev,
1324
+ session_id: sessionId,
1325
+ }));
1326
+ },
1327
+ };
1328
+ }
1329
+
1330
+ session = new AgentWidgetSession(config, {
1331
+ onMessagesChanged(messages) {
1332
+ renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
1333
+ // Re-render suggestions to hide them after first user message
1334
+ // Pass messages directly to avoid calling session.getMessages() during construction
1335
+ if (session) {
1336
+ const hasUserMessage = messages.some((msg) => msg.role === "user");
1337
+ if (hasUserMessage) {
1338
+ // Hide suggestions if user message exists
1339
+ suggestionsManager.render([], session, textarea, messages);
1340
+ } else {
1341
+ // Show suggestions if no user message yet
1342
+ suggestionsManager.render(config.suggestionChips, session, textarea, messages, config.suggestionChipsConfig);
1343
+ }
1344
+ }
1345
+ scheduleAutoScroll(!isStreaming);
1346
+ trackMessages(messages);
1347
+
1348
+ const lastUserMessage = [...messages]
1349
+ .reverse()
1350
+ .find((msg) => msg.role === "user");
1351
+ voiceState.lastUserMessageWasVoice = Boolean(lastUserMessage?.viaVoice);
1352
+ persistState(messages);
1353
+ },
1354
+ onStatusChanged(status) {
1355
+ const currentStatusConfig = config.statusIndicator ?? {};
1356
+ const getCurrentStatusText = (status: AgentWidgetSessionStatus): string => {
1357
+ if (status === "idle") return currentStatusConfig.idleText ?? statusCopy.idle;
1358
+ if (status === "connecting") return currentStatusConfig.connectingText ?? statusCopy.connecting;
1359
+ if (status === "connected") return currentStatusConfig.connectedText ?? statusCopy.connected;
1360
+ if (status === "error") return currentStatusConfig.errorText ?? statusCopy.error;
1361
+ return statusCopy[status];
1362
+ };
1363
+ statusText.textContent = getCurrentStatusText(status);
1364
+ },
1365
+ onStreamingChanged(streaming) {
1366
+ isStreaming = streaming;
1367
+ setComposerDisabled(streaming);
1368
+ // Re-render messages to show/hide typing indicator
1369
+ if (session) {
1370
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
1371
+ }
1372
+ if (!streaming) {
1373
+ scheduleAutoScroll(true);
1374
+ }
1375
+ }
1376
+ });
1377
+
1378
+ if (pendingStoredState) {
1379
+ pendingStoredState
1380
+ .then((state) => {
1381
+ if (!state) return;
1382
+ if (state.metadata) {
1383
+ persistentMetadata = ensureRecord(state.metadata);
1384
+ actionManager.syncFromMetadata();
1385
+ }
1386
+ if (state.messages?.length) {
1387
+ session.hydrateMessages(state.messages);
1388
+ }
1389
+ })
1390
+ .catch((error) => {
1391
+ if (typeof console !== "undefined") {
1392
+ // eslint-disable-next-line no-console
1393
+ console.error("[AgentWidget] Failed to hydrate stored state:", error);
1394
+ }
1395
+ });
1396
+ }
1397
+
1398
+ const handleSubmit = (event: Event) => {
1399
+ event.preventDefault();
1400
+ const value = textarea.value.trim();
1401
+ const hasAttachments = attachmentManager?.hasAttachments() ?? false;
1402
+
1403
+ // Must have text or attachments to send
1404
+ if (!value && !hasAttachments) return;
1405
+
1406
+ // Build content parts if there are attachments
1407
+ let contentParts: ContentPart[] | undefined;
1408
+ if (hasAttachments) {
1409
+ contentParts = [];
1410
+ // Add image parts first
1411
+ contentParts.push(...attachmentManager!.getContentParts());
1412
+ // Add text part if there's text
1413
+ if (value) {
1414
+ contentParts.push(createTextPart(value));
1415
+ }
1416
+ }
1417
+
1418
+ textarea.value = "";
1419
+ textarea.style.height = "auto"; // Reset height after clearing
1420
+
1421
+ // Send message with optional content parts
1422
+ session.sendMessage(value, { contentParts });
1423
+
1424
+ // Clear attachments after sending
1425
+ if (hasAttachments) {
1426
+ attachmentManager!.clearAttachments();
1427
+ }
1428
+ };
1429
+
1430
+ const handleInputEnter = (event: KeyboardEvent) => {
1431
+ if (event.key === "Enter" && !event.shiftKey) {
1432
+ event.preventDefault();
1433
+ sendButton.click();
1434
+ }
1435
+ };
1436
+
1437
+ // Voice recognition state and logic
1438
+ let speechRecognition: any = null;
1439
+ let isRecording = false;
1440
+ let pauseTimer: number | null = null;
1441
+ let originalMicStyles: {
1442
+ backgroundColor: string;
1443
+ color: string;
1444
+ borderColor: string;
1445
+ } | null = null;
1446
+
1447
+ const getSpeechRecognitionClass = (): any => {
1448
+ if (typeof window === 'undefined') return null;
1449
+ return (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition || null;
1450
+ };
1451
+
1452
+ const startVoiceRecognition = (
1453
+ source: AgentWidgetVoiceStateEvent["source"] = "user"
1454
+ ) => {
1455
+ if (isRecording || session.isStreaming()) return;
1456
+
1457
+ const SpeechRecognitionClass = getSpeechRecognitionClass();
1458
+ if (!SpeechRecognitionClass) return;
1459
+
1460
+ speechRecognition = new SpeechRecognitionClass();
1461
+ const voiceConfig = config.voiceRecognition ?? {};
1462
+ const pauseDuration = voiceConfig.pauseDuration ?? 2000;
1463
+
1464
+ speechRecognition.continuous = true;
1465
+ speechRecognition.interimResults = true;
1466
+ speechRecognition.lang = 'en-US';
1467
+
1468
+ // Store the initial text that was in the textarea
1469
+ const initialText = textarea.value;
1470
+
1471
+ speechRecognition.onresult = (event: any) => {
1472
+ // Build the complete transcript from all results
1473
+ let fullTranscript = "";
1474
+ let interimTranscript = "";
1475
+
1476
+ // Process all results from the beginning
1477
+ for (let i = 0; i < event.results.length; i++) {
1478
+ const result = event.results[i];
1479
+ const transcript = result[0].transcript;
1480
+
1481
+ if (result.isFinal) {
1482
+ fullTranscript += transcript + " ";
1483
+ } else {
1484
+ // Only take the last interim result
1485
+ interimTranscript = transcript;
1486
+ }
1487
+ }
1488
+
1489
+ // Update textarea with initial text + full transcript + interim
1490
+ const newValue = initialText + fullTranscript + interimTranscript;
1491
+ textarea.value = newValue;
1492
+
1493
+ // Reset pause timer on each result
1494
+ if (pauseTimer) {
1495
+ clearTimeout(pauseTimer);
1496
+ }
1497
+
1498
+ // Set timer to auto-submit after pause when we have any speech
1499
+ if (fullTranscript || interimTranscript) {
1500
+ pauseTimer = window.setTimeout(() => {
1501
+ const finalValue = textarea.value.trim();
1502
+ if (finalValue && speechRecognition && isRecording) {
1503
+ stopVoiceRecognition();
1504
+ textarea.value = "";
1505
+ textarea.style.height = "auto"; // Reset height after clearing
1506
+ session.sendMessage(finalValue, { viaVoice: true });
1507
+ }
1508
+ }, pauseDuration);
1509
+ }
1510
+ };
1511
+
1512
+ speechRecognition.onerror = (event: any) => {
1513
+ // Don't stop on "no-speech" error, just ignore it
1514
+ if (event.error !== 'no-speech') {
1515
+ stopVoiceRecognition();
1516
+ }
1517
+ };
1518
+
1519
+ speechRecognition.onend = () => {
1520
+ // If recognition ended naturally (not manually stopped), submit if there's text
1521
+ if (isRecording) {
1522
+ const finalValue = textarea.value.trim();
1523
+ if (finalValue && finalValue !== initialText.trim()) {
1524
+ textarea.value = "";
1525
+ textarea.style.height = "auto"; // Reset height after clearing
1526
+ session.sendMessage(finalValue, { viaVoice: true });
1527
+ }
1528
+ stopVoiceRecognition();
1529
+ }
1530
+ };
1531
+
1532
+ try {
1533
+ speechRecognition.start();
1534
+ isRecording = true;
1535
+ voiceState.active = true;
1536
+ if (source !== "system") {
1537
+ voiceState.manuallyDeactivated = false;
1538
+ }
1539
+ emitVoiceState(source);
1540
+ persistVoiceMetadata();
1541
+ if (micButton) {
1542
+ // Store original styles
1543
+ originalMicStyles = {
1544
+ backgroundColor: micButton.style.backgroundColor,
1545
+ color: micButton.style.color,
1546
+ borderColor: micButton.style.borderColor
1547
+ };
1548
+
1549
+ // Apply recording state styles from config
1550
+ const voiceConfig = config.voiceRecognition ?? {};
1551
+ const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
1552
+ const recordingIconColor = voiceConfig.recordingIconColor;
1553
+ const recordingBorderColor = voiceConfig.recordingBorderColor;
1554
+
1555
+ micButton.classList.add("tvw-voice-recording");
1556
+ micButton.style.backgroundColor = recordingBackgroundColor;
1557
+
1558
+ if (recordingIconColor) {
1559
+ micButton.style.color = recordingIconColor;
1560
+ // Update SVG stroke color if present
1561
+ const svg = micButton.querySelector("svg");
1562
+ if (svg) {
1563
+ svg.setAttribute("stroke", recordingIconColor);
1564
+ }
1565
+ }
1566
+
1567
+ if (recordingBorderColor) {
1568
+ micButton.style.borderColor = recordingBorderColor;
1569
+ }
1570
+
1571
+ micButton.setAttribute("aria-label", "Stop voice recognition");
1572
+ }
1573
+ } catch (error) {
1574
+ stopVoiceRecognition("system");
1575
+ }
1576
+ };
1577
+
1578
+ const stopVoiceRecognition = (
1579
+ source: AgentWidgetVoiceStateEvent["source"] = "user"
1580
+ ) => {
1581
+ if (!isRecording) return;
1582
+
1583
+ isRecording = false;
1584
+ if (pauseTimer) {
1585
+ clearTimeout(pauseTimer);
1586
+ pauseTimer = null;
1587
+ }
1588
+
1589
+ if (speechRecognition) {
1590
+ try {
1591
+ speechRecognition.stop();
1592
+ } catch (error) {
1593
+ // Ignore errors when stopping
1594
+ }
1595
+ speechRecognition = null;
1596
+ }
1597
+
1598
+ voiceState.active = false;
1599
+ emitVoiceState(source);
1600
+ persistVoiceMetadata();
1601
+
1602
+ if (micButton) {
1603
+ micButton.classList.remove("tvw-voice-recording");
1604
+
1605
+ // Restore original styles
1606
+ if (originalMicStyles) {
1607
+ micButton.style.backgroundColor = originalMicStyles.backgroundColor;
1608
+ micButton.style.color = originalMicStyles.color;
1609
+ micButton.style.borderColor = originalMicStyles.borderColor;
1610
+
1611
+ // Restore SVG stroke color if present
1612
+ const svg = micButton.querySelector("svg");
1613
+ if (svg) {
1614
+ svg.setAttribute("stroke", originalMicStyles.color || "currentColor");
1615
+ }
1616
+
1617
+ originalMicStyles = null;
1618
+ }
1619
+
1620
+ micButton.setAttribute("aria-label", "Start voice recognition");
1621
+ }
1622
+ };
1623
+
1624
+ // Function to create mic button dynamically
1625
+ const createMicButton = (voiceConfig: AgentWidgetConfig['voiceRecognition'], sendButtonConfig: AgentWidgetConfig['sendButton']): { micButton: HTMLButtonElement; micButtonWrapper: HTMLElement } | null => {
1626
+ const hasSpeechRecognition =
1627
+ typeof window !== 'undefined' &&
1628
+ (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
1629
+ typeof (window as any).SpeechRecognition !== 'undefined');
1630
+
1631
+ if (!hasSpeechRecognition) return null;
1632
+
1633
+ const micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
1634
+ const micButton = createElement(
1635
+ "button",
1636
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
1637
+ ) as HTMLButtonElement;
1638
+
1639
+ micButton.type = "button";
1640
+ micButton.setAttribute("aria-label", "Start voice recognition");
1641
+
1642
+ const micIconName = voiceConfig?.iconName ?? "mic";
1643
+ const buttonSize = sendButtonConfig?.size ?? "40px";
1644
+ const micIconSize = voiceConfig?.iconSize ?? buttonSize;
1645
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
1646
+
1647
+ // Use dedicated colors from voice recognition config, fallback to send button colors
1648
+ const backgroundColor = voiceConfig?.backgroundColor ?? sendButtonConfig?.backgroundColor;
1649
+ const iconColor = voiceConfig?.iconColor ?? sendButtonConfig?.textColor;
1650
+
1651
+ micButton.style.width = micIconSize;
1652
+ micButton.style.height = micIconSize;
1653
+ micButton.style.minWidth = micIconSize;
1654
+ micButton.style.minHeight = micIconSize;
1655
+ micButton.style.fontSize = "18px";
1656
+ micButton.style.lineHeight = "1";
1657
+
1658
+ // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
1659
+ const iconColorValue = iconColor || "currentColor";
1660
+ const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
1661
+ if (micIconSvg) {
1662
+ micButton.appendChild(micIconSvg);
1663
+ micButton.style.color = iconColorValue;
1664
+ } else {
1665
+ // Fallback to text if icon fails
1666
+ micButton.textContent = "🎤";
1667
+ micButton.style.color = iconColorValue;
1668
+ }
1669
+
1670
+ // Apply background color
1671
+ if (backgroundColor) {
1672
+ micButton.style.backgroundColor = backgroundColor;
1673
+ } else {
1674
+ micButton.classList.add("tvw-bg-cw-primary");
1675
+ }
1676
+
1677
+ // Apply icon/text color
1678
+ if (iconColor) {
1679
+ micButton.style.color = iconColor;
1680
+ } else if (!iconColor && !sendButtonConfig?.textColor) {
1681
+ micButton.classList.add("tvw-text-white");
1682
+ }
1683
+
1684
+ // Apply border styling
1685
+ if (voiceConfig?.borderWidth) {
1686
+ micButton.style.borderWidth = voiceConfig.borderWidth;
1687
+ micButton.style.borderStyle = "solid";
1688
+ }
1689
+ if (voiceConfig?.borderColor) {
1690
+ micButton.style.borderColor = voiceConfig.borderColor;
1691
+ }
1692
+
1693
+ // Apply padding styling
1694
+ if (voiceConfig?.paddingX) {
1695
+ micButton.style.paddingLeft = voiceConfig.paddingX;
1696
+ micButton.style.paddingRight = voiceConfig.paddingX;
1697
+ }
1698
+ if (voiceConfig?.paddingY) {
1699
+ micButton.style.paddingTop = voiceConfig.paddingY;
1700
+ micButton.style.paddingBottom = voiceConfig.paddingY;
1701
+ }
1702
+
1703
+ micButtonWrapper.appendChild(micButton);
1704
+
1705
+ // Add tooltip if enabled
1706
+ const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
1707
+ const showTooltip = voiceConfig?.showTooltip ?? false;
1708
+ if (showTooltip && tooltipText) {
1709
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
1710
+ tooltip.textContent = tooltipText;
1711
+ micButtonWrapper.appendChild(tooltip);
1712
+ }
1713
+
1714
+ return { micButton, micButtonWrapper };
1715
+ };
1716
+
1717
+ // Wire up mic button click handler
1718
+ const handleMicButtonClick = () => {
1719
+ if (isRecording) {
1720
+ // Stop recording and submit
1721
+ const finalValue = textarea.value.trim();
1722
+ voiceState.manuallyDeactivated = true;
1723
+ persistVoiceMetadata();
1724
+ stopVoiceRecognition("user");
1725
+ if (finalValue) {
1726
+ textarea.value = "";
1727
+ textarea.style.height = "auto"; // Reset height after clearing
1728
+ session.sendMessage(finalValue);
1729
+ }
1730
+ } else {
1731
+ // Start recording
1732
+ voiceState.manuallyDeactivated = false;
1733
+ persistVoiceMetadata();
1734
+ startVoiceRecognition("user");
1735
+ }
1736
+ };
1737
+
1738
+ if (micButton) {
1739
+ micButton.addEventListener("click", handleMicButtonClick);
1740
+
1741
+ destroyCallbacks.push(() => {
1742
+ stopVoiceRecognition("system");
1743
+ if (micButton) {
1744
+ micButton.removeEventListener("click", handleMicButtonClick);
1745
+ }
1746
+ });
1747
+ }
1748
+
1749
+ const autoResumeUnsub = eventBus.on("assistant:complete", () => {
1750
+ if (!voiceAutoResumeMode) return;
1751
+ if (voiceState.active || voiceState.manuallyDeactivated) return;
1752
+ if (voiceAutoResumeMode === "assistant" && !voiceState.lastUserMessageWasVoice) {
1753
+ return;
1754
+ }
1755
+ setTimeout(() => {
1756
+ if (!voiceState.active && !voiceState.manuallyDeactivated) {
1757
+ startVoiceRecognition("auto");
1758
+ }
1759
+ }, 600);
1760
+ });
1761
+ destroyCallbacks.push(autoResumeUnsub);
1762
+
1763
+ const toggleOpen = () => {
1764
+ setOpenState(!open, "user");
1765
+ };
1766
+
1767
+ // Plugin hook: renderLauncher - allow plugins to provide custom launcher
1768
+ let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
1769
+ let customLauncherElement: HTMLElement | null = null;
1770
+
1771
+ if (launcherEnabled) {
1772
+ const launcherPlugin = plugins.find(p => p.renderLauncher);
1773
+ if (launcherPlugin?.renderLauncher) {
1774
+ const customLauncher = launcherPlugin.renderLauncher({
1775
+ config,
1776
+ defaultRenderer: () => {
1777
+ const btn = createLauncherButton(config, toggleOpen);
1778
+ return btn.element;
1779
+ },
1780
+ onToggle: toggleOpen
1781
+ });
1782
+ if (customLauncher) {
1783
+ customLauncherElement = customLauncher;
1784
+ }
1785
+ }
1786
+
1787
+ // Use custom launcher if provided, otherwise use default
1788
+ if (!customLauncherElement) {
1789
+ launcherButtonInstance = createLauncherButton(config, toggleOpen);
1790
+ }
1791
+ }
1792
+
1793
+ if (launcherButtonInstance) {
1794
+ mount.appendChild(launcherButtonInstance.element);
1795
+ } else if (customLauncherElement) {
1796
+ mount.appendChild(customLauncherElement);
1797
+ }
1798
+ updateOpenState();
1799
+ suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
1800
+ updateCopy();
1801
+ setComposerDisabled(session.isStreaming());
1802
+ scheduleAutoScroll(true);
1803
+ maybeRestoreVoiceFromMetadata();
1804
+
1805
+ const recalcPanelHeight = () => {
1806
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
1807
+ const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
1808
+
1809
+ if (!launcherEnabled) {
1810
+ panel.style.height = "";
1811
+ panel.style.width = "";
1812
+ return;
1813
+ }
1814
+
1815
+ // In sidebar/fullHeight mode, don't override the width - it's handled by applyFullHeightStyles
1816
+ if (!sidebarMode) {
1817
+ const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
1818
+ const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
1819
+ panel.style.width = width;
1820
+ panel.style.maxWidth = width;
1821
+ }
1822
+
1823
+ // In fullHeight mode, don't set a fixed height
1824
+ if (!fullHeight) {
1825
+ const viewportHeight = window.innerHeight;
1826
+ const verticalMargin = 64; // leave space for launcher's offset
1827
+ const heightOffset = config.launcher?.heightOffset ?? 0;
1828
+ const available = Math.max(200, viewportHeight - verticalMargin);
1829
+ const clamped = Math.min(640, available);
1830
+ const finalHeight = Math.max(200, clamped - heightOffset);
1831
+ panel.style.height = `${finalHeight}px`;
1832
+ }
1833
+ };
1834
+
1835
+ recalcPanelHeight();
1836
+ window.addEventListener("resize", recalcPanelHeight);
1837
+ destroyCallbacks.push(() => window.removeEventListener("resize", recalcPanelHeight));
1838
+
1839
+ lastScrollTop = body.scrollTop;
1840
+
1841
+ const handleScroll = () => {
1842
+ const scrollTop = body.scrollTop;
1843
+ const scrollHeight = body.scrollHeight;
1844
+ const clientHeight = body.clientHeight;
1845
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
1846
+ const delta = Math.abs(scrollTop - lastScrollTop);
1847
+ lastScrollTop = scrollTop;
1848
+
1849
+ if (isAutoScrolling) return;
1850
+ if (delta <= USER_SCROLL_THRESHOLD) return;
1851
+
1852
+ if (!shouldAutoScroll && distanceFromBottom < BOTTOM_THRESHOLD) {
1853
+ isAutoScrollBlocked = false;
1854
+ shouldAutoScroll = true;
1855
+ return;
1856
+ }
1857
+
1858
+ if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
1859
+ isAutoScrollBlocked = true;
1860
+ blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
1861
+ shouldAutoScroll = false;
1862
+ }
1863
+ };
1864
+
1865
+ body.addEventListener("scroll", handleScroll, { passive: true });
1866
+ destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
1867
+ destroyCallbacks.push(() => {
1868
+ if (scrollRAF) cancelAnimationFrame(scrollRAF);
1869
+ });
1870
+
1871
+ const refreshCloseButton = () => {
1872
+ if (!closeButton) return;
1873
+ if (closeHandler) {
1874
+ closeButton.removeEventListener("click", closeHandler);
1875
+ closeHandler = null;
1876
+ }
1877
+ if (launcherEnabled) {
1878
+ closeButton.style.display = "";
1879
+ closeHandler = () => {
1880
+ open = false;
1881
+ updateOpenState();
1882
+ };
1883
+ closeButton.addEventListener("click", closeHandler);
1884
+ } else {
1885
+ closeButton.style.display = "none";
1886
+ }
1887
+ };
1888
+
1889
+ refreshCloseButton();
1890
+
1891
+ // Setup clear chat button click handler
1892
+ const setupClearChatButton = () => {
1893
+ const { clearChatButton } = panelElements;
1894
+ if (!clearChatButton) return;
1895
+
1896
+ clearChatButton.addEventListener("click", () => {
1897
+ // Clear messages in session (this will trigger onMessagesChanged which re-renders)
1898
+ session.clearMessages();
1899
+
1900
+ // Always clear the default localStorage key
1901
+ try {
1902
+ localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
1903
+ if (config.debug) {
1904
+ console.log(`[AgentWidget] Cleared default localStorage key: ${DEFAULT_CHAT_HISTORY_STORAGE_KEY}`);
1905
+ }
1906
+ } catch (error) {
1907
+ console.error("[AgentWidget] Failed to clear default localStorage:", error);
1908
+ }
1909
+
1910
+ // Also clear custom localStorage key if configured
1911
+ if (config.clearChatHistoryStorageKey && config.clearChatHistoryStorageKey !== DEFAULT_CHAT_HISTORY_STORAGE_KEY) {
1912
+ try {
1913
+ localStorage.removeItem(config.clearChatHistoryStorageKey);
1914
+ if (config.debug) {
1915
+ console.log(`[AgentWidget] Cleared custom localStorage key: ${config.clearChatHistoryStorageKey}`);
1916
+ }
1917
+ } catch (error) {
1918
+ console.error("[AgentWidget] Failed to clear custom localStorage:", error);
1919
+ }
1920
+ }
1921
+
1922
+ // Dispatch custom event for external handlers (e.g., localStorage clearing in examples)
1923
+ const clearEvent = new CustomEvent("persona:clear-chat", {
1924
+ detail: { timestamp: new Date().toISOString() }
1925
+ });
1926
+ window.dispatchEvent(clearEvent);
1927
+
1928
+ if (storageAdapter?.clear) {
1929
+ try {
1930
+ const result = storageAdapter.clear();
1931
+ if (result instanceof Promise) {
1932
+ result.catch((error) => {
1933
+ if (typeof console !== "undefined") {
1934
+ // eslint-disable-next-line no-console
1935
+ console.error("[AgentWidget] Failed to clear storage adapter:", error);
1936
+ }
1937
+ });
1938
+ }
1939
+ } catch (error) {
1940
+ if (typeof console !== "undefined") {
1941
+ // eslint-disable-next-line no-console
1942
+ console.error("[AgentWidget] Failed to clear storage adapter:", error);
1943
+ }
1944
+ }
1945
+ }
1946
+ persistentMetadata = {};
1947
+ actionManager.syncFromMetadata();
1948
+ });
1949
+ };
1950
+
1951
+ setupClearChatButton();
1952
+
1953
+ composerForm.addEventListener("submit", handleSubmit);
1954
+ textarea.addEventListener("keydown", handleInputEnter);
1955
+
1956
+ destroyCallbacks.push(() => {
1957
+ composerForm.removeEventListener("submit", handleSubmit);
1958
+ textarea.removeEventListener("keydown", handleInputEnter);
1959
+ });
1960
+
1961
+ destroyCallbacks.push(() => {
1962
+ session.cancel();
1963
+ });
1964
+
1965
+ if (launcherButtonInstance) {
1966
+ destroyCallbacks.push(() => {
1967
+ launcherButtonInstance?.destroy();
1968
+ });
1969
+ } else if (customLauncherElement) {
1970
+ destroyCallbacks.push(() => {
1971
+ customLauncherElement?.remove();
1972
+ });
1973
+ }
1974
+
1975
+ const controller: Controller = {
1976
+ update(nextConfig: AgentWidgetConfig) {
1977
+ const previousToolCallConfig = config.toolCall;
1978
+ const previousColorScheme = config.colorScheme;
1979
+ config = { ...config, ...nextConfig };
1980
+ // applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
1981
+ applyFullHeightStyles();
1982
+ applyThemeVariables(mount, config);
1983
+
1984
+ // Re-setup theme observer if colorScheme changed
1985
+ if (config.colorScheme !== previousColorScheme) {
1986
+ setupThemeObserver();
1987
+ }
1988
+
1989
+ // Update plugins
1990
+ const newPlugins = pluginRegistry.getForInstance(config.plugins);
1991
+ plugins.length = 0;
1992
+ plugins.push(...newPlugins);
1993
+
1994
+ launcherEnabled = config.launcher?.enabled ?? true;
1995
+ autoExpand = config.launcher?.autoExpand ?? false;
1996
+ showReasoning = config.features?.showReasoning ?? true;
1997
+ showToolCalls = config.features?.showToolCalls ?? true;
1998
+
1999
+ if (config.launcher?.enabled === false && launcherButtonInstance) {
2000
+ launcherButtonInstance.destroy();
2001
+ launcherButtonInstance = null;
2002
+ }
2003
+ if (config.launcher?.enabled === false && customLauncherElement) {
2004
+ customLauncherElement.remove();
2005
+ customLauncherElement = null;
2006
+ }
2007
+
2008
+ if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
2009
+ // Check for launcher plugin when re-enabling
2010
+ const launcherPlugin = plugins.find(p => p.renderLauncher);
2011
+ if (launcherPlugin?.renderLauncher) {
2012
+ const customLauncher = launcherPlugin.renderLauncher({
2013
+ config,
2014
+ defaultRenderer: () => {
2015
+ const btn = createLauncherButton(config, toggleOpen);
2016
+ return btn.element;
2017
+ },
2018
+ onToggle: toggleOpen
2019
+ });
2020
+ if (customLauncher) {
2021
+ customLauncherElement = customLauncher;
2022
+ mount.appendChild(customLauncherElement);
2023
+ }
2024
+ }
2025
+ if (!customLauncherElement) {
2026
+ launcherButtonInstance = createLauncherButton(config, toggleOpen);
2027
+ mount.appendChild(launcherButtonInstance.element);
2028
+ }
2029
+ }
2030
+
2031
+ if (launcherButtonInstance) {
2032
+ launcherButtonInstance.update(config);
2033
+ }
2034
+ // Note: Custom launcher updates are handled by the plugin's own logic
2035
+
2036
+ // Update panel header title and subtitle
2037
+ if (headerTitle && config.launcher?.title !== undefined) {
2038
+ headerTitle.textContent = config.launcher.title;
2039
+ }
2040
+ if (headerSubtitle && config.launcher?.subtitle !== undefined) {
2041
+ headerSubtitle.textContent = config.launcher.subtitle;
2042
+ }
2043
+
2044
+ // Update header layout if it changed
2045
+ const headerLayoutConfig = config.layout?.header;
2046
+ const headerLayoutChanged = headerLayoutConfig?.layout !== prevHeaderLayout;
2047
+
2048
+ if (headerLayoutChanged && header) {
2049
+ // Rebuild header with new layout
2050
+ const newHeaderElements = headerLayoutConfig
2051
+ ? buildHeaderWithLayout(config, headerLayoutConfig, {
2052
+ showClose: launcherEnabled,
2053
+ onClose: () => setOpenState(false, "user")
2054
+ })
2055
+ : buildHeader({
2056
+ config,
2057
+ showClose: launcherEnabled,
2058
+ onClose: () => setOpenState(false, "user")
2059
+ });
2060
+
2061
+ // Replace the old header with the new one
2062
+ header.replaceWith(newHeaderElements.header);
2063
+
2064
+ // Update references
2065
+ header = newHeaderElements.header;
2066
+ iconHolder = newHeaderElements.iconHolder;
2067
+ headerTitle = newHeaderElements.headerTitle;
2068
+ headerSubtitle = newHeaderElements.headerSubtitle;
2069
+ closeButton = newHeaderElements.closeButton;
2070
+
2071
+ prevHeaderLayout = headerLayoutConfig?.layout;
2072
+ } else if (headerLayoutConfig) {
2073
+ // Apply visibility settings without rebuilding
2074
+ if (iconHolder) {
2075
+ iconHolder.style.display = headerLayoutConfig.showIcon === false ? "none" : "";
2076
+ }
2077
+ if (headerTitle) {
2078
+ headerTitle.style.display = headerLayoutConfig.showTitle === false ? "none" : "";
2079
+ }
2080
+ if (headerSubtitle) {
2081
+ headerSubtitle.style.display = headerLayoutConfig.showSubtitle === false ? "none" : "";
2082
+ }
2083
+ if (closeButton) {
2084
+ closeButton.style.display = headerLayoutConfig.showCloseButton === false ? "none" : "";
2085
+ }
2086
+ if (panelElements.clearChatButtonWrapper) {
2087
+ // showClearChat explicitly controls visibility when set
2088
+ const showClearChat = headerLayoutConfig.showClearChat;
2089
+ if (showClearChat !== undefined) {
2090
+ panelElements.clearChatButtonWrapper.style.display = showClearChat ? "" : "none";
2091
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned
2092
+ const { closeButtonWrapper } = panelElements;
2093
+ if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
2094
+ if (showClearChat) {
2095
+ closeButtonWrapper.classList.remove("tvw-ml-auto");
2096
+ } else {
2097
+ closeButtonWrapper.classList.add("tvw-ml-auto");
2098
+ }
2099
+ }
2100
+ }
2101
+ }
2102
+ }
2103
+
2104
+ // Update header visibility based on layout.showHeader
2105
+ const showHeader = config.layout?.showHeader !== false; // default to true
2106
+ if (header) {
2107
+ header.style.display = showHeader ? "" : "none";
2108
+ }
2109
+
2110
+ // Update footer visibility based on layout.showFooter
2111
+ const showFooter = config.layout?.showFooter !== false; // default to true
2112
+ if (footer) {
2113
+ footer.style.display = showFooter ? "" : "none";
2114
+ }
2115
+
2116
+ // Only update open state if launcher enabled state changed or autoExpand value changed
2117
+ const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
2118
+ const autoExpandChanged = autoExpand !== prevAutoExpand;
2119
+
2120
+ if (launcherEnabledChanged) {
2121
+ // Launcher was enabled/disabled - update state accordingly
2122
+ if (!launcherEnabled) {
2123
+ // When launcher is disabled, always keep panel open
2124
+ open = true;
2125
+ updateOpenState();
2126
+ } else {
2127
+ // Launcher was just enabled - respect autoExpand setting
2128
+ setOpenState(autoExpand, "auto");
2129
+ }
2130
+ } else if (autoExpandChanged) {
2131
+ // autoExpand value changed - update state to match
2132
+ setOpenState(autoExpand, "auto");
2133
+ }
2134
+ // Otherwise, preserve current open state (user may have manually opened/closed)
2135
+
2136
+ // Update previous values for next comparison
2137
+ prevAutoExpand = autoExpand;
2138
+ prevLauncherEnabled = launcherEnabled;
2139
+ recalcPanelHeight();
2140
+ refreshCloseButton();
2141
+
2142
+ // Re-render messages if toolCall config changed (to apply new styles)
2143
+ const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
2144
+ if (toolCallConfigChanged && session) {
2145
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
2146
+ }
2147
+
2148
+ // Update panel icon sizes
2149
+ const launcher = config.launcher ?? {};
2150
+ const headerIconHidden = launcher.headerIconHidden ?? false;
2151
+ const layoutShowIcon = config.layout?.header?.showIcon;
2152
+ // Hide icon if either headerIconHidden is true OR layout.header.showIcon is false
2153
+ const shouldHideIcon = headerIconHidden || layoutShowIcon === false;
2154
+ const headerIconName = launcher.headerIconName;
2155
+ const headerIconSize = launcher.headerIconSize ?? "48px";
2156
+
2157
+ if (iconHolder) {
2158
+ const headerEl = container.querySelector(".tvw-border-b-cw-divider");
2159
+ const headerCopy = headerEl?.querySelector(".tvw-flex-col");
2160
+
2161
+ // Handle hide/show
2162
+ if (shouldHideIcon) {
2163
+ // Hide iconHolder
2164
+ iconHolder.style.display = "none";
2165
+ // Ensure headerCopy is still in header
2166
+ if (headerEl && headerCopy && !headerEl.contains(headerCopy)) {
2167
+ headerEl.insertBefore(headerCopy, headerEl.firstChild);
2168
+ }
2169
+ } else {
2170
+ // Show iconHolder
2171
+ iconHolder.style.display = "";
2172
+ iconHolder.style.height = headerIconSize;
2173
+ iconHolder.style.width = headerIconSize;
2174
+
2175
+ // Ensure iconHolder is before headerCopy in header
2176
+ if (headerEl && headerCopy) {
2177
+ if (!headerEl.contains(iconHolder)) {
2178
+ headerEl.insertBefore(iconHolder, headerCopy);
2179
+ } else if (iconHolder.nextSibling !== headerCopy) {
2180
+ // Reorder if needed
2181
+ iconHolder.remove();
2182
+ headerEl.insertBefore(iconHolder, headerCopy);
2183
+ }
2184
+ }
2185
+
2186
+ // Update icon content based on priority: Lucide icon > iconUrl > agentIconText
2187
+ if (headerIconName) {
2188
+ // Use Lucide icon
2189
+ const iconSize = parseFloat(headerIconSize) || 24;
2190
+ const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
2191
+ if (iconSvg) {
2192
+ iconHolder.replaceChildren(iconSvg);
2193
+ } else {
2194
+ // Fallback to agentIconText if Lucide icon fails
2195
+ iconHolder.textContent = launcher.agentIconText ?? "💬";
2196
+ }
2197
+ } else if (launcher.iconUrl) {
2198
+ // Use image URL
2199
+ const img = iconHolder.querySelector("img");
2200
+ if (img) {
2201
+ img.src = launcher.iconUrl;
2202
+ img.style.height = headerIconSize;
2203
+ img.style.width = headerIconSize;
2204
+ } else {
2205
+ // Create new img if it doesn't exist
2206
+ const newImg = document.createElement("img");
2207
+ newImg.src = launcher.iconUrl;
2208
+ newImg.alt = "";
2209
+ newImg.className = "tvw-rounded-xl tvw-object-cover";
2210
+ newImg.style.height = headerIconSize;
2211
+ newImg.style.width = headerIconSize;
2212
+ iconHolder.replaceChildren(newImg);
2213
+ }
2214
+ } else {
2215
+ // Use text/emoji - clear any SVG or img first
2216
+ const existingSvg = iconHolder.querySelector("svg");
2217
+ const existingImg = iconHolder.querySelector("img");
2218
+ if (existingSvg || existingImg) {
2219
+ iconHolder.replaceChildren();
2220
+ }
2221
+ iconHolder.textContent = launcher.agentIconText ?? "💬";
2222
+ }
2223
+
2224
+ // Update image size if present
2225
+ const img = iconHolder.querySelector("img");
2226
+ if (img) {
2227
+ img.style.height = headerIconSize;
2228
+ img.style.width = headerIconSize;
2229
+ }
2230
+ }
2231
+ }
2232
+
2233
+ // Handle title/subtitle visibility from layout config
2234
+ const layoutShowTitle = config.layout?.header?.showTitle;
2235
+ const layoutShowSubtitle = config.layout?.header?.showSubtitle;
2236
+ if (headerTitle) {
2237
+ headerTitle.style.display = layoutShowTitle === false ? "none" : "";
2238
+ }
2239
+ if (headerSubtitle) {
2240
+ headerSubtitle.style.display = layoutShowSubtitle === false ? "none" : "";
2241
+ }
2242
+
2243
+ if (closeButton) {
2244
+ // Handle close button visibility from layout config
2245
+ const layoutShowCloseButton = config.layout?.header?.showCloseButton;
2246
+ if (layoutShowCloseButton === false) {
2247
+ closeButton.style.display = "none";
2248
+ } else {
2249
+ closeButton.style.display = "";
2250
+ }
2251
+
2252
+ const closeButtonSize = launcher.closeButtonSize ?? "32px";
2253
+ const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
2254
+ closeButton.style.height = closeButtonSize;
2255
+ closeButton.style.width = closeButtonSize;
2256
+
2257
+ // Update placement if changed - move the wrapper (not just the button) to preserve tooltip
2258
+ const { closeButtonWrapper } = panelElements;
2259
+ const isTopRight = closeButtonPlacement === "top-right";
2260
+ const currentlyTopRight = closeButtonWrapper?.classList.contains("tvw-absolute");
2261
+
2262
+ if (closeButtonWrapper && isTopRight !== currentlyTopRight) {
2263
+ // Placement changed - need to move wrapper and update classes
2264
+ closeButtonWrapper.remove();
2265
+
2266
+ // Update wrapper classes
2267
+ if (isTopRight) {
2268
+ closeButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50";
2269
+ container.style.position = "relative";
2270
+ container.appendChild(closeButtonWrapper);
2271
+ } else {
2272
+ // Check if clear chat is inline to determine if we need ml-auto
2273
+ const clearChatPlacement = launcher.clearChat?.placement ?? "inline";
2274
+ const clearChatEnabled = launcher.clearChat?.enabled ?? true;
2275
+ closeButtonWrapper.className = (clearChatEnabled && clearChatPlacement === "inline") ? "" : "tvw-ml-auto";
2276
+ // Find header element
2277
+ const header = container.querySelector(".tvw-border-b-cw-divider");
2278
+ if (header) {
2279
+ header.appendChild(closeButtonWrapper);
2280
+ }
2281
+ }
2282
+ }
2283
+
2284
+ // Apply close button styling from config
2285
+ if (launcher.closeButtonColor) {
2286
+ closeButton.style.color = launcher.closeButtonColor;
2287
+ closeButton.classList.remove("tvw-text-cw-muted");
2288
+ } else {
2289
+ closeButton.style.color = "";
2290
+ closeButton.classList.add("tvw-text-cw-muted");
2291
+ }
2292
+
2293
+ if (launcher.closeButtonBackgroundColor) {
2294
+ closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
2295
+ closeButton.classList.remove("hover:tvw-bg-gray-100");
2296
+ } else {
2297
+ closeButton.style.backgroundColor = "";
2298
+ closeButton.classList.add("hover:tvw-bg-gray-100");
2299
+ }
2300
+
2301
+ // Apply border if width and/or color are provided
2302
+ if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
2303
+ const borderWidth = launcher.closeButtonBorderWidth || "0px";
2304
+ const borderColor = launcher.closeButtonBorderColor || "transparent";
2305
+ closeButton.style.border = `${borderWidth} solid ${borderColor}`;
2306
+ closeButton.classList.remove("tvw-border-none");
2307
+ } else {
2308
+ closeButton.style.border = "";
2309
+ closeButton.classList.add("tvw-border-none");
2310
+ }
2311
+
2312
+ if (launcher.closeButtonBorderRadius) {
2313
+ closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
2314
+ closeButton.classList.remove("tvw-rounded-full");
2315
+ } else {
2316
+ closeButton.style.borderRadius = "";
2317
+ closeButton.classList.add("tvw-rounded-full");
2318
+ }
2319
+
2320
+ // Update padding
2321
+ if (launcher.closeButtonPaddingX) {
2322
+ closeButton.style.paddingLeft = launcher.closeButtonPaddingX;
2323
+ closeButton.style.paddingRight = launcher.closeButtonPaddingX;
2324
+ } else {
2325
+ closeButton.style.paddingLeft = "";
2326
+ closeButton.style.paddingRight = "";
2327
+ }
2328
+ if (launcher.closeButtonPaddingY) {
2329
+ closeButton.style.paddingTop = launcher.closeButtonPaddingY;
2330
+ closeButton.style.paddingBottom = launcher.closeButtonPaddingY;
2331
+ } else {
2332
+ closeButton.style.paddingTop = "";
2333
+ closeButton.style.paddingBottom = "";
2334
+ }
2335
+
2336
+ // Update icon
2337
+ const closeButtonIconName = launcher.closeButtonIconName ?? "x";
2338
+ const closeButtonIconText = launcher.closeButtonIconText ?? "×";
2339
+
2340
+ // Clear existing content and render new icon
2341
+ closeButton.innerHTML = "";
2342
+ const iconSvg = renderLucideIcon(closeButtonIconName, "20px", launcher.closeButtonColor || "", 2);
2343
+ if (iconSvg) {
2344
+ closeButton.appendChild(iconSvg);
2345
+ } else {
2346
+ closeButton.textContent = closeButtonIconText;
2347
+ }
2348
+
2349
+ // Update tooltip
2350
+ const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
2351
+ const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
2352
+
2353
+ closeButton.setAttribute("aria-label", closeButtonTooltipText);
2354
+
2355
+ if (closeButtonWrapper) {
2356
+ // Clean up old tooltip event listeners if they exist
2357
+ if ((closeButtonWrapper as any)._cleanupTooltip) {
2358
+ (closeButtonWrapper as any)._cleanupTooltip();
2359
+ delete (closeButtonWrapper as any)._cleanupTooltip;
2360
+ }
2361
+
2362
+ // Set up new portaled tooltip with event listeners
2363
+ if (closeButtonShowTooltip && closeButtonTooltipText) {
2364
+ let portaledTooltip: HTMLElement | null = null;
2365
+
2366
+ const showTooltip = () => {
2367
+ if (portaledTooltip || !closeButton) return; // Already showing or button doesn't exist
2368
+
2369
+ // Create tooltip element
2370
+ portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
2371
+ portaledTooltip.textContent = closeButtonTooltipText;
2372
+
2373
+ // Add arrow
2374
+ const arrow = createElement("div");
2375
+ arrow.className = "tvw-clear-chat-tooltip-arrow";
2376
+ portaledTooltip.appendChild(arrow);
2377
+
2378
+ // Get button position
2379
+ const buttonRect = closeButton.getBoundingClientRect();
2380
+
2381
+ // Position tooltip above button
2382
+ portaledTooltip.style.position = "fixed";
2383
+ portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
2384
+ portaledTooltip.style.top = `${buttonRect.top - 8}px`;
2385
+ portaledTooltip.style.transform = "translate(-50%, -100%)";
2386
+
2387
+ // Append to body
2388
+ document.body.appendChild(portaledTooltip);
2389
+ };
2390
+
2391
+ const hideTooltip = () => {
2392
+ if (portaledTooltip && portaledTooltip.parentNode) {
2393
+ portaledTooltip.parentNode.removeChild(portaledTooltip);
2394
+ portaledTooltip = null;
2395
+ }
2396
+ };
2397
+
2398
+ // Add event listeners
2399
+ closeButtonWrapper.addEventListener("mouseenter", showTooltip);
2400
+ closeButtonWrapper.addEventListener("mouseleave", hideTooltip);
2401
+ closeButton.addEventListener("focus", showTooltip);
2402
+ closeButton.addEventListener("blur", hideTooltip);
2403
+
2404
+ // Store cleanup function on the wrapper for later use
2405
+ (closeButtonWrapper as any)._cleanupTooltip = () => {
2406
+ hideTooltip();
2407
+ if (closeButtonWrapper) {
2408
+ closeButtonWrapper.removeEventListener("mouseenter", showTooltip);
2409
+ closeButtonWrapper.removeEventListener("mouseleave", hideTooltip);
2410
+ }
2411
+ if (closeButton) {
2412
+ closeButton.removeEventListener("focus", showTooltip);
2413
+ closeButton.removeEventListener("blur", hideTooltip);
2414
+ }
2415
+ };
2416
+ }
2417
+ }
2418
+ }
2419
+
2420
+ // Update clear chat button styling from config
2421
+ const { clearChatButton, clearChatButtonWrapper } = panelElements;
2422
+ if (clearChatButton) {
2423
+ const clearChatConfig = launcher.clearChat ?? {};
2424
+ const clearChatEnabled = clearChatConfig.enabled ?? true;
2425
+ const layoutShowClearChat = config.layout?.header?.showClearChat;
2426
+ // layout.header.showClearChat takes precedence if explicitly set
2427
+ // Otherwise fall back to launcher.clearChat.enabled
2428
+ const shouldShowClearChat = layoutShowClearChat !== undefined
2429
+ ? layoutShowClearChat
2430
+ : clearChatEnabled;
2431
+ const clearChatPlacement = clearChatConfig.placement ?? "inline";
2432
+
2433
+ // Show/hide button based on layout config (primary) or launcher config (fallback)
2434
+ if (clearChatButtonWrapper) {
2435
+ clearChatButtonWrapper.style.display = shouldShowClearChat ? "" : "none";
2436
+
2437
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned
2438
+ const { closeButtonWrapper } = panelElements;
2439
+ if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
2440
+ if (shouldShowClearChat) {
2441
+ closeButtonWrapper.classList.remove("tvw-ml-auto");
2442
+ } else {
2443
+ closeButtonWrapper.classList.add("tvw-ml-auto");
2444
+ }
2445
+ }
2446
+
2447
+ // Update placement if changed
2448
+ const isTopRight = clearChatPlacement === "top-right";
2449
+ const currentlyTopRight = clearChatButtonWrapper.classList.contains("tvw-absolute");
2450
+
2451
+ if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
2452
+ clearChatButtonWrapper.remove();
2453
+
2454
+ if (isTopRight) {
2455
+ // Don't use tvw-clear-chat-button-wrapper class for top-right mode as its
2456
+ // display: inline-flex causes alignment issues with the close button
2457
+ clearChatButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-z-50";
2458
+ // Position to the left of the close button (which is at right: 1rem/16px)
2459
+ // Close button is ~32px wide, plus small gap = 48px from right
2460
+ clearChatButtonWrapper.style.right = "48px";
2461
+ container.style.position = "relative";
2462
+ container.appendChild(clearChatButtonWrapper);
2463
+ } else {
2464
+ clearChatButtonWrapper.className = "tvw-relative tvw-ml-auto tvw-clear-chat-button-wrapper";
2465
+ // Clear the inline right style when switching back to inline mode
2466
+ clearChatButtonWrapper.style.right = "";
2467
+ // Find header and insert before close button
2468
+ const header = container.querySelector(".tvw-border-b-cw-divider");
2469
+ const closeButtonWrapperEl = panelElements.closeButtonWrapper;
2470
+ if (header && closeButtonWrapperEl && closeButtonWrapperEl.parentElement === header) {
2471
+ header.insertBefore(clearChatButtonWrapper, closeButtonWrapperEl);
2472
+ } else if (header) {
2473
+ header.appendChild(clearChatButtonWrapper);
2474
+ }
2475
+ }
2476
+
2477
+ // Also update close button's ml-auto class based on clear chat position
2478
+ const closeButtonWrapperEl = panelElements.closeButtonWrapper;
2479
+ if (closeButtonWrapperEl && !closeButtonWrapperEl.classList.contains("tvw-absolute")) {
2480
+ if (isTopRight) {
2481
+ // Clear chat moved to top-right, close needs ml-auto
2482
+ closeButtonWrapperEl.classList.add("tvw-ml-auto");
2483
+ } else {
2484
+ // Clear chat is inline, close doesn't need ml-auto
2485
+ closeButtonWrapperEl.classList.remove("tvw-ml-auto");
2486
+ }
2487
+ }
2488
+ }
2489
+ }
2490
+
2491
+ if (shouldShowClearChat) {
2492
+ // Update size
2493
+ const clearChatSize = clearChatConfig.size ?? "32px";
2494
+ clearChatButton.style.height = clearChatSize;
2495
+ clearChatButton.style.width = clearChatSize;
2496
+
2497
+ // Update icon
2498
+ const clearChatIconName = clearChatConfig.iconName ?? "refresh-cw";
2499
+ const clearChatIconColor = clearChatConfig.iconColor ?? "";
2500
+
2501
+ // Clear existing icon and render new one
2502
+ clearChatButton.innerHTML = "";
2503
+ const iconSvg = renderLucideIcon(clearChatIconName, "20px", clearChatIconColor || "", 2);
2504
+ if (iconSvg) {
2505
+ clearChatButton.appendChild(iconSvg);
2506
+ }
2507
+
2508
+ // Update icon color
2509
+ if (clearChatIconColor) {
2510
+ clearChatButton.style.color = clearChatIconColor;
2511
+ clearChatButton.classList.remove("tvw-text-cw-muted");
2512
+ } else {
2513
+ clearChatButton.style.color = "";
2514
+ clearChatButton.classList.add("tvw-text-cw-muted");
2515
+ }
2516
+
2517
+ // Update background color
2518
+ if (clearChatConfig.backgroundColor) {
2519
+ clearChatButton.style.backgroundColor = clearChatConfig.backgroundColor;
2520
+ clearChatButton.classList.remove("hover:tvw-bg-gray-100");
2521
+ } else {
2522
+ clearChatButton.style.backgroundColor = "";
2523
+ clearChatButton.classList.add("hover:tvw-bg-gray-100");
2524
+ }
2525
+
2526
+ // Update border
2527
+ if (clearChatConfig.borderWidth || clearChatConfig.borderColor) {
2528
+ const borderWidth = clearChatConfig.borderWidth || "0px";
2529
+ const borderColor = clearChatConfig.borderColor || "transparent";
2530
+ clearChatButton.style.border = `${borderWidth} solid ${borderColor}`;
2531
+ clearChatButton.classList.remove("tvw-border-none");
2532
+ } else {
2533
+ clearChatButton.style.border = "";
2534
+ clearChatButton.classList.add("tvw-border-none");
2535
+ }
2536
+
2537
+ // Update border radius
2538
+ if (clearChatConfig.borderRadius) {
2539
+ clearChatButton.style.borderRadius = clearChatConfig.borderRadius;
2540
+ clearChatButton.classList.remove("tvw-rounded-full");
2541
+ } else {
2542
+ clearChatButton.style.borderRadius = "";
2543
+ clearChatButton.classList.add("tvw-rounded-full");
2544
+ }
2545
+
2546
+ // Update padding
2547
+ if (clearChatConfig.paddingX) {
2548
+ clearChatButton.style.paddingLeft = clearChatConfig.paddingX;
2549
+ clearChatButton.style.paddingRight = clearChatConfig.paddingX;
2550
+ } else {
2551
+ clearChatButton.style.paddingLeft = "";
2552
+ clearChatButton.style.paddingRight = "";
2553
+ }
2554
+ if (clearChatConfig.paddingY) {
2555
+ clearChatButton.style.paddingTop = clearChatConfig.paddingY;
2556
+ clearChatButton.style.paddingBottom = clearChatConfig.paddingY;
2557
+ } else {
2558
+ clearChatButton.style.paddingTop = "";
2559
+ clearChatButton.style.paddingBottom = "";
2560
+ }
2561
+
2562
+ const clearChatTooltipText = clearChatConfig.tooltipText ?? "Clear chat";
2563
+ const clearChatShowTooltip = clearChatConfig.showTooltip ?? true;
2564
+
2565
+ clearChatButton.setAttribute("aria-label", clearChatTooltipText);
2566
+
2567
+ if (clearChatButtonWrapper) {
2568
+ // Clean up old tooltip event listeners if they exist
2569
+ if ((clearChatButtonWrapper as any)._cleanupTooltip) {
2570
+ (clearChatButtonWrapper as any)._cleanupTooltip();
2571
+ delete (clearChatButtonWrapper as any)._cleanupTooltip;
2572
+ }
2573
+
2574
+ // Set up new portaled tooltip with event listeners
2575
+ if (clearChatShowTooltip && clearChatTooltipText) {
2576
+ let portaledTooltip: HTMLElement | null = null;
2577
+
2578
+ const showTooltip = () => {
2579
+ if (portaledTooltip || !clearChatButton) return; // Already showing or button doesn't exist
2580
+
2581
+ // Create tooltip element
2582
+ portaledTooltip = createElement("div", "tvw-clear-chat-tooltip");
2583
+ portaledTooltip.textContent = clearChatTooltipText;
2584
+
2585
+ // Add arrow
2586
+ const arrow = createElement("div");
2587
+ arrow.className = "tvw-clear-chat-tooltip-arrow";
2588
+ portaledTooltip.appendChild(arrow);
2589
+
2590
+ // Get button position
2591
+ const buttonRect = clearChatButton.getBoundingClientRect();
2592
+
2593
+ // Position tooltip above button
2594
+ portaledTooltip.style.position = "fixed";
2595
+ portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
2596
+ portaledTooltip.style.top = `${buttonRect.top - 8}px`;
2597
+ portaledTooltip.style.transform = "translate(-50%, -100%)";
2598
+
2599
+ // Append to body
2600
+ document.body.appendChild(portaledTooltip);
2601
+ };
2602
+
2603
+ const hideTooltip = () => {
2604
+ if (portaledTooltip && portaledTooltip.parentNode) {
2605
+ portaledTooltip.parentNode.removeChild(portaledTooltip);
2606
+ portaledTooltip = null;
2607
+ }
2608
+ };
2609
+
2610
+ // Add event listeners
2611
+ clearChatButtonWrapper.addEventListener("mouseenter", showTooltip);
2612
+ clearChatButtonWrapper.addEventListener("mouseleave", hideTooltip);
2613
+ clearChatButton.addEventListener("focus", showTooltip);
2614
+ clearChatButton.addEventListener("blur", hideTooltip);
2615
+
2616
+ // Store cleanup function on the button for later use
2617
+ (clearChatButtonWrapper as any)._cleanupTooltip = () => {
2618
+ hideTooltip();
2619
+ if (clearChatButtonWrapper) {
2620
+ clearChatButtonWrapper.removeEventListener("mouseenter", showTooltip);
2621
+ clearChatButtonWrapper.removeEventListener("mouseleave", hideTooltip);
2622
+ }
2623
+ if (clearChatButton) {
2624
+ clearChatButton.removeEventListener("focus", showTooltip);
2625
+ clearChatButton.removeEventListener("blur", hideTooltip);
2626
+ }
2627
+ };
2628
+ }
2629
+ }
2630
+ }
2631
+ }
2632
+
2633
+ const nextParsers =
2634
+ config.actionParsers && config.actionParsers.length
2635
+ ? config.actionParsers
2636
+ : [defaultJsonActionParser];
2637
+ const nextHandlers =
2638
+ config.actionHandlers && config.actionHandlers.length
2639
+ ? config.actionHandlers
2640
+ : [defaultActionHandlers.message, defaultActionHandlers.messageAndClick];
2641
+
2642
+ actionManager = createActionManager({
2643
+ parsers: nextParsers,
2644
+ handlers: nextHandlers,
2645
+ getSessionMetadata,
2646
+ updateSessionMetadata,
2647
+ emit: eventBus.emit,
2648
+ documentRef: typeof document !== "undefined" ? document : null
2649
+ });
2650
+
2651
+ postprocess = buildPostprocessor(config, actionManager);
2652
+ session.updateConfig(config);
2653
+ renderMessagesWithPlugins(
2654
+ messagesWrapper,
2655
+ session.getMessages(),
2656
+ postprocess
2657
+ );
2658
+ suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
2659
+ updateCopy();
2660
+ setComposerDisabled(session.isStreaming());
2661
+
2662
+ // Update voice recognition mic button visibility
2663
+ const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
2664
+ const hasSpeechRecognition =
2665
+ typeof window !== 'undefined' &&
2666
+ (typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
2667
+ typeof (window as any).SpeechRecognition !== 'undefined');
2668
+
2669
+ if (voiceRecognitionEnabled && hasSpeechRecognition) {
2670
+ // Create or update mic button
2671
+ if (!micButton || !micButtonWrapper) {
2672
+ // Create new mic button
2673
+ const micButtonResult = createMicButton(config.voiceRecognition, config.sendButton);
2674
+ if (micButtonResult) {
2675
+ // Update the mutable references
2676
+ micButton = micButtonResult.micButton;
2677
+ micButtonWrapper = micButtonResult.micButtonWrapper;
2678
+
2679
+ // Insert into right actions before send button wrapper
2680
+ rightActions.insertBefore(micButtonWrapper, sendButtonWrapper);
2681
+
2682
+ // Wire up click handler
2683
+ micButton.addEventListener("click", handleMicButtonClick);
2684
+
2685
+ // Set disabled state
2686
+ micButton.disabled = session.isStreaming();
2687
+ }
2688
+ } else {
2689
+ // Update existing mic button with new config
2690
+ const voiceConfig = config.voiceRecognition ?? {};
2691
+ const sendButtonConfig = config.sendButton ?? {};
2692
+
2693
+ // Update icon name and size
2694
+ const micIconName = voiceConfig.iconName ?? "mic";
2695
+ const buttonSize = sendButtonConfig.size ?? "40px";
2696
+ const micIconSize = voiceConfig.iconSize ?? buttonSize;
2697
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
2698
+
2699
+ micButton.style.width = micIconSize;
2700
+ micButton.style.height = micIconSize;
2701
+ micButton.style.minWidth = micIconSize;
2702
+ micButton.style.minHeight = micIconSize;
2703
+
2704
+ // Update icon
2705
+ const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor ?? "currentColor";
2706
+ micButton.innerHTML = "";
2707
+ const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColor, 2);
2708
+ if (micIconSvg) {
2709
+ micButton.appendChild(micIconSvg);
2710
+ } else {
2711
+ micButton.textContent = "🎤";
2712
+ }
2713
+
2714
+ // Update colors
2715
+ const backgroundColor = voiceConfig.backgroundColor ?? sendButtonConfig.backgroundColor;
2716
+ if (backgroundColor) {
2717
+ micButton.style.backgroundColor = backgroundColor;
2718
+ micButton.classList.remove("tvw-bg-cw-primary");
2719
+ } else {
2720
+ micButton.style.backgroundColor = "";
2721
+ micButton.classList.add("tvw-bg-cw-primary");
2722
+ }
2723
+
2724
+ if (iconColor) {
2725
+ micButton.style.color = iconColor;
2726
+ micButton.classList.remove("tvw-text-white");
2727
+ } else if (!iconColor && !sendButtonConfig.textColor) {
2728
+ micButton.style.color = "";
2729
+ micButton.classList.add("tvw-text-white");
2730
+ }
2731
+
2732
+ // Update border styling
2733
+ if (voiceConfig.borderWidth) {
2734
+ micButton.style.borderWidth = voiceConfig.borderWidth;
2735
+ micButton.style.borderStyle = "solid";
2736
+ } else {
2737
+ micButton.style.borderWidth = "";
2738
+ micButton.style.borderStyle = "";
2739
+ }
2740
+ if (voiceConfig.borderColor) {
2741
+ micButton.style.borderColor = voiceConfig.borderColor;
2742
+ } else {
2743
+ micButton.style.borderColor = "";
2744
+ }
2745
+
2746
+ // Update padding styling
2747
+ if (voiceConfig.paddingX) {
2748
+ micButton.style.paddingLeft = voiceConfig.paddingX;
2749
+ micButton.style.paddingRight = voiceConfig.paddingX;
2750
+ } else {
2751
+ micButton.style.paddingLeft = "";
2752
+ micButton.style.paddingRight = "";
2753
+ }
2754
+ if (voiceConfig.paddingY) {
2755
+ micButton.style.paddingTop = voiceConfig.paddingY;
2756
+ micButton.style.paddingBottom = voiceConfig.paddingY;
2757
+ } else {
2758
+ micButton.style.paddingTop = "";
2759
+ micButton.style.paddingBottom = "";
2760
+ }
2761
+
2762
+ // Update tooltip
2763
+ const tooltip = micButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
2764
+ const tooltipText = voiceConfig.tooltipText ?? "Start voice recognition";
2765
+ const showTooltip = voiceConfig.showTooltip ?? false;
2766
+ if (showTooltip && tooltipText) {
2767
+ if (!tooltip) {
2768
+ // Create tooltip if it doesn't exist
2769
+ const newTooltip = document.createElement("div");
2770
+ newTooltip.className = "tvw-send-button-tooltip";
2771
+ newTooltip.textContent = tooltipText;
2772
+ micButtonWrapper?.insertBefore(newTooltip, micButton);
2773
+ } else {
2774
+ tooltip.textContent = tooltipText;
2775
+ tooltip.style.display = "";
2776
+ }
2777
+ } else if (tooltip) {
2778
+ // Hide tooltip if disabled
2779
+ tooltip.style.display = "none";
2780
+ }
2781
+
2782
+ // Show and update disabled state
2783
+ micButtonWrapper.style.display = "";
2784
+ micButton.disabled = session.isStreaming();
2785
+ }
2786
+ } else {
2787
+ // Hide mic button
2788
+ if (micButton && micButtonWrapper) {
2789
+ micButtonWrapper.style.display = "none";
2790
+ // Stop any active recording if disabling
2791
+ if (isRecording) {
2792
+ stopVoiceRecognition();
2793
+ }
2794
+ }
2795
+ }
2796
+
2797
+ // Update attachment button visibility based on attachments config
2798
+ const attachmentsEnabled = config.attachments?.enabled === true;
2799
+ if (attachmentsEnabled) {
2800
+ // Create or show attachment button
2801
+ if (!attachmentButtonWrapper || !attachmentButton) {
2802
+ // Need to create the attachment elements dynamically
2803
+ const attachmentsConfig = config.attachments ?? {};
2804
+ const sendButtonConfig = config.sendButton ?? {};
2805
+ const buttonSize = sendButtonConfig.size ?? "40px";
2806
+
2807
+ // Create previews container if not exists
2808
+ if (!attachmentPreviewsContainer) {
2809
+ attachmentPreviewsContainer = createElement("div", "tvw-attachment-previews tvw-flex tvw-flex-wrap tvw-gap-2 tvw-mb-2");
2810
+ attachmentPreviewsContainer.style.display = "none";
2811
+ composerForm.insertBefore(attachmentPreviewsContainer, textarea);
2812
+ }
2813
+
2814
+ // Create file input if not exists
2815
+ if (!attachmentInput) {
2816
+ attachmentInput = document.createElement("input");
2817
+ attachmentInput.type = "file";
2818
+ attachmentInput.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
2819
+ attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
2820
+ attachmentInput.style.display = "none";
2821
+ attachmentInput.setAttribute("aria-label", "Attach files");
2822
+ composerForm.insertBefore(attachmentInput, textarea);
2823
+ }
2824
+
2825
+ // Create attachment button wrapper
2826
+ attachmentButtonWrapper = createElement("div", "tvw-send-button-wrapper");
2827
+
2828
+ // Create attachment button
2829
+ attachmentButton = createElement(
2830
+ "button",
2831
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer tvw-attachment-button"
2832
+ ) as HTMLButtonElement;
2833
+ attachmentButton.type = "button";
2834
+ attachmentButton.setAttribute("aria-label", attachmentsConfig.buttonTooltipText ?? "Attach file");
2835
+
2836
+ // Default to paperclip icon
2837
+ const attachIconName = attachmentsConfig.buttonIconName ?? "paperclip";
2838
+ const attachIconSize = buttonSize;
2839
+ const buttonSizeNum = parseFloat(attachIconSize) || 40;
2840
+ // Icon should be ~60% of button size to match other icons visually
2841
+ const attachIconSizeNum = Math.round(buttonSizeNum * 0.6);
2842
+
2843
+ attachmentButton.style.width = attachIconSize;
2844
+ attachmentButton.style.height = attachIconSize;
2845
+ attachmentButton.style.minWidth = attachIconSize;
2846
+ attachmentButton.style.minHeight = attachIconSize;
2847
+ attachmentButton.style.fontSize = "18px";
2848
+ attachmentButton.style.lineHeight = "1";
2849
+ attachmentButton.style.backgroundColor = "transparent";
2850
+ attachmentButton.style.color = "var(--cw-primary, #111827)";
2851
+ attachmentButton.style.border = "none";
2852
+ attachmentButton.style.borderRadius = "6px";
2853
+ attachmentButton.style.transition = "background-color 0.15s ease";
2854
+
2855
+ // Add hover effect via mouseenter/mouseleave
2856
+ attachmentButton.addEventListener("mouseenter", () => {
2857
+ attachmentButton!.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
2858
+ });
2859
+ attachmentButton.addEventListener("mouseleave", () => {
2860
+ attachmentButton!.style.backgroundColor = "transparent";
2861
+ });
2862
+
2863
+ const attachIconSvg = renderLucideIcon(attachIconName, attachIconSizeNum, "currentColor", 1.5);
2864
+ if (attachIconSvg) {
2865
+ attachmentButton.appendChild(attachIconSvg);
2866
+ } else {
2867
+ attachmentButton.textContent = "📎";
2868
+ }
2869
+
2870
+ attachmentButton.addEventListener("click", (e) => {
2871
+ e.preventDefault();
2872
+ attachmentInput?.click();
2873
+ });
2874
+
2875
+ attachmentButtonWrapper.appendChild(attachmentButton);
2876
+
2877
+ // Add tooltip
2878
+ const attachTooltipText = attachmentsConfig.buttonTooltipText ?? "Attach file";
2879
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
2880
+ tooltip.textContent = attachTooltipText;
2881
+ attachmentButtonWrapper.appendChild(tooltip);
2882
+
2883
+ // Insert into left actions container
2884
+ leftActions.append(attachmentButtonWrapper);
2885
+
2886
+ // Initialize attachment manager
2887
+ if (!attachmentManager && attachmentInput && attachmentPreviewsContainer) {
2888
+ attachmentManager = AttachmentManager.fromConfig(attachmentsConfig);
2889
+ attachmentManager.setPreviewsContainer(attachmentPreviewsContainer);
2890
+
2891
+ attachmentInput.addEventListener("change", async () => {
2892
+ if (attachmentManager && attachmentInput?.files) {
2893
+ await attachmentManager.handleFileSelect(attachmentInput.files);
2894
+ attachmentInput.value = "";
2895
+ }
2896
+ });
2897
+ }
2898
+ } else {
2899
+ // Show existing attachment button and update config
2900
+ attachmentButtonWrapper.style.display = "";
2901
+
2902
+ // Update file input accept attribute when config changes
2903
+ const attachmentsConfig = config.attachments ?? {};
2904
+ if (attachmentInput) {
2905
+ attachmentInput.accept = (attachmentsConfig.allowedTypes ?? ALL_SUPPORTED_MIME_TYPES).join(",");
2906
+ attachmentInput.multiple = (attachmentsConfig.maxFiles ?? 4) > 1;
2907
+ }
2908
+
2909
+ // Update attachment manager config
2910
+ if (attachmentManager) {
2911
+ attachmentManager.updateConfig({
2912
+ allowedTypes: attachmentsConfig.allowedTypes,
2913
+ maxFileSize: attachmentsConfig.maxFileSize,
2914
+ maxFiles: attachmentsConfig.maxFiles
2915
+ });
2916
+ }
2917
+ }
2918
+ } else {
2919
+ // Hide attachment button if disabled
2920
+ if (attachmentButtonWrapper) {
2921
+ attachmentButtonWrapper.style.display = "none";
2922
+ }
2923
+ // Clear any pending attachments
2924
+ if (attachmentManager) {
2925
+ attachmentManager.clearAttachments();
2926
+ }
2927
+ }
2928
+
2929
+ // Update send button styling
2930
+ const sendButtonConfig = config.sendButton ?? {};
2931
+ const useIcon = sendButtonConfig.useIcon ?? false;
2932
+ const iconText = sendButtonConfig.iconText ?? "↑";
2933
+ const iconName = sendButtonConfig.iconName;
2934
+ const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
2935
+ const showTooltip = sendButtonConfig.showTooltip ?? false;
2936
+ const buttonSize = sendButtonConfig.size ?? "40px";
2937
+ const backgroundColor = sendButtonConfig.backgroundColor;
2938
+ const textColor = sendButtonConfig.textColor;
2939
+
2940
+ // Update button content and styling based on mode
2941
+ if (useIcon) {
2942
+ // Icon mode: circular button
2943
+ sendButton.style.width = buttonSize;
2944
+ sendButton.style.height = buttonSize;
2945
+ sendButton.style.minWidth = buttonSize;
2946
+ sendButton.style.minHeight = buttonSize;
2947
+ sendButton.style.fontSize = "18px";
2948
+ sendButton.style.lineHeight = "1";
2949
+
2950
+ // Clear existing content
2951
+ sendButton.innerHTML = "";
2952
+
2953
+ // Use Lucide icon if iconName is provided, otherwise fall back to iconText
2954
+ if (iconName) {
2955
+ const iconSize = parseFloat(buttonSize) || 24;
2956
+ const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
2957
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
2958
+ if (iconSvg) {
2959
+ sendButton.appendChild(iconSvg);
2960
+ sendButton.style.color = iconColor;
2961
+ } else {
2962
+ // Fallback to text if icon fails to render
2963
+ sendButton.textContent = iconText;
2964
+ if (textColor) {
2965
+ sendButton.style.color = textColor;
2966
+ } else {
2967
+ sendButton.classList.add("tvw-text-white");
2968
+ }
2969
+ }
2970
+ } else {
2971
+ sendButton.textContent = iconText;
2972
+ if (textColor) {
2973
+ sendButton.style.color = textColor;
2974
+ } else {
2975
+ sendButton.classList.add("tvw-text-white");
2976
+ }
2977
+ }
2978
+
2979
+ // Update classes
2980
+ sendButton.className = "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer";
2981
+
2982
+ if (backgroundColor) {
2983
+ sendButton.style.backgroundColor = backgroundColor;
2984
+ sendButton.classList.remove("tvw-bg-cw-primary");
2985
+ } else {
2986
+ sendButton.classList.add("tvw-bg-cw-primary");
2987
+ }
2988
+ } else {
2989
+ // Text mode: existing behavior
2990
+ sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
2991
+ sendButton.style.width = "";
2992
+ sendButton.style.height = "";
2993
+ sendButton.style.minWidth = "";
2994
+ sendButton.style.minHeight = "";
2995
+ sendButton.style.fontSize = "";
2996
+ sendButton.style.lineHeight = "";
2997
+
2998
+ // Update classes
2999
+ 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";
3000
+
3001
+ if (backgroundColor) {
3002
+ sendButton.style.backgroundColor = backgroundColor;
3003
+ sendButton.classList.remove("tvw-bg-cw-accent");
3004
+ } else {
3005
+ sendButton.classList.add("tvw-bg-cw-accent");
3006
+ }
3007
+
3008
+ if (textColor) {
3009
+ sendButton.style.color = textColor;
3010
+ } else {
3011
+ sendButton.classList.add("tvw-text-white");
3012
+ }
3013
+ }
3014
+
3015
+ // Apply border styling
3016
+ if (sendButtonConfig.borderWidth) {
3017
+ sendButton.style.borderWidth = sendButtonConfig.borderWidth;
3018
+ sendButton.style.borderStyle = "solid";
3019
+ } else {
3020
+ sendButton.style.borderWidth = "";
3021
+ sendButton.style.borderStyle = "";
3022
+ }
3023
+ if (sendButtonConfig.borderColor) {
3024
+ sendButton.style.borderColor = sendButtonConfig.borderColor;
3025
+ } else {
3026
+ sendButton.style.borderColor = "";
3027
+ }
3028
+
3029
+ // Apply padding styling (works in both icon and text mode)
3030
+ if (sendButtonConfig.paddingX) {
3031
+ sendButton.style.paddingLeft = sendButtonConfig.paddingX;
3032
+ sendButton.style.paddingRight = sendButtonConfig.paddingX;
3033
+ } else {
3034
+ sendButton.style.paddingLeft = "";
3035
+ sendButton.style.paddingRight = "";
3036
+ }
3037
+ if (sendButtonConfig.paddingY) {
3038
+ sendButton.style.paddingTop = sendButtonConfig.paddingY;
3039
+ sendButton.style.paddingBottom = sendButtonConfig.paddingY;
3040
+ } else {
3041
+ sendButton.style.paddingTop = "";
3042
+ sendButton.style.paddingBottom = "";
3043
+ }
3044
+
3045
+ // Update tooltip
3046
+ const tooltip = sendButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
3047
+ if (showTooltip && tooltipText) {
3048
+ if (!tooltip) {
3049
+ // Create tooltip if it doesn't exist
3050
+ const newTooltip = document.createElement("div");
3051
+ newTooltip.className = "tvw-send-button-tooltip";
3052
+ newTooltip.textContent = tooltipText;
3053
+ sendButtonWrapper?.insertBefore(newTooltip, sendButton);
3054
+ } else {
3055
+ tooltip.textContent = tooltipText;
3056
+ tooltip.style.display = "";
3057
+ }
3058
+ } else if (tooltip) {
3059
+ tooltip.style.display = "none";
3060
+ }
3061
+
3062
+ // Update status indicator visibility and text
3063
+ const statusIndicatorConfig = config.statusIndicator ?? {};
3064
+ const isVisible = statusIndicatorConfig.visible ?? true;
3065
+ statusText.style.display = isVisible ? "" : "none";
3066
+
3067
+ // Update status text if status is currently set
3068
+ if (session) {
3069
+ const currentStatus = session.getStatus();
3070
+ const getCurrentStatusText = (status: AgentWidgetSessionStatus): string => {
3071
+ if (status === "idle") return statusIndicatorConfig.idleText ?? statusCopy.idle;
3072
+ if (status === "connecting") return statusIndicatorConfig.connectingText ?? statusCopy.connecting;
3073
+ if (status === "connected") return statusIndicatorConfig.connectedText ?? statusCopy.connected;
3074
+ if (status === "error") return statusIndicatorConfig.errorText ?? statusCopy.error;
3075
+ return statusCopy[status];
3076
+ };
3077
+ statusText.textContent = getCurrentStatusText(currentStatus);
3078
+ }
3079
+ },
3080
+ open() {
3081
+ if (!launcherEnabled) return;
3082
+ setOpenState(true, "api");
3083
+ },
3084
+ close() {
3085
+ if (!launcherEnabled) return;
3086
+ setOpenState(false, "api");
3087
+ },
3088
+ toggle() {
3089
+ if (!launcherEnabled) return;
3090
+ setOpenState(!open, "api");
3091
+ },
3092
+ clearChat() {
3093
+ // Clear messages in session (this will trigger onMessagesChanged which re-renders)
3094
+ session.clearMessages();
3095
+
3096
+ // Always clear the default localStorage key
3097
+ try {
3098
+ localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
3099
+ if (config.debug) {
3100
+ console.log(`[AgentWidget] Cleared default localStorage key: ${DEFAULT_CHAT_HISTORY_STORAGE_KEY}`);
3101
+ }
3102
+ } catch (error) {
3103
+ console.error("[AgentWidget] Failed to clear default localStorage:", error);
3104
+ }
3105
+
3106
+ // Also clear custom localStorage key if configured
3107
+ if (config.clearChatHistoryStorageKey && config.clearChatHistoryStorageKey !== DEFAULT_CHAT_HISTORY_STORAGE_KEY) {
3108
+ try {
3109
+ localStorage.removeItem(config.clearChatHistoryStorageKey);
3110
+ if (config.debug) {
3111
+ console.log(`[AgentWidget] Cleared custom localStorage key: ${config.clearChatHistoryStorageKey}`);
3112
+ }
3113
+ } catch (error) {
3114
+ console.error("[AgentWidget] Failed to clear custom localStorage:", error);
3115
+ }
3116
+ }
3117
+
3118
+ // Dispatch custom event for external handlers (e.g., localStorage clearing in examples)
3119
+ const clearEvent = new CustomEvent("persona:clear-chat", {
3120
+ detail: { timestamp: new Date().toISOString() }
3121
+ });
3122
+ window.dispatchEvent(clearEvent);
3123
+
3124
+ if (storageAdapter?.clear) {
3125
+ try {
3126
+ const result = storageAdapter.clear();
3127
+ if (result instanceof Promise) {
3128
+ result.catch((error) => {
3129
+ if (typeof console !== "undefined") {
3130
+ // eslint-disable-next-line no-console
3131
+ console.error("[AgentWidget] Failed to clear storage adapter:", error);
3132
+ }
3133
+ });
3134
+ }
3135
+ } catch (error) {
3136
+ if (typeof console !== "undefined") {
3137
+ // eslint-disable-next-line no-console
3138
+ console.error("[AgentWidget] Failed to clear storage adapter:", error);
3139
+ }
3140
+ }
3141
+ }
3142
+ persistentMetadata = {};
3143
+ actionManager.syncFromMetadata();
3144
+ },
3145
+ setMessage(message: string): boolean {
3146
+ if (!textarea) return false;
3147
+ if (session.isStreaming()) return false;
3148
+
3149
+ // Auto-open widget if closed and launcher is enabled
3150
+ if (!open && launcherEnabled) {
3151
+ setOpenState(true, "system");
3152
+ }
3153
+
3154
+ textarea.value = message;
3155
+ // Trigger input event for any listeners
3156
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
3157
+ return true;
3158
+ },
3159
+ submitMessage(message?: string): boolean {
3160
+ if (session.isStreaming()) return false;
3161
+
3162
+ const valueToSubmit = message?.trim() || textarea.value.trim();
3163
+ if (!valueToSubmit) return false;
3164
+
3165
+ // Auto-open widget if closed and launcher is enabled
3166
+ if (!open && launcherEnabled) {
3167
+ setOpenState(true, "system");
3168
+ }
3169
+
3170
+ textarea.value = "";
3171
+ textarea.style.height = "auto"; // Reset height after clearing
3172
+ session.sendMessage(valueToSubmit);
3173
+ return true;
3174
+ },
3175
+ startVoiceRecognition(): boolean {
3176
+ if (isRecording || session.isStreaming()) return false;
3177
+
3178
+ const SpeechRecognitionClass = getSpeechRecognitionClass();
3179
+ if (!SpeechRecognitionClass) return false;
3180
+
3181
+ // Auto-open widget if closed and launcher is enabled
3182
+ if (!open && launcherEnabled) {
3183
+ setOpenState(true, "system");
3184
+ }
3185
+
3186
+ voiceState.manuallyDeactivated = false;
3187
+ persistVoiceMetadata();
3188
+ startVoiceRecognition("user");
3189
+ return true;
3190
+ },
3191
+ stopVoiceRecognition(): boolean {
3192
+ if (!isRecording) return false;
3193
+
3194
+ voiceState.manuallyDeactivated = true;
3195
+ persistVoiceMetadata();
3196
+ stopVoiceRecognition("user");
3197
+ return true;
3198
+ },
3199
+ injectTestMessage(event: AgentWidgetEvent) {
3200
+ // Auto-open widget if closed and launcher is enabled
3201
+ if (!open && launcherEnabled) {
3202
+ setOpenState(true, "system");
3203
+ }
3204
+ session.injectTestEvent(event);
3205
+ },
3206
+ getMessages() {
3207
+ return session.getMessages();
3208
+ },
3209
+ getStatus() {
3210
+ return session.getStatus();
3211
+ },
3212
+ getPersistentMetadata() {
3213
+ return { ...persistentMetadata };
3214
+ },
3215
+ updatePersistentMetadata(
3216
+ updater: (prev: Record<string, unknown>) => Record<string, unknown>
3217
+ ) {
3218
+ updateSessionMetadata(updater);
3219
+ },
3220
+ on(event, handler) {
3221
+ return eventBus.on(event, handler);
3222
+ },
3223
+ off(event, handler) {
3224
+ eventBus.off(event, handler);
3225
+ },
3226
+ // State query methods
3227
+ isOpen(): boolean {
3228
+ return launcherEnabled && open;
3229
+ },
3230
+ isVoiceActive(): boolean {
3231
+ return voiceState.active;
3232
+ },
3233
+ getState(): AgentWidgetStateSnapshot {
3234
+ return {
3235
+ open: launcherEnabled && open,
3236
+ launcherEnabled,
3237
+ voiceActive: voiceState.active,
3238
+ streaming: session.isStreaming()
3239
+ };
3240
+ },
3241
+ // Feedback methods (CSAT/NPS)
3242
+ showCSATFeedback(options?: Partial<CSATFeedbackOptions>) {
3243
+ // Auto-open widget if closed and launcher is enabled
3244
+ if (!open && launcherEnabled) {
3245
+ setOpenState(true, "system");
3246
+ }
3247
+
3248
+ // Remove any existing feedback forms
3249
+ const existingFeedback = messagesWrapper.querySelector('.tvw-feedback-container');
3250
+ if (existingFeedback) {
3251
+ existingFeedback.remove();
3252
+ }
3253
+
3254
+ const feedbackEl = createCSATFeedback({
3255
+ onSubmit: async (rating, comment) => {
3256
+ if (session.isClientTokenMode()) {
3257
+ await session.submitCSATFeedback(rating, comment);
3258
+ }
3259
+ options?.onSubmit?.(rating, comment);
3260
+ },
3261
+ onDismiss: options?.onDismiss,
3262
+ ...options,
3263
+ });
3264
+
3265
+ // Append to messages area at the bottom
3266
+ messagesWrapper.appendChild(feedbackEl);
3267
+ feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
3268
+ },
3269
+ showNPSFeedback(options?: Partial<NPSFeedbackOptions>) {
3270
+ // Auto-open widget if closed and launcher is enabled
3271
+ if (!open && launcherEnabled) {
3272
+ setOpenState(true, "system");
3273
+ }
3274
+
3275
+ // Remove any existing feedback forms
3276
+ const existingFeedback = messagesWrapper.querySelector('.tvw-feedback-container');
3277
+ if (existingFeedback) {
3278
+ existingFeedback.remove();
3279
+ }
3280
+
3281
+ const feedbackEl = createNPSFeedback({
3282
+ onSubmit: async (rating, comment) => {
3283
+ if (session.isClientTokenMode()) {
3284
+ await session.submitNPSFeedback(rating, comment);
3285
+ }
3286
+ options?.onSubmit?.(rating, comment);
3287
+ },
3288
+ onDismiss: options?.onDismiss,
3289
+ ...options,
3290
+ });
3291
+
3292
+ // Append to messages area at the bottom
3293
+ messagesWrapper.appendChild(feedbackEl);
3294
+ feedbackEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
3295
+ },
3296
+ async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
3297
+ return session.submitCSATFeedback(rating, comment);
3298
+ },
3299
+ async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
3300
+ return session.submitNPSFeedback(rating, comment);
3301
+ },
3302
+ destroy() {
3303
+ destroyCallbacks.forEach((cb) => cb());
3304
+ wrapper.remove();
3305
+ launcherButtonInstance?.destroy();
3306
+ customLauncherElement?.remove();
3307
+ if (closeHandler) {
3308
+ closeButton.removeEventListener("click", closeHandler);
3309
+ }
3310
+ }
3311
+ };
3312
+
3313
+ const shouldExposeDebugApi =
3314
+ (runtimeOptions?.debugTools ?? false) || Boolean(config.debug);
3315
+
3316
+ if (shouldExposeDebugApi && typeof window !== "undefined") {
3317
+ const previousDebug = (window as any).AgentWidgetBrowser;
3318
+ const debugApi = {
3319
+ controller,
3320
+ getMessages: controller.getMessages,
3321
+ getStatus: controller.getStatus,
3322
+ getMetadata: controller.getPersistentMetadata,
3323
+ updateMetadata: controller.updatePersistentMetadata,
3324
+ clearHistory: () => controller.clearChat(),
3325
+ setVoiceActive: (active: boolean) =>
3326
+ active
3327
+ ? controller.startVoiceRecognition()
3328
+ : controller.stopVoiceRecognition()
3329
+ };
3330
+ (window as any).AgentWidgetBrowser = debugApi;
3331
+ destroyCallbacks.push(() => {
3332
+ if ((window as any).AgentWidgetBrowser === debugApi) {
3333
+ (window as any).AgentWidgetBrowser = previousDebug;
3334
+ }
3335
+ });
3336
+ }
3337
+
3338
+ return controller;
3339
+ };
3340
+
3341
+ export type AgentWidgetController = Controller;