vanilla-agent 1.30.0 → 1.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-agent",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "description": "Themeable, plugable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
package/src/client.ts CHANGED
@@ -139,15 +139,21 @@ export class AgentWidgetClient {
139
139
  }
140
140
 
141
141
  private async _doInitSession(): Promise<ClientSession> {
142
+ // Get stored session_id if available (for session resumption)
143
+ const storedSessionId = this.config.getStoredSessionId?.() || null;
144
+
145
+ const requestBody: Record<string, unknown> = {
146
+ token: this.config.clientToken,
147
+ ...(this.config.flowId && { flow_id: this.config.flowId }),
148
+ ...(storedSessionId && { session_id: storedSessionId }),
149
+ };
150
+
142
151
  const response = await fetch(this.getClientApiUrl('init'), {
143
152
  method: 'POST',
144
153
  headers: {
145
154
  'Content-Type': 'application/json',
146
155
  },
147
- body: JSON.stringify({
148
- token: this.config.clientToken,
149
- flow_id: this.config.flowId,
150
- }),
156
+ body: JSON.stringify(requestBody),
151
157
  });
152
158
 
153
159
  if (!response.ok) {
@@ -162,6 +168,12 @@ export class AgentWidgetClient {
162
168
  }
163
169
 
164
170
  const data: ClientInitResponse = await response.json();
171
+
172
+ // Store the new session_id for future resumption
173
+ if (this.config.setStoredSessionId) {
174
+ this.config.setStoredSessionId(data.session_id);
175
+ }
176
+
165
177
  return {
166
178
  sessionId: data.session_id,
167
179
  expiresAt: new Date(data.expires_at),
@@ -381,6 +393,13 @@ export class AgentWidgetClient {
381
393
  const basePayload = await this.buildPayload(options.messages);
382
394
 
383
395
  // Build the chat request payload with message IDs for feedback tracking
396
+ // Filter out session_id from metadata if present (it's only for local storage)
397
+ const sanitizedMetadata = basePayload.metadata
398
+ ? Object.fromEntries(
399
+ Object.entries(basePayload.metadata).filter(([key]) => key !== 'session_id')
400
+ )
401
+ : undefined;
402
+
384
403
  const chatRequest: ClientChatRequest = {
385
404
  session_id: session.sessionId,
386
405
  messages: options.messages.map(m => ({
@@ -390,8 +409,8 @@ export class AgentWidgetClient {
390
409
  })),
391
410
  // Include pre-generated assistant message ID if provided
392
411
  ...(options.assistantMessageId && { assistant_message_id: options.assistantMessageId }),
393
- // Include metadata/context from middleware if present
394
- ...(basePayload.metadata && { metadata: basePayload.metadata }),
412
+ // Include metadata/context from middleware if present (excluding session_id)
413
+ ...(sanitizedMetadata && Object.keys(sanitizedMetadata).length > 0 && { metadata: sanitizedMetadata }),
395
414
  ...(basePayload.context && { context: basePayload.context }),
396
415
  };
397
416
 
@@ -94,6 +94,7 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
94
94
 
95
95
  // Build header using layout config if available, otherwise use standard builder
96
96
  const headerLayoutConfig = config?.layout?.header;
97
+ const showHeader = config?.layout?.showHeader !== false; // default to true
97
98
  const headerElements: HeaderElements = headerLayoutConfig
98
99
  ? buildHeaderWithLayout(config!, headerLayoutConfig, { showClose })
99
100
  : buildHeader({ config, showClose });
@@ -132,10 +133,26 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
132
133
 
133
134
  // Build composer/footer using extracted builder
134
135
  const composerElements: ComposerElements = buildComposer({ config });
136
+ const showFooter = config?.layout?.showFooter !== false; // default to true
135
137
 
136
138
  // Assemble container with header, body, and footer
137
- attachHeaderToContainer(container, headerElements, config);
138
- container.append(body, composerElements.footer);
139
+ if (showHeader) {
140
+ attachHeaderToContainer(container, headerElements, config);
141
+ } else {
142
+ // Hide header completely
143
+ headerElements.header.style.display = 'none';
144
+ attachHeaderToContainer(container, headerElements, config);
145
+ }
146
+
147
+ container.append(body);
148
+
149
+ if (showFooter) {
150
+ container.append(composerElements.footer);
151
+ } else {
152
+ // Hide footer completely
153
+ composerElements.footer.style.display = 'none';
154
+ container.append(composerElements.footer);
155
+ }
139
156
 
140
157
  return {
141
158
  container,
@@ -4,7 +4,34 @@ import { describeReasonStatus } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
5
 
6
6
  // Expansion state per widget instance
7
- const reasoningExpansionState = new Set<string>();
7
+ export const reasoningExpansionState = new Set<string>();
8
+
9
+ // Helper function to update reasoning bubble UI after expansion state changes
10
+ export const updateReasoningBubbleUI = (messageId: string, bubble: HTMLElement): void => {
11
+ const expanded = reasoningExpansionState.has(messageId);
12
+ const header = bubble.querySelector('button[data-expand-header="true"]') as HTMLElement;
13
+ const content = bubble.querySelector('.tvw-border-t') as HTMLElement;
14
+
15
+ if (!header || !content) return;
16
+
17
+ header.setAttribute("aria-expanded", expanded ? "true" : "false");
18
+
19
+ // Find toggle icon container - it's the direct child div of headerMeta (which has tvw-ml-auto)
20
+ const headerMeta = header.querySelector('.tvw-ml-auto') as HTMLElement;
21
+ const toggleIcon = headerMeta?.querySelector(':scope > .tvw-flex.tvw-items-center') as HTMLElement;
22
+ if (toggleIcon) {
23
+ toggleIcon.innerHTML = "";
24
+ const iconColor = "currentColor";
25
+ const chevronIcon = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 16, iconColor, 2);
26
+ if (chevronIcon) {
27
+ toggleIcon.appendChild(chevronIcon);
28
+ } else {
29
+ toggleIcon.textContent = expanded ? "Hide" : "Show";
30
+ }
31
+ }
32
+
33
+ content.style.display = expanded ? "" : "none";
34
+ };
8
35
 
9
36
  export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement => {
10
37
  const reasoning = message.reasoning;
@@ -41,6 +68,8 @@ export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement
41
68
  ) as HTMLButtonElement;
42
69
  header.type = "button";
43
70
  header.setAttribute("aria-expanded", expanded ? "true" : "false");
71
+ header.setAttribute("data-expand-header", "true");
72
+ header.setAttribute("data-bubble-type", "reasoning");
44
73
 
45
74
  const headerContent = createElement("div", "tvw-flex tvw-flex-col tvw-text-left");
46
75
  const title = createElement("span", "tvw-text-xs tvw-text-cw-primary");
@@ -105,28 +134,6 @@ export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement
105
134
  content.style.display = expanded ? "" : "none";
106
135
  };
107
136
 
108
- const toggleExpansion = () => {
109
- expanded = !expanded;
110
- if (expanded) {
111
- reasoningExpansionState.add(message.id);
112
- } else {
113
- reasoningExpansionState.delete(message.id);
114
- }
115
- applyExpansionState();
116
- };
117
-
118
- header.addEventListener("pointerdown", (event) => {
119
- event.preventDefault();
120
- toggleExpansion();
121
- });
122
-
123
- header.addEventListener("keydown", (event) => {
124
- if (event.key === "Enter" || event.key === " ") {
125
- event.preventDefault();
126
- toggleExpansion();
127
- }
128
- });
129
-
130
137
  applyExpansionState();
131
138
 
132
139
  bubble.append(header, content);
@@ -4,7 +4,35 @@ import { formatUnknownValue, describeToolTitle } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
5
 
6
6
  // Expansion state per widget instance
7
- const toolExpansionState = new Set<string>();
7
+ export const toolExpansionState = new Set<string>();
8
+
9
+ // Helper function to update tool bubble UI after expansion state changes
10
+ export const updateToolBubbleUI = (messageId: string, bubble: HTMLElement, config?: AgentWidgetConfig): void => {
11
+ const expanded = toolExpansionState.has(messageId);
12
+ const toolCallConfig = config?.toolCall ?? {};
13
+ const header = bubble.querySelector('button[data-expand-header="true"]') as HTMLElement;
14
+ const content = bubble.querySelector('.tvw-border-t') as HTMLElement;
15
+
16
+ if (!header || !content) return;
17
+
18
+ header.setAttribute("aria-expanded", expanded ? "true" : "false");
19
+
20
+ // Find toggle icon container - it's the direct child div of headerMeta (which has tvw-ml-auto)
21
+ const headerMeta = header.querySelector('.tvw-ml-auto') as HTMLElement;
22
+ const toggleIcon = headerMeta?.querySelector(':scope > .tvw-flex.tvw-items-center') as HTMLElement;
23
+ if (toggleIcon) {
24
+ toggleIcon.innerHTML = "";
25
+ const iconColor = toolCallConfig.toggleTextColor || toolCallConfig.headerTextColor || "currentColor";
26
+ const chevronIcon = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 16, iconColor, 2);
27
+ if (chevronIcon) {
28
+ toggleIcon.appendChild(chevronIcon);
29
+ } else {
30
+ toggleIcon.textContent = expanded ? "Hide" : "Show";
31
+ }
32
+ }
33
+
34
+ content.style.display = expanded ? "" : "none";
35
+ };
8
36
 
9
37
  export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidgetConfig): HTMLElement => {
10
38
  const tool = message.toolCall;
@@ -57,6 +85,8 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
57
85
  ) as HTMLButtonElement;
58
86
  header.type = "button";
59
87
  header.setAttribute("aria-expanded", expanded ? "true" : "false");
88
+ header.setAttribute("data-expand-header", "true");
89
+ header.setAttribute("data-bubble-type", "tool");
60
90
 
61
91
  // Apply header styles
62
92
  if (toolCallConfig.headerBackgroundColor) {
@@ -248,28 +278,6 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
248
278
  content.style.display = expanded ? "" : "none";
249
279
  };
250
280
 
251
- const toggleToolExpansion = () => {
252
- expanded = !expanded;
253
- if (expanded) {
254
- toolExpansionState.add(message.id);
255
- } else {
256
- toolExpansionState.delete(message.id);
257
- }
258
- applyToolExpansion();
259
- };
260
-
261
- header.addEventListener("pointerdown", (event) => {
262
- event.preventDefault();
263
- toggleToolExpansion();
264
- });
265
-
266
- header.addEventListener("keydown", (event) => {
267
- if (event.key === "Enter" || event.key === " ") {
268
- event.preventDefault();
269
- toggleToolExpansion();
270
- }
271
- });
272
-
273
281
  applyToolExpansion();
274
282
 
275
283
  bubble.append(header, content);
package/src/types.ts CHANGED
@@ -790,6 +790,19 @@ export type AgentWidgetLayoutConfig = {
790
790
  messages?: AgentWidgetMessageLayoutConfig;
791
791
  /** Slot renderers for custom content injection */
792
792
  slots?: Partial<Record<WidgetLayoutSlot, SlotRenderer>>;
793
+ /**
794
+ * Show/hide the header section entirely.
795
+ * When false, the header (including icon, title, buttons) is completely hidden.
796
+ * @default true
797
+ */
798
+ showHeader?: boolean;
799
+ /**
800
+ * Show/hide the footer/composer section entirely.
801
+ * When false, the footer (including input field, send button, suggestions) is completely hidden.
802
+ * Useful for read-only conversation previews.
803
+ * @default true
804
+ */
805
+ showFooter?: boolean;
793
806
  };
794
807
 
795
808
  // ============================================================================
@@ -1101,6 +1114,37 @@ export type AgentWidgetConfig = {
1101
1114
  * ```
1102
1115
  */
1103
1116
  onSessionExpired?: () => void;
1117
+ /**
1118
+ * Get stored session ID for session resumption (client token mode only).
1119
+ * Called when initializing a new session to check if there's a previous session_id
1120
+ * that should be passed to /client/init to resume the same conversation record.
1121
+ *
1122
+ * @example
1123
+ * ```typescript
1124
+ * config: {
1125
+ * getStoredSessionId: () => {
1126
+ * const stored = localStorage.getItem('session_id');
1127
+ * return stored || null;
1128
+ * }
1129
+ * }
1130
+ * ```
1131
+ */
1132
+ getStoredSessionId?: () => string | null;
1133
+ /**
1134
+ * Store session ID for session resumption (client token mode only).
1135
+ * Called when a new session is initialized to persist the session_id
1136
+ * so it can be used to resume the conversation later.
1137
+ *
1138
+ * @example
1139
+ * ```typescript
1140
+ * config: {
1141
+ * setStoredSessionId: (sessionId) => {
1142
+ * localStorage.setItem('session_id', sessionId);
1143
+ * }
1144
+ * }
1145
+ * ```
1146
+ */
1147
+ setStoredSessionId?: (sessionId: string) => void;
1104
1148
  /**
1105
1149
  * Static headers to include with each request.
1106
1150
  * For dynamic headers (e.g., auth tokens), use `getHeaders` instead.
package/src/ui.ts CHANGED
@@ -26,8 +26,8 @@ import { positionMap } from "./utils/positioning";
26
26
  import type { HeaderElements, ComposerElements } from "./components/panel";
27
27
  import { MessageTransform, MessageActionCallbacks } from "./components/message-bubble";
28
28
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
29
- import { createReasoningBubble } from "./components/reasoning-bubble";
30
- import { createToolBubble } from "./components/tool-bubble";
29
+ import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
30
+ import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
31
31
  import { createSuggestions } from "./components/suggestions";
32
32
  import { enhanceWithForms } from "./components/forms";
33
33
  import { pluginRegistry } from "./plugins/registry";
@@ -204,8 +204,8 @@ export const createAgentExperience = (
204
204
  }
205
205
  }
206
206
 
207
- const getMetadata = () => persistentMetadata;
208
- const updateMetadata = (
207
+ const getSessionMetadata = () => persistentMetadata;
208
+ const updateSessionMetadata = (
209
209
  updater: (prev: Record<string, unknown>) => Record<string, unknown>
210
210
  ) => {
211
211
  const next = updater({ ...persistentMetadata }) ?? {};
@@ -226,8 +226,8 @@ export const createAgentExperience = (
226
226
  let actionManager = createActionManager({
227
227
  parsers: resolvedActionParsers,
228
228
  handlers: resolvedActionHandlers,
229
- getMetadata,
230
- updateMetadata,
229
+ getSessionMetadata,
230
+ updateSessionMetadata,
231
231
  emit: eventBus.emit,
232
232
  documentRef: typeof document !== "undefined" ? document : null
233
233
  });
@@ -453,6 +453,60 @@ export const createAgentExperience = (
453
453
  // Render custom slots
454
454
  renderSlots();
455
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
+
456
510
  panel.appendChild(container);
457
511
  mount.appendChild(wrapper);
458
512
 
@@ -704,7 +758,7 @@ export const createAgentExperience = (
704
758
  });
705
759
  };
706
760
  const persistVoiceMetadata = () => {
707
- updateMetadata((prev) => ({
761
+ updateSessionMetadata((prev) => ({
708
762
  ...prev,
709
763
  voiceState: {
710
764
  active: voiceState.active,
@@ -1228,6 +1282,24 @@ export const createAgentExperience = (
1228
1282
  textarea.style.fontWeight = fontWeight;
1229
1283
  };
1230
1284
 
1285
+ // Add session ID persistence callbacks for client token mode
1286
+ // These allow the widget to resume conversations by passing session_id to /client/init
1287
+ if (config.clientToken) {
1288
+ config = {
1289
+ ...config,
1290
+ getStoredSessionId: () => {
1291
+ const storedId = persistentMetadata['session_id'];
1292
+ return typeof storedId === 'string' ? storedId : null;
1293
+ },
1294
+ setStoredSessionId: (sessionId: string) => {
1295
+ updateSessionMetadata((prev) => ({
1296
+ ...prev,
1297
+ session_id: sessionId,
1298
+ }));
1299
+ },
1300
+ };
1301
+ }
1302
+
1231
1303
  session = new AgentWidgetSession(config, {
1232
1304
  onMessagesChanged(messages) {
1233
1305
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
@@ -1975,6 +2047,18 @@ export const createAgentExperience = (
1975
2047
  }
1976
2048
  }
1977
2049
 
2050
+ // Update header visibility based on layout.showHeader
2051
+ const showHeader = config.layout?.showHeader !== false; // default to true
2052
+ if (header) {
2053
+ header.style.display = showHeader ? "" : "none";
2054
+ }
2055
+
2056
+ // Update footer visibility based on layout.showFooter
2057
+ const showFooter = config.layout?.showFooter !== false; // default to true
2058
+ if (footer) {
2059
+ footer.style.display = showFooter ? "" : "none";
2060
+ }
2061
+
1978
2062
  // Only update open state if launcher enabled state changed or autoExpand value changed
1979
2063
  const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
1980
2064
  const autoExpandChanged = autoExpand !== prevAutoExpand;
@@ -2504,8 +2588,8 @@ export const createAgentExperience = (
2504
2588
  actionManager = createActionManager({
2505
2589
  parsers: nextParsers,
2506
2590
  handlers: nextHandlers,
2507
- getMetadata,
2508
- updateMetadata,
2591
+ getSessionMetadata,
2592
+ updateSessionMetadata,
2509
2593
  emit: eventBus.emit,
2510
2594
  documentRef: typeof document !== "undefined" ? document : null
2511
2595
  });
@@ -2949,7 +3033,7 @@ export const createAgentExperience = (
2949
3033
  updatePersistentMetadata(
2950
3034
  updater: (prev: Record<string, unknown>) => Record<string, unknown>
2951
3035
  ) {
2952
- updateMetadata(updater);
3036
+ updateSessionMetadata(updater);
2953
3037
  },
2954
3038
  on(event, handler) {
2955
3039
  return eventBus.on(event, handler);
@@ -19,8 +19,8 @@ type ActionManagerProcessContext = {
19
19
  type ActionManagerOptions = {
20
20
  parsers: AgentWidgetActionParser[];
21
21
  handlers: AgentWidgetActionHandler[];
22
- getMetadata: () => Record<string, unknown>;
23
- updateMetadata: (
22
+ getSessionMetadata: () => Record<string, unknown>;
23
+ updateSessionMetadata: (
24
24
  updater: (prev: Record<string, unknown>) => Record<string, unknown>
25
25
  ) => void;
26
26
  emit: <K extends keyof AgentWidgetControllerEventMap>(
@@ -123,18 +123,18 @@ const ensureArrayOfStrings = (value: unknown): string[] => {
123
123
 
124
124
  export const createActionManager = (options: ActionManagerOptions) => {
125
125
  let processedIds = new Set(
126
- ensureArrayOfStrings(options.getMetadata().processedActionMessageIds)
126
+ ensureArrayOfStrings(options.getSessionMetadata().processedActionMessageIds)
127
127
  );
128
128
 
129
129
  const syncFromMetadata = () => {
130
130
  processedIds = new Set(
131
- ensureArrayOfStrings(options.getMetadata().processedActionMessageIds)
131
+ ensureArrayOfStrings(options.getSessionMetadata().processedActionMessageIds)
132
132
  );
133
133
  };
134
134
 
135
135
  const persistProcessedIds = () => {
136
136
  const latestIds = Array.from(processedIds);
137
- options.updateMetadata((prev) => ({
137
+ options.updateSessionMetadata((prev) => ({
138
138
  ...prev,
139
139
  processedActionMessageIds: latestIds
140
140
  }));
@@ -195,8 +195,8 @@ export const createActionManager = (options: ActionManagerOptions) => {
195
195
  try {
196
196
  const handlerResult = handler(action, {
197
197
  message: context.message,
198
- metadata: options.getMetadata(),
199
- updateMetadata: options.updateMetadata,
198
+ metadata: options.getSessionMetadata(),
199
+ updateMetadata: options.updateSessionMetadata,
200
200
  document: options.documentRef
201
201
  } as AgentWidgetActionContext) as AgentWidgetActionHandlerResult | void;
202
202