vanilla-agent 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts CHANGED
@@ -9,14 +9,17 @@ import {
9
9
  AgentWidgetControllerEventMap,
10
10
  AgentWidgetVoiceStateEvent,
11
11
  AgentWidgetStateEvent,
12
- AgentWidgetStateSnapshot
12
+ AgentWidgetStateSnapshot,
13
+ WidgetLayoutSlot,
14
+ SlotRenderer
13
15
  } from "./types";
14
16
  import { applyThemeVariables } from "./utils/theme";
15
17
  import { renderLucideIcon } from "./utils/icons";
16
18
  import { createElement } from "./utils/dom";
17
19
  import { statusCopy } from "./utils/constants";
18
20
  import { createLauncherButton } from "./components/launcher";
19
- import { createWrapper, buildPanel } from "./components/panel";
21
+ import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
22
+ import type { HeaderElements, ComposerElements } from "./components/panel";
20
23
  import { MessageTransform } from "./components/message-bubble";
21
24
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
22
25
  import { createReasoningBubble } from "./components/reasoning-bubble";
@@ -223,7 +226,7 @@ export const createAgentExperience = (
223
226
 
224
227
  const { wrapper, panel } = createWrapper(config);
225
228
  const panelElements = buildPanel(config, launcherEnabled);
226
- const {
229
+ let {
227
230
  container,
228
231
  body,
229
232
  messagesWrapper,
@@ -238,16 +241,297 @@ export const createAgentExperience = (
238
241
  closeButton,
239
242
  iconHolder,
240
243
  headerTitle,
241
- headerSubtitle
244
+ headerSubtitle,
245
+ header,
246
+ footer
242
247
  } = panelElements;
243
248
 
244
249
  // Use mutable references for mic button so we can update them dynamically
245
250
  let micButton: HTMLButtonElement | null = panelElements.micButton;
246
251
  let micButtonWrapper: HTMLElement | null = panelElements.micButtonWrapper;
247
252
 
253
+ // Plugin hook: renderHeader - allow plugins to provide custom header
254
+ const headerPlugin = plugins.find(p => p.renderHeader);
255
+ if (headerPlugin?.renderHeader) {
256
+ const customHeader = headerPlugin.renderHeader({
257
+ config,
258
+ defaultRenderer: () => {
259
+ const headerElements = buildHeader({ config, showClose: launcherEnabled });
260
+ attachHeaderToContainer(container, headerElements, config);
261
+ return headerElements.header;
262
+ },
263
+ onClose: () => setOpenState(false, "user")
264
+ });
265
+ if (customHeader) {
266
+ // Replace the default header with custom header
267
+ const existingHeader = container.querySelector('.tvw-border-b-cw-divider');
268
+ if (existingHeader) {
269
+ existingHeader.replaceWith(customHeader);
270
+ header = customHeader;
271
+ }
272
+ }
273
+ }
274
+
275
+ // Plugin hook: renderComposer - allow plugins to provide custom composer
276
+ const composerPlugin = plugins.find(p => p.renderComposer);
277
+ if (composerPlugin?.renderComposer) {
278
+ const customComposer = composerPlugin.renderComposer({
279
+ config,
280
+ defaultRenderer: () => {
281
+ const composerElements = buildComposer({ config });
282
+ return composerElements.footer;
283
+ },
284
+ onSubmit: (text: string) => {
285
+ if (session && !session.isStreaming()) {
286
+ session.sendMessage(text);
287
+ }
288
+ },
289
+ disabled: false
290
+ });
291
+ if (customComposer) {
292
+ // Replace the default footer with custom composer
293
+ footer.replaceWith(customComposer);
294
+ footer = customComposer;
295
+ // Note: When using custom composer, textarea/sendButton/etc may not exist
296
+ // The plugin is responsible for providing its own submit handling
297
+ }
298
+ }
299
+
300
+ // Slot system: allow custom content injection into specific regions
301
+ const renderSlots = () => {
302
+ const slots = config.layout?.slots ?? {};
303
+
304
+ // Helper to get default slot content
305
+ const getDefaultSlotContent = (slot: WidgetLayoutSlot): HTMLElement | null => {
306
+ switch (slot) {
307
+ case "body-top":
308
+ // Default: the intro card
309
+ return container.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6") as HTMLElement || null;
310
+ case "messages":
311
+ return messagesWrapper;
312
+ case "footer-top":
313
+ return suggestions;
314
+ case "composer":
315
+ return composerForm;
316
+ case "footer-bottom":
317
+ return statusText;
318
+ default:
319
+ return null;
320
+ }
321
+ };
322
+
323
+ // Helper to insert content into slot region
324
+ const insertSlotContent = (slot: WidgetLayoutSlot, element: HTMLElement) => {
325
+ switch (slot) {
326
+ case "header-left":
327
+ case "header-center":
328
+ case "header-right":
329
+ // Header slots - prepend/append to header
330
+ if (slot === "header-left") {
331
+ header.insertBefore(element, header.firstChild);
332
+ } else if (slot === "header-right") {
333
+ header.appendChild(element);
334
+ } else {
335
+ // header-center: insert after icon/title
336
+ const titleSection = header.querySelector(".tvw-flex-col");
337
+ if (titleSection) {
338
+ titleSection.parentNode?.insertBefore(element, titleSection.nextSibling);
339
+ } else {
340
+ header.appendChild(element);
341
+ }
342
+ }
343
+ break;
344
+ case "body-top":
345
+ // Replace or prepend to body
346
+ const introCard = body.querySelector(".tvw-rounded-2xl.tvw-bg-cw-surface.tvw-p-6");
347
+ if (introCard) {
348
+ introCard.replaceWith(element);
349
+ } else {
350
+ body.insertBefore(element, body.firstChild);
351
+ }
352
+ break;
353
+ case "body-bottom":
354
+ // Append after messages wrapper
355
+ body.appendChild(element);
356
+ break;
357
+ case "footer-top":
358
+ // Replace suggestions area
359
+ suggestions.replaceWith(element);
360
+ break;
361
+ case "footer-bottom":
362
+ // Replace or append after status text
363
+ statusText.replaceWith(element);
364
+ break;
365
+ default:
366
+ // For other slots, just append to appropriate container
367
+ break;
368
+ }
369
+ };
370
+
371
+ // Process each configured slot
372
+ for (const [slotName, renderer] of Object.entries(slots) as [WidgetLayoutSlot, SlotRenderer][]) {
373
+ if (renderer) {
374
+ try {
375
+ const slotElement = renderer({
376
+ config,
377
+ defaultContent: () => getDefaultSlotContent(slotName)
378
+ });
379
+ if (slotElement) {
380
+ insertSlotContent(slotName, slotElement);
381
+ }
382
+ } catch (error) {
383
+ if (typeof console !== "undefined") {
384
+ // eslint-disable-next-line no-console
385
+ console.error(`[AgentWidget] Error rendering slot "${slotName}":`, error);
386
+ }
387
+ }
388
+ }
389
+ }
390
+ };
391
+
392
+ // Render custom slots
393
+ renderSlots();
394
+
248
395
  panel.appendChild(container);
249
396
  mount.appendChild(wrapper);
250
397
 
398
+ // Apply full-height and sidebar styles if enabled
399
+ // This ensures the widget fills its container height with proper flex layout
400
+ const applyFullHeightStyles = () => {
401
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
402
+ const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
403
+ const theme = config.theme ?? {};
404
+
405
+ // Determine panel styling based on mode, with theme overrides
406
+ const position = config.launcher?.position ?? 'bottom-left';
407
+ const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
408
+
409
+ // Default values based on mode
410
+ const defaultPanelBorder = sidebarMode ? 'none' : '1px solid var(--tvw-cw-border)';
411
+ const defaultPanelShadow = sidebarMode
412
+ ? (isLeftSidebar ? '2px 0 12px rgba(0, 0, 0, 0.08)' : '-2px 0 12px rgba(0, 0, 0, 0.08)')
413
+ : '0 25px 50px -12px rgba(0, 0, 0, 0.25)';
414
+ const defaultPanelBorderRadius = sidebarMode ? '0' : '16px';
415
+
416
+ // Apply theme overrides or defaults
417
+ const panelBorder = theme.panelBorder ?? defaultPanelBorder;
418
+ const panelShadow = theme.panelShadow ?? defaultPanelShadow;
419
+ const panelBorderRadius = theme.panelBorderRadius ?? defaultPanelBorderRadius;
420
+
421
+ // Apply panel styling to container (works in all modes)
422
+ container.style.border = panelBorder;
423
+ container.style.boxShadow = panelShadow;
424
+ container.style.borderRadius = panelBorderRadius;
425
+
426
+ if (fullHeight) {
427
+ // Mount container
428
+ mount.style.display = 'flex';
429
+ mount.style.flexDirection = 'column';
430
+ mount.style.height = '100%';
431
+ mount.style.minHeight = '0';
432
+
433
+ // Wrapper
434
+ wrapper.style.display = 'flex';
435
+ wrapper.style.flexDirection = 'column';
436
+ wrapper.style.flex = '1 1 0%';
437
+ wrapper.style.minHeight = '0';
438
+ wrapper.style.maxHeight = '100%';
439
+ wrapper.style.height = '100%';
440
+ wrapper.style.overflow = 'hidden';
441
+
442
+ // Panel
443
+ panel.style.display = 'flex';
444
+ panel.style.flexDirection = 'column';
445
+ panel.style.flex = '1 1 0%';
446
+ panel.style.minHeight = '0';
447
+ panel.style.maxHeight = '100%';
448
+ panel.style.height = '100%';
449
+ panel.style.overflow = 'hidden';
450
+
451
+ // Main container
452
+ container.style.display = 'flex';
453
+ container.style.flexDirection = 'column';
454
+ container.style.flex = '1 1 0%';
455
+ container.style.minHeight = '0';
456
+ container.style.maxHeight = '100%';
457
+ container.style.overflow = 'hidden';
458
+
459
+ // Body (scrollable messages area)
460
+ body.style.flex = '1 1 0%';
461
+ body.style.minHeight = '0';
462
+ body.style.overflowY = 'auto';
463
+
464
+ // Footer (composer) - should not shrink
465
+ footer.style.flexShrink = '0';
466
+ }
467
+
468
+ // Apply sidebar-specific styles
469
+ if (sidebarMode) {
470
+ const sidebarWidth = config.launcher?.sidebarWidth ?? '420px';
471
+
472
+ // Remove Tailwind positioning classes that add spacing (tvw-bottom-6, tvw-right-6, etc.)
473
+ wrapper.classList.remove(
474
+ 'tvw-bottom-6', 'tvw-right-6', 'tvw-left-6', 'tvw-top-6',
475
+ 'tvw-bottom-4', 'tvw-right-4', 'tvw-left-4', 'tvw-top-4'
476
+ );
477
+
478
+ // Wrapper - fixed position, flush with edges
479
+ wrapper.style.cssText = `
480
+ position: fixed !important;
481
+ top: 0 !important;
482
+ bottom: 0 !important;
483
+ width: ${sidebarWidth} !important;
484
+ height: 100vh !important;
485
+ max-height: 100vh !important;
486
+ margin: 0 !important;
487
+ padding: 0 !important;
488
+ display: flex !important;
489
+ flex-direction: column !important;
490
+ ${isLeftSidebar ? 'left: 0 !important; right: auto !important;' : 'left: auto !important; right: 0 !important;'}
491
+ `;
492
+
493
+ // Panel - fill wrapper (override inline width/max-width from panel.ts)
494
+ panel.style.cssText = `
495
+ position: relative !important;
496
+ display: flex !important;
497
+ flex-direction: column !important;
498
+ flex: 1 1 0% !important;
499
+ width: 100% !important;
500
+ max-width: 100% !important;
501
+ height: 100% !important;
502
+ min-height: 0 !important;
503
+ margin: 0 !important;
504
+ padding: 0 !important;
505
+ `;
506
+ // Force override any inline width/maxWidth that may be set elsewhere
507
+ panel.style.setProperty('width', '100%', 'important');
508
+ panel.style.setProperty('max-width', '100%', 'important');
509
+
510
+ // Container - apply configurable styles with sidebar layout
511
+ container.style.cssText = `
512
+ display: flex !important;
513
+ flex-direction: column !important;
514
+ flex: 1 1 0% !important;
515
+ width: 100% !important;
516
+ height: 100% !important;
517
+ min-height: 0 !important;
518
+ max-height: 100% !important;
519
+ overflow: hidden !important;
520
+ border-radius: ${panelBorderRadius} !important;
521
+ border: ${panelBorder} !important;
522
+ box-shadow: ${panelShadow} !important;
523
+ `;
524
+
525
+ // Remove footer border in sidebar mode
526
+ footer.style.cssText = `
527
+ flex-shrink: 0 !important;
528
+ border-top: none !important;
529
+ padding: 8px 16px 12px 16px !important;
530
+ `;
531
+ }
532
+ };
533
+ applyFullHeightStyles();
534
+
251
535
  const destroyCallbacks: Array<() => void> = [];
252
536
  const suggestionsManager = createSuggestions(suggestions);
253
537
  let closeHandler: (() => void) | null = null;
@@ -506,6 +790,9 @@ export const createAgentExperience = (
506
790
  return false;
507
791
  });
508
792
 
793
+ // Get message layout config
794
+ const messageLayoutConfig = config.layout?.messages;
795
+
509
796
  if (matchingPlugin) {
510
797
  if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
511
798
  if (!showReasoning) return;
@@ -525,7 +812,7 @@ export const createAgentExperience = (
525
812
  bubble = matchingPlugin.renderMessage({
526
813
  message,
527
814
  defaultRenderer: () => {
528
- const b = createStandardBubble(message, transform);
815
+ const b = createStandardBubble(message, transform, messageLayoutConfig);
529
816
  if (message.role !== "user") {
530
817
  enhanceWithForms(b, message, config, session);
531
818
  }
@@ -590,8 +877,24 @@ export const createAgentExperience = (
590
877
  if (!showToolCalls) return;
591
878
  bubble = createToolBubble(message, config);
592
879
  } else {
593
- bubble = createStandardBubble(message, transform);
594
- if (message.role !== "user") {
880
+ // Check for custom message renderers in layout config
881
+ const messageLayoutConfig = config.layout?.messages;
882
+ if (messageLayoutConfig?.renderUserMessage && message.role === "user") {
883
+ bubble = messageLayoutConfig.renderUserMessage({
884
+ message,
885
+ config,
886
+ streaming: Boolean(message.streaming)
887
+ });
888
+ } else if (messageLayoutConfig?.renderAssistantMessage && message.role === "assistant") {
889
+ bubble = messageLayoutConfig.renderAssistantMessage({
890
+ message,
891
+ config,
892
+ streaming: Boolean(message.streaming)
893
+ });
894
+ } else {
895
+ bubble = createStandardBubble(message, transform, messageLayoutConfig);
896
+ }
897
+ if (message.role !== "user" && bubble) {
595
898
  enhanceWithForms(bubble, message, config, session);
596
899
  }
597
900
  }
@@ -662,6 +965,8 @@ export const createAgentExperience = (
662
965
  // Hide launcher button when widget is open
663
966
  if (launcherButtonInstance) {
664
967
  launcherButtonInstance.element.style.display = "none";
968
+ } else if (customLauncherElement) {
969
+ customLauncherElement.style.display = "none";
665
970
  }
666
971
  } else {
667
972
  wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
@@ -670,6 +975,8 @@ export const createAgentExperience = (
670
975
  // Show launcher button when widget is closed
671
976
  if (launcherButtonInstance) {
672
977
  launcherButtonInstance.element.style.display = "";
978
+ } else if (customLauncherElement) {
979
+ customLauncherElement.style.display = "";
673
980
  }
674
981
  }
675
982
  };
@@ -1163,12 +1470,36 @@ export const createAgentExperience = (
1163
1470
  setOpenState(!open, "user");
1164
1471
  };
1165
1472
 
1166
- let launcherButtonInstance = launcherEnabled
1167
- ? createLauncherButton(config, toggleOpen)
1168
- : null;
1473
+ // Plugin hook: renderLauncher - allow plugins to provide custom launcher
1474
+ let launcherButtonInstance: ReturnType<typeof createLauncherButton> | null = null;
1475
+ let customLauncherElement: HTMLElement | null = null;
1476
+
1477
+ if (launcherEnabled) {
1478
+ const launcherPlugin = plugins.find(p => p.renderLauncher);
1479
+ if (launcherPlugin?.renderLauncher) {
1480
+ const customLauncher = launcherPlugin.renderLauncher({
1481
+ config,
1482
+ defaultRenderer: () => {
1483
+ const btn = createLauncherButton(config, toggleOpen);
1484
+ return btn.element;
1485
+ },
1486
+ onToggle: toggleOpen
1487
+ });
1488
+ if (customLauncher) {
1489
+ customLauncherElement = customLauncher;
1490
+ }
1491
+ }
1492
+
1493
+ // Use custom launcher if provided, otherwise use default
1494
+ if (!customLauncherElement) {
1495
+ launcherButtonInstance = createLauncherButton(config, toggleOpen);
1496
+ }
1497
+ }
1169
1498
 
1170
1499
  if (launcherButtonInstance) {
1171
1500
  mount.appendChild(launcherButtonInstance.element);
1501
+ } else if (customLauncherElement) {
1502
+ mount.appendChild(customLauncherElement);
1172
1503
  }
1173
1504
  updateOpenState();
1174
1505
  suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
@@ -1178,20 +1509,31 @@ export const createAgentExperience = (
1178
1509
  maybeRestoreVoiceFromMetadata();
1179
1510
 
1180
1511
  const recalcPanelHeight = () => {
1512
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
1513
+ const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
1514
+
1181
1515
  if (!launcherEnabled) {
1182
1516
  panel.style.height = "";
1183
1517
  panel.style.width = "";
1184
1518
  return;
1185
1519
  }
1186
- const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
1187
- const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
1188
- panel.style.width = width;
1189
- panel.style.maxWidth = width;
1190
- const viewportHeight = window.innerHeight;
1191
- const verticalMargin = 64; // leave space for launcher's offset
1192
- const available = Math.max(200, viewportHeight - verticalMargin);
1193
- const clamped = Math.min(640, available);
1194
- panel.style.height = `${clamped}px`;
1520
+
1521
+ // In sidebar/fullHeight mode, don't override the width - it's handled by applyFullHeightStyles
1522
+ if (!sidebarMode) {
1523
+ const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
1524
+ const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
1525
+ panel.style.width = width;
1526
+ panel.style.maxWidth = width;
1527
+ }
1528
+
1529
+ // In fullHeight mode, don't set a fixed height
1530
+ if (!fullHeight) {
1531
+ const viewportHeight = window.innerHeight;
1532
+ const verticalMargin = 64; // leave space for launcher's offset
1533
+ const available = Math.max(200, viewportHeight - verticalMargin);
1534
+ const clamped = Math.min(640, available);
1535
+ panel.style.height = `${clamped}px`;
1536
+ }
1195
1537
  };
1196
1538
 
1197
1539
  recalcPanelHeight();
@@ -1328,6 +1670,10 @@ export const createAgentExperience = (
1328
1670
  destroyCallbacks.push(() => {
1329
1671
  launcherButtonInstance?.destroy();
1330
1672
  });
1673
+ } else if (customLauncherElement) {
1674
+ destroyCallbacks.push(() => {
1675
+ customLauncherElement?.remove();
1676
+ });
1331
1677
  }
1332
1678
 
1333
1679
  const controller: Controller = {
@@ -1335,6 +1681,7 @@ export const createAgentExperience = (
1335
1681
  const previousToolCallConfig = config.toolCall;
1336
1682
  config = { ...config, ...nextConfig };
1337
1683
  applyThemeVariables(mount, config);
1684
+ applyFullHeightStyles();
1338
1685
 
1339
1686
  // Update plugins
1340
1687
  const newPlugins = pluginRegistry.getForInstance(config.plugins);
@@ -1350,15 +1697,38 @@ export const createAgentExperience = (
1350
1697
  launcherButtonInstance.destroy();
1351
1698
  launcherButtonInstance = null;
1352
1699
  }
1700
+ if (config.launcher?.enabled === false && customLauncherElement) {
1701
+ customLauncherElement.remove();
1702
+ customLauncherElement = null;
1703
+ }
1353
1704
 
1354
- if (config.launcher?.enabled !== false && !launcherButtonInstance) {
1355
- launcherButtonInstance = createLauncherButton(config, toggleOpen);
1356
- mount.appendChild(launcherButtonInstance.element);
1705
+ if (config.launcher?.enabled !== false && !launcherButtonInstance && !customLauncherElement) {
1706
+ // Check for launcher plugin when re-enabling
1707
+ const launcherPlugin = plugins.find(p => p.renderLauncher);
1708
+ if (launcherPlugin?.renderLauncher) {
1709
+ const customLauncher = launcherPlugin.renderLauncher({
1710
+ config,
1711
+ defaultRenderer: () => {
1712
+ const btn = createLauncherButton(config, toggleOpen);
1713
+ return btn.element;
1714
+ },
1715
+ onToggle: toggleOpen
1716
+ });
1717
+ if (customLauncher) {
1718
+ customLauncherElement = customLauncher;
1719
+ mount.appendChild(customLauncherElement);
1720
+ }
1721
+ }
1722
+ if (!customLauncherElement) {
1723
+ launcherButtonInstance = createLauncherButton(config, toggleOpen);
1724
+ mount.appendChild(launcherButtonInstance.element);
1725
+ }
1357
1726
  }
1358
1727
 
1359
1728
  if (launcherButtonInstance) {
1360
1729
  launcherButtonInstance.update(config);
1361
1730
  }
1731
+ // Note: Custom launcher updates are handled by the plugin's own logic
1362
1732
 
1363
1733
  // Update panel header title and subtitle
1364
1734
  if (headerTitle && config.launcher?.title !== undefined) {
@@ -1487,25 +1857,29 @@ export const createAgentExperience = (
1487
1857
  closeButton.style.height = closeButtonSize;
1488
1858
  closeButton.style.width = closeButtonSize;
1489
1859
 
1490
- // Update placement if changed
1860
+ // Update placement if changed - move the wrapper (not just the button) to preserve tooltip
1861
+ const { closeButtonWrapper } = panelElements;
1491
1862
  const isTopRight = closeButtonPlacement === "top-right";
1492
- const hasTopRightClasses = closeButton.classList.contains("tvw-absolute");
1863
+ const currentlyTopRight = closeButtonWrapper?.classList.contains("tvw-absolute");
1493
1864
 
1494
- if (isTopRight !== hasTopRightClasses) {
1495
- // Placement changed - need to move button and update classes
1496
- closeButton.remove();
1865
+ if (closeButtonWrapper && isTopRight !== currentlyTopRight) {
1866
+ // Placement changed - need to move wrapper and update classes
1867
+ closeButtonWrapper.remove();
1497
1868
 
1498
- // Update classes
1869
+ // Update wrapper classes
1499
1870
  if (isTopRight) {
1500
- closeButton.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50 tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none";
1871
+ closeButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50";
1501
1872
  container.style.position = "relative";
1502
- container.appendChild(closeButton);
1873
+ container.appendChild(closeButtonWrapper);
1503
1874
  } else {
1504
- closeButton.className = "tvw-ml-auto tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none";
1505
- // Find header element (first child of container)
1875
+ // Check if clear chat is inline to determine if we need ml-auto
1876
+ const clearChatPlacement = launcher.clearChat?.placement ?? "inline";
1877
+ const clearChatEnabled = launcher.clearChat?.enabled ?? true;
1878
+ closeButtonWrapper.className = (clearChatEnabled && clearChatPlacement === "inline") ? "" : "tvw-ml-auto";
1879
+ // Find header element
1506
1880
  const header = container.querySelector(".tvw-border-b-cw-divider");
1507
1881
  if (header) {
1508
- header.appendChild(closeButton);
1882
+ header.appendChild(closeButtonWrapper);
1509
1883
  }
1510
1884
  }
1511
1885
  }
@@ -1576,7 +1950,6 @@ export const createAgentExperience = (
1576
1950
  }
1577
1951
 
1578
1952
  // Update tooltip
1579
- const { closeButtonWrapper } = panelElements;
1580
1953
  const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
1581
1954
  const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
1582
1955
 
@@ -1652,10 +2025,54 @@ export const createAgentExperience = (
1652
2025
  if (clearChatButton) {
1653
2026
  const clearChatConfig = launcher.clearChat ?? {};
1654
2027
  const clearChatEnabled = clearChatConfig.enabled ?? true;
2028
+ const clearChatPlacement = clearChatConfig.placement ?? "inline";
1655
2029
 
1656
2030
  // Show/hide button based on enabled state
1657
2031
  if (clearChatButtonWrapper) {
1658
2032
  clearChatButtonWrapper.style.display = clearChatEnabled ? "" : "none";
2033
+
2034
+ // Update placement if changed
2035
+ const isTopRight = clearChatPlacement === "top-right";
2036
+ const currentlyTopRight = clearChatButtonWrapper.classList.contains("tvw-absolute");
2037
+
2038
+ if (isTopRight !== currentlyTopRight && clearChatEnabled) {
2039
+ clearChatButtonWrapper.remove();
2040
+
2041
+ if (isTopRight) {
2042
+ // Don't use tvw-clear-chat-button-wrapper class for top-right mode as its
2043
+ // display: inline-flex causes alignment issues with the close button
2044
+ clearChatButtonWrapper.className = "tvw-absolute tvw-top-4 tvw-z-50";
2045
+ // Position to the left of the close button (which is at right: 1rem/16px)
2046
+ // Close button is ~32px wide, plus small gap = 48px from right
2047
+ clearChatButtonWrapper.style.right = "48px";
2048
+ container.style.position = "relative";
2049
+ container.appendChild(clearChatButtonWrapper);
2050
+ } else {
2051
+ clearChatButtonWrapper.className = "tvw-relative tvw-ml-auto tvw-clear-chat-button-wrapper";
2052
+ // Clear the inline right style when switching back to inline mode
2053
+ clearChatButtonWrapper.style.right = "";
2054
+ // Find header and insert before close button
2055
+ const header = container.querySelector(".tvw-border-b-cw-divider");
2056
+ const closeButtonWrapperEl = panelElements.closeButtonWrapper;
2057
+ if (header && closeButtonWrapperEl && closeButtonWrapperEl.parentElement === header) {
2058
+ header.insertBefore(clearChatButtonWrapper, closeButtonWrapperEl);
2059
+ } else if (header) {
2060
+ header.appendChild(clearChatButtonWrapper);
2061
+ }
2062
+ }
2063
+
2064
+ // Also update close button's ml-auto class based on clear chat position
2065
+ const closeButtonWrapperEl = panelElements.closeButtonWrapper;
2066
+ if (closeButtonWrapperEl && !closeButtonWrapperEl.classList.contains("tvw-absolute")) {
2067
+ if (isTopRight) {
2068
+ // Clear chat moved to top-right, close needs ml-auto
2069
+ closeButtonWrapperEl.classList.add("tvw-ml-auto");
2070
+ } else {
2071
+ // Clear chat is inline, close doesn't need ml-auto
2072
+ closeButtonWrapperEl.classList.remove("tvw-ml-auto");
2073
+ }
2074
+ }
2075
+ }
1659
2076
  }
1660
2077
 
1661
2078
  if (clearChatEnabled) {
@@ -2284,6 +2701,7 @@ export const createAgentExperience = (
2284
2701
  destroyCallbacks.forEach((cb) => cb());
2285
2702
  wrapper.remove();
2286
2703
  launcherButtonInstance?.destroy();
2704
+ customLauncherElement?.remove();
2287
2705
  if (closeHandler) {
2288
2706
  closeButton.removeEventListener("click", closeHandler);
2289
2707
  }