vanilla-agent 1.29.0 → 1.31.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
@@ -17,15 +17,17 @@ import {
17
17
  import { applyThemeVariables, createThemeObserver } from "./utils/theme";
18
18
  import { renderLucideIcon } from "./utils/icons";
19
19
  import { createElement } from "./utils/dom";
20
+ import { morphMessages } from "./utils/morph";
20
21
  import { statusCopy } from "./utils/constants";
21
22
  import { createLauncherButton } from "./components/launcher";
22
23
  import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
24
+ import { buildHeaderWithLayout } from "./components/header-layouts";
23
25
  import { positionMap } from "./utils/positioning";
24
26
  import type { HeaderElements, ComposerElements } from "./components/panel";
25
27
  import { MessageTransform, MessageActionCallbacks } from "./components/message-bubble";
26
28
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
27
- import { createReasoningBubble } from "./components/reasoning-bubble";
28
- import { createToolBubble } from "./components/tool-bubble";
29
+ import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
30
+ import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
29
31
  import { createSuggestions } from "./components/suggestions";
30
32
  import { enhanceWithForms } from "./components/forms";
31
33
  import { pluginRegistry } from "./plugins/registry";
@@ -235,6 +237,7 @@ export const createAgentExperience = (
235
237
  let autoExpand = config.launcher?.autoExpand ?? false;
236
238
  let prevAutoExpand = autoExpand;
237
239
  let prevLauncherEnabled = launcherEnabled;
240
+ let prevHeaderLayout = config.layout?.header?.layout;
238
241
  let open = launcherEnabled ? autoExpand : true;
239
242
  let postprocess = buildPostprocessor(config, actionManager);
240
243
  let showReasoning = config.features?.showReasoning ?? true;
@@ -450,6 +453,60 @@ export const createAgentExperience = (
450
453
  // Render custom slots
451
454
  renderSlots();
452
455
 
456
+ // Add event delegation for reasoning and tool bubble expansion
457
+ // This handles clicks even after idiomorph morphs the DOM
458
+ const handleBubbleExpansion = (event: Event) => {
459
+ const target = event.target as HTMLElement;
460
+
461
+ // Check if the click/keypress is on an expand header button
462
+ const headerButton = target.closest('button[data-expand-header="true"]') as HTMLElement;
463
+ if (!headerButton) return;
464
+
465
+ // Find the parent bubble element
466
+ const bubble = headerButton.closest('.vanilla-reasoning-bubble, .vanilla-tool-bubble') as HTMLElement;
467
+ if (!bubble) return;
468
+
469
+ // Get message ID from bubble
470
+ const messageId = bubble.getAttribute('data-message-id');
471
+ if (!messageId) return;
472
+
473
+ const bubbleType = headerButton.getAttribute('data-bubble-type');
474
+
475
+ // Toggle expansion state
476
+ if (bubbleType === 'reasoning') {
477
+ if (reasoningExpansionState.has(messageId)) {
478
+ reasoningExpansionState.delete(messageId);
479
+ } else {
480
+ reasoningExpansionState.add(messageId);
481
+ }
482
+ updateReasoningBubbleUI(messageId, bubble);
483
+ } else if (bubbleType === 'tool') {
484
+ if (toolExpansionState.has(messageId)) {
485
+ toolExpansionState.delete(messageId);
486
+ } else {
487
+ toolExpansionState.add(messageId);
488
+ }
489
+ updateToolBubbleUI(messageId, bubble, config);
490
+ }
491
+ };
492
+
493
+ // Attach event listeners to messagesWrapper for event delegation
494
+ messagesWrapper.addEventListener('pointerdown', (event) => {
495
+ const target = event.target as HTMLElement;
496
+ if (target.closest('button[data-expand-header="true"]')) {
497
+ event.preventDefault();
498
+ handleBubbleExpansion(event);
499
+ }
500
+ });
501
+
502
+ messagesWrapper.addEventListener('keydown', (event) => {
503
+ const target = event.target as HTMLElement;
504
+ if ((event.key === 'Enter' || event.key === ' ') && target.closest('button[data-expand-header="true"]')) {
505
+ event.preventDefault();
506
+ handleBubbleExpansion(event);
507
+ }
508
+ });
509
+
453
510
  panel.appendChild(container);
454
511
  mount.appendChild(wrapper);
455
512
 
@@ -904,8 +961,8 @@ export const createAgentExperience = (
904
961
  messages: AgentWidgetMessage[],
905
962
  transform: MessageTransform
906
963
  ) => {
907
- container.innerHTML = "";
908
- const fragment = document.createDocumentFragment();
964
+ // Build new content in a temporary container for morphing
965
+ const tempContainer = document.createElement("div");
909
966
 
910
967
  messages.forEach((message) => {
911
968
  let bubble: HTMLElement | null = null;
@@ -976,8 +1033,8 @@ export const createAgentExperience = (
976
1033
  });
977
1034
  if (componentBubble) {
978
1035
  // Wrap component in standard bubble styling
979
- const wrapper = document.createElement("div");
980
- wrapper.className = [
1036
+ const componentWrapper = document.createElement("div");
1037
+ componentWrapper.className = [
981
1038
  "vanilla-message-bubble",
982
1039
  "tvw-max-w-[85%]",
983
1040
  "tvw-rounded-2xl",
@@ -986,7 +1043,9 @@ export const createAgentExperience = (
986
1043
  "tvw-border-cw-message-border",
987
1044
  "tvw-p-4"
988
1045
  ].join(" ");
989
- wrapper.setAttribute("data-message-id", message.id);
1046
+ // Set id for idiomorph matching
1047
+ componentWrapper.id = `bubble-${message.id}`;
1048
+ componentWrapper.setAttribute("data-message-id", message.id);
990
1049
 
991
1050
  // Add text content above component if present (combined text+component response)
992
1051
  if (message.content && message.content.trim()) {
@@ -998,11 +1057,11 @@ export const createAgentExperience = (
998
1057
  streaming: Boolean(message.streaming),
999
1058
  raw: message.rawContent
1000
1059
  });
1001
- wrapper.appendChild(textDiv);
1060
+ componentWrapper.appendChild(textDiv);
1002
1061
  }
1003
1062
 
1004
- wrapper.appendChild(componentBubble);
1005
- bubble = wrapper;
1063
+ componentWrapper.appendChild(componentBubble);
1064
+ bubble = componentWrapper;
1006
1065
  }
1007
1066
  }
1008
1067
  }
@@ -1048,11 +1107,14 @@ export const createAgentExperience = (
1048
1107
 
1049
1108
  const wrapper = document.createElement("div");
1050
1109
  wrapper.className = "tvw-flex";
1110
+ // Set id for idiomorph matching
1111
+ wrapper.id = `wrapper-${message.id}`;
1112
+ wrapper.setAttribute("data-wrapper-id", message.id);
1051
1113
  if (message.role === "user") {
1052
1114
  wrapper.classList.add("tvw-justify-end");
1053
1115
  }
1054
1116
  wrapper.appendChild(bubble);
1055
- fragment.appendChild(wrapper);
1117
+ tempContainer.appendChild(wrapper);
1056
1118
  });
1057
1119
 
1058
1120
  // Add standalone typing indicator only if streaming but no assistant message is streaming yet
@@ -1085,16 +1147,21 @@ export const createAgentExperience = (
1085
1147
  "tvw-px-5",
1086
1148
  "tvw-py-3"
1087
1149
  ].join(" ");
1150
+ typingBubble.setAttribute("data-typing-indicator", "true");
1088
1151
 
1089
1152
  typingBubble.appendChild(typingIndicator);
1090
1153
 
1091
1154
  const typingWrapper = document.createElement("div");
1092
1155
  typingWrapper.className = "tvw-flex";
1156
+ // Set id for idiomorph matching
1157
+ typingWrapper.id = "wrapper-typing-indicator";
1158
+ typingWrapper.setAttribute("data-wrapper-id", "typing-indicator");
1093
1159
  typingWrapper.appendChild(typingBubble);
1094
- fragment.appendChild(typingWrapper);
1160
+ tempContainer.appendChild(typingWrapper);
1095
1161
  }
1096
1162
 
1097
- container.appendChild(fragment);
1163
+ // Use idiomorph to morph the container contents
1164
+ morphMessages(container, tempContainer);
1098
1165
  // Defer scroll to next frame for smoother animation and to prevent jolt
1099
1166
  // This allows the browser to update layout (e.g., typing indicator removal) before scrolling
1100
1167
  // Use double RAF to ensure layout has fully settled before starting scroll animation
@@ -1902,6 +1969,78 @@ export const createAgentExperience = (
1902
1969
  headerSubtitle.textContent = config.launcher.subtitle;
1903
1970
  }
1904
1971
 
1972
+ // Update header layout if it changed
1973
+ const headerLayoutConfig = config.layout?.header;
1974
+ const headerLayoutChanged = headerLayoutConfig?.layout !== prevHeaderLayout;
1975
+
1976
+ if (headerLayoutChanged && header) {
1977
+ // Rebuild header with new layout
1978
+ const newHeaderElements = headerLayoutConfig
1979
+ ? buildHeaderWithLayout(config, headerLayoutConfig, {
1980
+ showClose: launcherEnabled,
1981
+ onClose: () => setOpenState(false, "user")
1982
+ })
1983
+ : buildHeader({
1984
+ config,
1985
+ showClose: launcherEnabled,
1986
+ onClose: () => setOpenState(false, "user")
1987
+ });
1988
+
1989
+ // Replace the old header with the new one
1990
+ header.replaceWith(newHeaderElements.header);
1991
+
1992
+ // Update references
1993
+ header = newHeaderElements.header;
1994
+ iconHolder = newHeaderElements.iconHolder;
1995
+ headerTitle = newHeaderElements.headerTitle;
1996
+ headerSubtitle = newHeaderElements.headerSubtitle;
1997
+ closeButton = newHeaderElements.closeButton;
1998
+
1999
+ prevHeaderLayout = headerLayoutConfig?.layout;
2000
+ } else if (headerLayoutConfig) {
2001
+ // Apply visibility settings without rebuilding
2002
+ if (iconHolder) {
2003
+ iconHolder.style.display = headerLayoutConfig.showIcon === false ? "none" : "";
2004
+ }
2005
+ if (headerTitle) {
2006
+ headerTitle.style.display = headerLayoutConfig.showTitle === false ? "none" : "";
2007
+ }
2008
+ if (headerSubtitle) {
2009
+ headerSubtitle.style.display = headerLayoutConfig.showSubtitle === false ? "none" : "";
2010
+ }
2011
+ if (closeButton) {
2012
+ closeButton.style.display = headerLayoutConfig.showCloseButton === false ? "none" : "";
2013
+ }
2014
+ if (panelElements.clearChatButtonWrapper) {
2015
+ // showClearChat explicitly controls visibility when set
2016
+ const showClearChat = headerLayoutConfig.showClearChat;
2017
+ if (showClearChat !== undefined) {
2018
+ panelElements.clearChatButtonWrapper.style.display = showClearChat ? "" : "none";
2019
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned
2020
+ const { closeButtonWrapper } = panelElements;
2021
+ if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
2022
+ if (showClearChat) {
2023
+ closeButtonWrapper.classList.remove("tvw-ml-auto");
2024
+ } else {
2025
+ closeButtonWrapper.classList.add("tvw-ml-auto");
2026
+ }
2027
+ }
2028
+ }
2029
+ }
2030
+ }
2031
+
2032
+ // Update header visibility based on layout.showHeader
2033
+ const showHeader = config.layout?.showHeader !== false; // default to true
2034
+ if (header) {
2035
+ header.style.display = showHeader ? "" : "none";
2036
+ }
2037
+
2038
+ // Update footer visibility based on layout.showFooter
2039
+ const showFooter = config.layout?.showFooter !== false; // default to true
2040
+ if (footer) {
2041
+ footer.style.display = showFooter ? "" : "none";
2042
+ }
2043
+
1905
2044
  // Only update open state if launcher enabled state changed or autoExpand value changed
1906
2045
  const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
1907
2046
  const autoExpandChanged = autoExpand !== prevAutoExpand;
@@ -1937,20 +2076,23 @@ export const createAgentExperience = (
1937
2076
  // Update panel icon sizes
1938
2077
  const launcher = config.launcher ?? {};
1939
2078
  const headerIconHidden = launcher.headerIconHidden ?? false;
2079
+ const layoutShowIcon = config.layout?.header?.showIcon;
2080
+ // Hide icon if either headerIconHidden is true OR layout.header.showIcon is false
2081
+ const shouldHideIcon = headerIconHidden || layoutShowIcon === false;
1940
2082
  const headerIconName = launcher.headerIconName;
1941
2083
  const headerIconSize = launcher.headerIconSize ?? "48px";
1942
-
2084
+
1943
2085
  if (iconHolder) {
1944
- const header = container.querySelector(".tvw-border-b-cw-divider");
1945
- const headerCopy = header?.querySelector(".tvw-flex-col");
1946
-
2086
+ const headerEl = container.querySelector(".tvw-border-b-cw-divider");
2087
+ const headerCopy = headerEl?.querySelector(".tvw-flex-col");
2088
+
1947
2089
  // Handle hide/show
1948
- if (headerIconHidden) {
2090
+ if (shouldHideIcon) {
1949
2091
  // Hide iconHolder
1950
2092
  iconHolder.style.display = "none";
1951
2093
  // Ensure headerCopy is still in header
1952
- if (header && headerCopy && !header.contains(headerCopy)) {
1953
- header.insertBefore(headerCopy, header.firstChild);
2094
+ if (headerEl && headerCopy && !headerEl.contains(headerCopy)) {
2095
+ headerEl.insertBefore(headerCopy, headerEl.firstChild);
1954
2096
  }
1955
2097
  } else {
1956
2098
  // Show iconHolder
@@ -1959,13 +2101,13 @@ export const createAgentExperience = (
1959
2101
  iconHolder.style.width = headerIconSize;
1960
2102
 
1961
2103
  // Ensure iconHolder is before headerCopy in header
1962
- if (header && headerCopy) {
1963
- if (!header.contains(iconHolder)) {
1964
- header.insertBefore(iconHolder, headerCopy);
2104
+ if (headerEl && headerCopy) {
2105
+ if (!headerEl.contains(iconHolder)) {
2106
+ headerEl.insertBefore(iconHolder, headerCopy);
1965
2107
  } else if (iconHolder.nextSibling !== headerCopy) {
1966
2108
  // Reorder if needed
1967
2109
  iconHolder.remove();
1968
- header.insertBefore(iconHolder, headerCopy);
2110
+ headerEl.insertBefore(iconHolder, headerCopy);
1969
2111
  }
1970
2112
  }
1971
2113
 
@@ -2015,7 +2157,26 @@ export const createAgentExperience = (
2015
2157
  }
2016
2158
  }
2017
2159
  }
2160
+
2161
+ // Handle title/subtitle visibility from layout config
2162
+ const layoutShowTitle = config.layout?.header?.showTitle;
2163
+ const layoutShowSubtitle = config.layout?.header?.showSubtitle;
2164
+ if (headerTitle) {
2165
+ headerTitle.style.display = layoutShowTitle === false ? "none" : "";
2166
+ }
2167
+ if (headerSubtitle) {
2168
+ headerSubtitle.style.display = layoutShowSubtitle === false ? "none" : "";
2169
+ }
2170
+
2018
2171
  if (closeButton) {
2172
+ // Handle close button visibility from layout config
2173
+ const layoutShowCloseButton = config.layout?.header?.showCloseButton;
2174
+ if (layoutShowCloseButton === false) {
2175
+ closeButton.style.display = "none";
2176
+ } else {
2177
+ closeButton.style.display = "";
2178
+ }
2179
+
2019
2180
  const closeButtonSize = launcher.closeButtonSize ?? "32px";
2020
2181
  const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
2021
2182
  closeButton.style.height = closeButtonSize;
@@ -2189,17 +2350,33 @@ export const createAgentExperience = (
2189
2350
  if (clearChatButton) {
2190
2351
  const clearChatConfig = launcher.clearChat ?? {};
2191
2352
  const clearChatEnabled = clearChatConfig.enabled ?? true;
2353
+ const layoutShowClearChat = config.layout?.header?.showClearChat;
2354
+ // layout.header.showClearChat takes precedence if explicitly set
2355
+ // Otherwise fall back to launcher.clearChat.enabled
2356
+ const shouldShowClearChat = layoutShowClearChat !== undefined
2357
+ ? layoutShowClearChat
2358
+ : clearChatEnabled;
2192
2359
  const clearChatPlacement = clearChatConfig.placement ?? "inline";
2193
2360
 
2194
- // Show/hide button based on enabled state
2361
+ // Show/hide button based on layout config (primary) or launcher config (fallback)
2195
2362
  if (clearChatButtonWrapper) {
2196
- clearChatButtonWrapper.style.display = clearChatEnabled ? "" : "none";
2363
+ clearChatButtonWrapper.style.display = shouldShowClearChat ? "" : "none";
2364
+
2365
+ // When clear chat is hidden, close button needs ml-auto to stay right-aligned
2366
+ const { closeButtonWrapper } = panelElements;
2367
+ if (closeButtonWrapper && !closeButtonWrapper.classList.contains("tvw-absolute")) {
2368
+ if (shouldShowClearChat) {
2369
+ closeButtonWrapper.classList.remove("tvw-ml-auto");
2370
+ } else {
2371
+ closeButtonWrapper.classList.add("tvw-ml-auto");
2372
+ }
2373
+ }
2197
2374
 
2198
2375
  // Update placement if changed
2199
2376
  const isTopRight = clearChatPlacement === "top-right";
2200
2377
  const currentlyTopRight = clearChatButtonWrapper.classList.contains("tvw-absolute");
2201
2378
 
2202
- if (isTopRight !== currentlyTopRight && clearChatEnabled) {
2379
+ if (isTopRight !== currentlyTopRight && shouldShowClearChat) {
2203
2380
  clearChatButtonWrapper.remove();
2204
2381
 
2205
2382
  if (isTopRight) {
@@ -2239,7 +2416,7 @@ export const createAgentExperience = (
2239
2416
  }
2240
2417
  }
2241
2418
 
2242
- if (clearChatEnabled) {
2419
+ if (shouldShowClearChat) {
2243
2420
  // Update size
2244
2421
  const clearChatSize = clearChatConfig.size ?? "32px";
2245
2422
  clearChatButton.style.height = clearChatSize;
@@ -0,0 +1,36 @@
1
+ import { Idiomorph } from "idiomorph";
2
+
3
+ export type MorphOptions = {
4
+ preserveTypingAnimation?: boolean;
5
+ };
6
+
7
+ /**
8
+ * Morph a container's contents using idiomorph with chat-widget-specific
9
+ * preservation rules for typing indicators.
10
+ *
11
+ * Action buttons are matched by their `id` attribute (set to `actions-{messageId}`)
12
+ * so idiomorph updates them in place rather than recreating them.
13
+ */
14
+ export const morphMessages = (
15
+ container: HTMLElement,
16
+ newContent: HTMLElement,
17
+ options: MorphOptions = {}
18
+ ): void => {
19
+ const { preserveTypingAnimation = true } = options;
20
+
21
+ Idiomorph.morph(container, newContent.innerHTML, {
22
+ morphStyle: "innerHTML",
23
+ callbacks: {
24
+ beforeNodeMorphed(oldNode: Node, newNode: Node): boolean | void {
25
+ if (!(oldNode instanceof HTMLElement)) return;
26
+
27
+ // Preserve typing indicator dots to maintain animation continuity
28
+ if (preserveTypingAnimation) {
29
+ if (oldNode.classList.contains("tvw-animate-typing")) {
30
+ return false;
31
+ }
32
+ }
33
+ },
34
+ },
35
+ });
36
+ };