vanilla-agent 1.9.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";
@@ -31,6 +34,13 @@ import {
31
34
  defaultActionHandlers,
32
35
  defaultJsonActionParser
33
36
  } from "./utils/actions";
37
+ import { createLocalStorageAdapter } from "./utils/storage";
38
+ import { componentRegistry } from "./components/registry";
39
+ import {
40
+ renderComponentDirective,
41
+ extractComponentDirectiveFromMessage,
42
+ hasComponentDirective
43
+ } from "./utils/component-middleware";
34
44
 
35
45
  // Default localStorage key for chat history (automatically cleared on clear chat)
36
46
  const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "vanilla-agent-chat-history";
@@ -132,10 +142,15 @@ export const createAgentExperience = (
132
142
 
133
143
  // Get plugins for this instance
134
144
  const plugins = pluginRegistry.getForInstance(config.plugins);
145
+
146
+ // Register components from config
147
+ if (config.components) {
148
+ componentRegistry.registerAll(config.components);
149
+ }
135
150
  const eventBus = createEventBus<AgentWidgetControllerEventMap>();
136
151
 
137
- const storageAdapter: AgentWidgetStorageAdapter | undefined =
138
- config.storageAdapter;
152
+ const storageAdapter: AgentWidgetStorageAdapter =
153
+ config.storageAdapter ?? createLocalStorageAdapter();
139
154
  let persistentMetadata: Record<string, unknown> = {};
140
155
  let pendingStoredState: Promise<AgentWidgetStoredState | null> | null = null;
141
156
 
@@ -211,7 +226,7 @@ export const createAgentExperience = (
211
226
 
212
227
  const { wrapper, panel } = createWrapper(config);
213
228
  const panelElements = buildPanel(config, launcherEnabled);
214
- const {
229
+ let {
215
230
  container,
216
231
  body,
217
232
  messagesWrapper,
@@ -226,16 +241,297 @@ export const createAgentExperience = (
226
241
  closeButton,
227
242
  iconHolder,
228
243
  headerTitle,
229
- headerSubtitle
244
+ headerSubtitle,
245
+ header,
246
+ footer
230
247
  } = panelElements;
231
248
 
232
249
  // Use mutable references for mic button so we can update them dynamically
233
250
  let micButton: HTMLButtonElement | null = panelElements.micButton;
234
251
  let micButtonWrapper: HTMLElement | null = panelElements.micButtonWrapper;
235
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
+
236
395
  panel.appendChild(container);
237
396
  mount.appendChild(wrapper);
238
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
+
239
535
  const destroyCallbacks: Array<() => void> = [];
240
536
  const suggestionsManager = createSuggestions(suggestions);
241
537
  let closeHandler: (() => void) | null = null;
@@ -302,11 +598,17 @@ export const createAgentExperience = (
302
598
  : [];
303
599
 
304
600
  function persistState(messagesOverride?: AgentWidgetMessage[]) {
305
- if (!storageAdapter?.save || !session) return;
601
+ if (!storageAdapter?.save) return;
602
+
603
+ // Allow saving even if session doesn't exist yet (for metadata during init)
604
+ const messages = messagesOverride
605
+ ? stripStreamingFromMessages(messagesOverride)
606
+ : session
607
+ ? getMessagesForPersistence()
608
+ : [];
609
+
306
610
  const payload = {
307
- messages: messagesOverride
308
- ? stripStreamingFromMessages(messagesOverride)
309
- : getMessagesForPersistence(),
611
+ messages,
310
612
  metadata: persistentMetadata
311
613
  };
312
614
  try {
@@ -488,6 +790,9 @@ export const createAgentExperience = (
488
790
  return false;
489
791
  });
490
792
 
793
+ // Get message layout config
794
+ const messageLayoutConfig = config.layout?.messages;
795
+
491
796
  if (matchingPlugin) {
492
797
  if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
493
798
  if (!showReasoning) return;
@@ -507,7 +812,7 @@ export const createAgentExperience = (
507
812
  bubble = matchingPlugin.renderMessage({
508
813
  message,
509
814
  defaultRenderer: () => {
510
- const b = createStandardBubble(message, transform);
815
+ const b = createStandardBubble(message, transform, messageLayoutConfig);
511
816
  if (message.role !== "user") {
512
817
  enhanceWithForms(b, message, config, session);
513
818
  }
@@ -518,6 +823,51 @@ export const createAgentExperience = (
518
823
  }
519
824
  }
520
825
 
826
+ // Check for component directive if no plugin handled it
827
+ if (!bubble && message.role === "assistant" && !message.variant) {
828
+ const enableComponentStreaming = config.enableComponentStreaming !== false; // Default to true
829
+ if (enableComponentStreaming && hasComponentDirective(message)) {
830
+ const directive = extractComponentDirectiveFromMessage(message);
831
+ if (directive) {
832
+ const componentBubble = renderComponentDirective(directive, {
833
+ config,
834
+ message,
835
+ transform
836
+ });
837
+ if (componentBubble) {
838
+ // Wrap component in standard bubble styling
839
+ const wrapper = document.createElement("div");
840
+ wrapper.className = [
841
+ "vanilla-message-bubble",
842
+ "tvw-max-w-[85%]",
843
+ "tvw-rounded-2xl",
844
+ "tvw-bg-cw-surface",
845
+ "tvw-border",
846
+ "tvw-border-cw-message-border",
847
+ "tvw-p-4"
848
+ ].join(" ");
849
+ wrapper.setAttribute("data-message-id", message.id);
850
+
851
+ // Add text content above component if present (combined text+component response)
852
+ if (message.content && message.content.trim()) {
853
+ const textDiv = document.createElement("div");
854
+ textDiv.className = "tvw-mb-3 tvw-text-sm tvw-leading-relaxed";
855
+ textDiv.innerHTML = transform({
856
+ text: message.content,
857
+ message,
858
+ streaming: Boolean(message.streaming),
859
+ raw: message.rawContent
860
+ });
861
+ wrapper.appendChild(textDiv);
862
+ }
863
+
864
+ wrapper.appendChild(componentBubble);
865
+ bubble = wrapper;
866
+ }
867
+ }
868
+ }
869
+ }
870
+
521
871
  // Fallback to default rendering if plugin returned null or no plugin matched
522
872
  if (!bubble) {
523
873
  if (message.variant === "reasoning" && message.reasoning) {
@@ -527,8 +877,24 @@ export const createAgentExperience = (
527
877
  if (!showToolCalls) return;
528
878
  bubble = createToolBubble(message, config);
529
879
  } else {
530
- bubble = createStandardBubble(message, transform);
531
- 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) {
532
898
  enhanceWithForms(bubble, message, config, session);
533
899
  }
534
900
  }
@@ -599,6 +965,8 @@ export const createAgentExperience = (
599
965
  // Hide launcher button when widget is open
600
966
  if (launcherButtonInstance) {
601
967
  launcherButtonInstance.element.style.display = "none";
968
+ } else if (customLauncherElement) {
969
+ customLauncherElement.style.display = "none";
602
970
  }
603
971
  } else {
604
972
  wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
@@ -607,6 +975,8 @@ export const createAgentExperience = (
607
975
  // Show launcher button when widget is closed
608
976
  if (launcherButtonInstance) {
609
977
  launcherButtonInstance.element.style.display = "";
978
+ } else if (customLauncherElement) {
979
+ customLauncherElement.style.display = "";
610
980
  }
611
981
  }
612
982
  };
@@ -1100,12 +1470,36 @@ export const createAgentExperience = (
1100
1470
  setOpenState(!open, "user");
1101
1471
  };
1102
1472
 
1103
- let launcherButtonInstance = launcherEnabled
1104
- ? createLauncherButton(config, toggleOpen)
1105
- : 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
+ }
1106
1498
 
1107
1499
  if (launcherButtonInstance) {
1108
1500
  mount.appendChild(launcherButtonInstance.element);
1501
+ } else if (customLauncherElement) {
1502
+ mount.appendChild(customLauncherElement);
1109
1503
  }
1110
1504
  updateOpenState();
1111
1505
  suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
@@ -1115,20 +1509,31 @@ export const createAgentExperience = (
1115
1509
  maybeRestoreVoiceFromMetadata();
1116
1510
 
1117
1511
  const recalcPanelHeight = () => {
1512
+ const sidebarMode = config.launcher?.sidebarMode ?? false;
1513
+ const fullHeight = sidebarMode || (config.launcher?.fullHeight ?? false);
1514
+
1118
1515
  if (!launcherEnabled) {
1119
1516
  panel.style.height = "";
1120
1517
  panel.style.width = "";
1121
1518
  return;
1122
1519
  }
1123
- const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
1124
- const width = launcherWidth ?? "min(400px, calc(100vw - 24px))";
1125
- panel.style.width = width;
1126
- panel.style.maxWidth = width;
1127
- const viewportHeight = window.innerHeight;
1128
- const verticalMargin = 64; // leave space for launcher's offset
1129
- const available = Math.max(200, viewportHeight - verticalMargin);
1130
- const clamped = Math.min(640, available);
1131
- 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
+ }
1132
1537
  };
1133
1538
 
1134
1539
  recalcPanelHeight();
@@ -1265,6 +1670,10 @@ export const createAgentExperience = (
1265
1670
  destroyCallbacks.push(() => {
1266
1671
  launcherButtonInstance?.destroy();
1267
1672
  });
1673
+ } else if (customLauncherElement) {
1674
+ destroyCallbacks.push(() => {
1675
+ customLauncherElement?.remove();
1676
+ });
1268
1677
  }
1269
1678
 
1270
1679
  const controller: Controller = {
@@ -1272,6 +1681,7 @@ export const createAgentExperience = (
1272
1681
  const previousToolCallConfig = config.toolCall;
1273
1682
  config = { ...config, ...nextConfig };
1274
1683
  applyThemeVariables(mount, config);
1684
+ applyFullHeightStyles();
1275
1685
 
1276
1686
  // Update plugins
1277
1687
  const newPlugins = pluginRegistry.getForInstance(config.plugins);
@@ -1287,15 +1697,38 @@ export const createAgentExperience = (
1287
1697
  launcherButtonInstance.destroy();
1288
1698
  launcherButtonInstance = null;
1289
1699
  }
1700
+ if (config.launcher?.enabled === false && customLauncherElement) {
1701
+ customLauncherElement.remove();
1702
+ customLauncherElement = null;
1703
+ }
1290
1704
 
1291
- if (config.launcher?.enabled !== false && !launcherButtonInstance) {
1292
- launcherButtonInstance = createLauncherButton(config, toggleOpen);
1293
- 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
+ }
1294
1726
  }
1295
1727
 
1296
1728
  if (launcherButtonInstance) {
1297
1729
  launcherButtonInstance.update(config);
1298
1730
  }
1731
+ // Note: Custom launcher updates are handled by the plugin's own logic
1299
1732
 
1300
1733
  // Update panel header title and subtitle
1301
1734
  if (headerTitle && config.launcher?.title !== undefined) {
@@ -1424,25 +1857,29 @@ export const createAgentExperience = (
1424
1857
  closeButton.style.height = closeButtonSize;
1425
1858
  closeButton.style.width = closeButtonSize;
1426
1859
 
1427
- // Update placement if changed
1860
+ // Update placement if changed - move the wrapper (not just the button) to preserve tooltip
1861
+ const { closeButtonWrapper } = panelElements;
1428
1862
  const isTopRight = closeButtonPlacement === "top-right";
1429
- const hasTopRightClasses = closeButton.classList.contains("tvw-absolute");
1863
+ const currentlyTopRight = closeButtonWrapper?.classList.contains("tvw-absolute");
1430
1864
 
1431
- if (isTopRight !== hasTopRightClasses) {
1432
- // Placement changed - need to move button and update classes
1433
- closeButton.remove();
1865
+ if (closeButtonWrapper && isTopRight !== currentlyTopRight) {
1866
+ // Placement changed - need to move wrapper and update classes
1867
+ closeButtonWrapper.remove();
1434
1868
 
1435
- // Update classes
1869
+ // Update wrapper classes
1436
1870
  if (isTopRight) {
1437
- 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";
1438
1872
  container.style.position = "relative";
1439
- container.appendChild(closeButton);
1873
+ container.appendChild(closeButtonWrapper);
1440
1874
  } else {
1441
- 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";
1442
- // 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
1443
1880
  const header = container.querySelector(".tvw-border-b-cw-divider");
1444
1881
  if (header) {
1445
- header.appendChild(closeButton);
1882
+ header.appendChild(closeButtonWrapper);
1446
1883
  }
1447
1884
  }
1448
1885
  }
@@ -1513,7 +1950,6 @@ export const createAgentExperience = (
1513
1950
  }
1514
1951
 
1515
1952
  // Update tooltip
1516
- const { closeButtonWrapper } = panelElements;
1517
1953
  const closeButtonTooltipText = launcher.closeButtonTooltipText ?? "Close chat";
1518
1954
  const closeButtonShowTooltip = launcher.closeButtonShowTooltip ?? true;
1519
1955
 
@@ -1589,10 +2025,54 @@ export const createAgentExperience = (
1589
2025
  if (clearChatButton) {
1590
2026
  const clearChatConfig = launcher.clearChat ?? {};
1591
2027
  const clearChatEnabled = clearChatConfig.enabled ?? true;
2028
+ const clearChatPlacement = clearChatConfig.placement ?? "inline";
1592
2029
 
1593
2030
  // Show/hide button based on enabled state
1594
2031
  if (clearChatButtonWrapper) {
1595
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
+ }
1596
2076
  }
1597
2077
 
1598
2078
  if (clearChatEnabled) {
@@ -2221,6 +2701,7 @@ export const createAgentExperience = (
2221
2701
  destroyCallbacks.forEach((cb) => cb());
2222
2702
  wrapper.remove();
2223
2703
  launcherButtonInstance?.destroy();
2704
+ customLauncherElement?.remove();
2224
2705
  if (closeHandler) {
2225
2706
  closeButton.removeEventListener("click", closeHandler);
2226
2707
  }