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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-agent",
3
- "version": "1.9.0",
3
+ "version": "1.11.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
@@ -5,7 +5,11 @@ import {
5
5
  AgentWidgetStreamParser,
6
6
  AgentWidgetContextProvider,
7
7
  AgentWidgetRequestMiddleware,
8
- AgentWidgetRequestPayload
8
+ AgentWidgetRequestPayload,
9
+ AgentWidgetCustomFetch,
10
+ AgentWidgetSSEEventParser,
11
+ AgentWidgetHeadersFunction,
12
+ AgentWidgetSSEEventResult
9
13
  } from "./types";
10
14
  import {
11
15
  extractTextFromJson,
@@ -48,6 +52,9 @@ export class AgentWidgetClient {
48
52
  private readonly createStreamParser: () => AgentWidgetStreamParser;
49
53
  private readonly contextProviders: AgentWidgetContextProvider[];
50
54
  private readonly requestMiddleware?: AgentWidgetRequestMiddleware;
55
+ private readonly customFetch?: AgentWidgetCustomFetch;
56
+ private readonly parseSSEEvent?: AgentWidgetSSEEventParser;
57
+ private readonly getHeaders?: AgentWidgetHeadersFunction;
51
58
 
52
59
  constructor(private config: AgentWidgetConfig = {}) {
53
60
  this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
@@ -60,6 +67,9 @@ export class AgentWidgetClient {
60
67
  this.createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
61
68
  this.contextProviders = config.contextProviders ?? [];
62
69
  this.requestMiddleware = config.requestMiddleware;
70
+ this.customFetch = config.customFetch;
71
+ this.parseSSEEvent = config.parseSSEEvent;
72
+ this.getHeaders = config.getHeaders;
63
73
  }
64
74
 
65
75
  public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
@@ -70,19 +80,54 @@ export class AgentWidgetClient {
70
80
 
71
81
  onEvent({ type: "status", status: "connecting" });
72
82
 
73
- const body = await this.buildPayload(options.messages);
83
+ const payload = await this.buildPayload(options.messages);
74
84
 
75
85
  if (this.debug) {
76
86
  // eslint-disable-next-line no-console
77
- console.debug("[AgentWidgetClient] dispatch body", body);
87
+ console.debug("[AgentWidgetClient] dispatch payload", payload);
78
88
  }
79
89
 
80
- const response = await fetch(this.apiUrl, {
81
- method: "POST",
82
- headers: this.headers,
83
- body: JSON.stringify(body),
84
- signal: controller.signal
85
- });
90
+ // Build headers - merge static headers with dynamic headers if provided
91
+ let headers = { ...this.headers };
92
+ if (this.getHeaders) {
93
+ try {
94
+ const dynamicHeaders = await this.getHeaders();
95
+ headers = { ...headers, ...dynamicHeaders };
96
+ } catch (error) {
97
+ if (typeof console !== "undefined") {
98
+ // eslint-disable-next-line no-console
99
+ console.error("[AgentWidget] getHeaders error:", error);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Use customFetch if provided, otherwise use default fetch
105
+ let response: Response;
106
+ if (this.customFetch) {
107
+ try {
108
+ response = await this.customFetch(
109
+ this.apiUrl,
110
+ {
111
+ method: "POST",
112
+ headers,
113
+ body: JSON.stringify(payload),
114
+ signal: controller.signal
115
+ },
116
+ payload
117
+ );
118
+ } catch (error) {
119
+ const err = error instanceof Error ? error : new Error(String(error));
120
+ onEvent({ type: "error", error: err });
121
+ throw err;
122
+ }
123
+ } else {
124
+ response = await fetch(this.apiUrl, {
125
+ method: "POST",
126
+ headers,
127
+ body: JSON.stringify(payload),
128
+ signal: controller.signal
129
+ });
130
+ }
86
131
 
87
132
  if (!response.ok || !response.body) {
88
133
  const error = new Error(
@@ -112,7 +157,7 @@ export class AgentWidgetClient {
112
157
  })
113
158
  .map((message) => ({
114
159
  role: message.role,
115
- content: message.content,
160
+ content: message.rawContent || message.content,
116
161
  createdAt: message.createdAt
117
162
  }));
118
163
 
@@ -167,6 +212,70 @@ export class AgentWidgetClient {
167
212
  return payload;
168
213
  }
169
214
 
215
+ /**
216
+ * Handle custom SSE event parsing via parseSSEEvent callback
217
+ * Returns true if event was handled, false otherwise
218
+ */
219
+ private async handleCustomSSEEvent(
220
+ payload: unknown,
221
+ onEvent: SSEHandler,
222
+ assistantMessageRef: { current: AgentWidgetMessage | null },
223
+ emitMessage: (msg: AgentWidgetMessage) => void,
224
+ nextSequence: () => number
225
+ ): Promise<boolean> {
226
+ if (!this.parseSSEEvent) return false;
227
+
228
+ try {
229
+ const result = await this.parseSSEEvent(payload);
230
+ if (result === null) return false; // Event should be ignored
231
+
232
+ const ensureAssistant = () => {
233
+ if (assistantMessageRef.current) return assistantMessageRef.current;
234
+ const msg: AgentWidgetMessage = {
235
+ id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
236
+ role: "assistant",
237
+ content: "",
238
+ createdAt: new Date().toISOString(),
239
+ streaming: true,
240
+ variant: "assistant",
241
+ sequence: nextSequence()
242
+ };
243
+ assistantMessageRef.current = msg;
244
+ emitMessage(msg);
245
+ return msg;
246
+ };
247
+
248
+ if (result.text !== undefined) {
249
+ const assistant = ensureAssistant();
250
+ assistant.content += result.text;
251
+ emitMessage(assistant);
252
+ }
253
+
254
+ if (result.done) {
255
+ if (assistantMessageRef.current) {
256
+ assistantMessageRef.current.streaming = false;
257
+ emitMessage(assistantMessageRef.current);
258
+ }
259
+ onEvent({ type: "status", status: "idle" });
260
+ }
261
+
262
+ if (result.error) {
263
+ onEvent({
264
+ type: "error",
265
+ error: new Error(result.error)
266
+ });
267
+ }
268
+
269
+ return true; // Event was handled
270
+ } catch (error) {
271
+ if (typeof console !== "undefined") {
272
+ // eslint-disable-next-line no-console
273
+ console.error("[AgentWidget] parseSSEEvent error:", error);
274
+ }
275
+ return false;
276
+ }
277
+ }
278
+
170
279
  private async streamResponse(
171
280
  body: ReadableStream<Uint8Array>,
172
281
  onEvent: SSEHandler
@@ -215,6 +324,8 @@ export class AgentWidgetClient {
215
324
  };
216
325
 
217
326
  let assistantMessage: AgentWidgetMessage | null = null;
327
+ // Reference to track assistant message for custom event handler
328
+ const assistantMessageRef = { current: null as AgentWidgetMessage | null };
218
329
  const reasoningMessages = new Map<string, AgentWidgetMessage>();
219
330
  const toolMessages = new Map<string, AgentWidgetMessage>();
220
331
  const reasoningContext = {
@@ -265,7 +376,6 @@ export class AgentWidgetClient {
265
376
  content: "",
266
377
  createdAt: new Date().toISOString(),
267
378
  streaming: true,
268
- variant: "assistant",
269
379
  sequence: nextSequence()
270
380
  };
271
381
  emitMessage(assistantMessage);
@@ -471,6 +581,24 @@ export class AgentWidgetClient {
471
581
  const payloadType =
472
582
  eventType !== "message" ? eventType : payload.type ?? "message";
473
583
 
584
+ // If custom SSE event parser is provided, try it first
585
+ if (this.parseSSEEvent) {
586
+ // Keep assistant message ref in sync
587
+ assistantMessageRef.current = assistantMessage;
588
+ const handled = await this.handleCustomSSEEvent(
589
+ payload,
590
+ onEvent,
591
+ assistantMessageRef,
592
+ emitMessage,
593
+ nextSequence
594
+ );
595
+ // Update assistantMessage from ref (in case it was created)
596
+ if (assistantMessageRef.current && !assistantMessage) {
597
+ assistantMessage = assistantMessageRef.current;
598
+ }
599
+ if (handled) continue; // Skip default handling if custom handler processed it
600
+ }
601
+
474
602
  if (payloadType === "reason_start") {
475
603
  const reasoningId =
476
604
  resolveReasoningId(payload, true) ?? `reason-${nextSequence()}`;
@@ -635,7 +763,8 @@ export class AgentWidgetClient {
635
763
  continue;
636
764
  }
637
765
  const assistant = ensureAssistantMessage();
638
- const chunk = payload.text ?? payload.delta ?? payload.content ?? "";
766
+ // Support various field names: text, delta, content, chunk (Travrse uses 'chunk')
767
+ const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
639
768
  if (chunk) {
640
769
  // Accumulate raw content for structured format parsing
641
770
  const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
@@ -0,0 +1,366 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { AgentWidgetConfig } from "../types";
4
+
5
+ export interface ComposerElements {
6
+ footer: HTMLElement;
7
+ suggestions: HTMLElement;
8
+ composerForm: HTMLFormElement;
9
+ textarea: HTMLTextAreaElement;
10
+ sendButton: HTMLButtonElement;
11
+ sendButtonWrapper: HTMLElement;
12
+ micButton: HTMLButtonElement | null;
13
+ micButtonWrapper: HTMLElement | null;
14
+ statusText: HTMLElement;
15
+ }
16
+
17
+ export interface ComposerBuildContext {
18
+ config?: AgentWidgetConfig;
19
+ onSubmit?: (text: string) => void;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Helper to get font family CSS value from config preset
25
+ */
26
+ const getFontFamilyValue = (
27
+ family: "sans-serif" | "serif" | "mono"
28
+ ): string => {
29
+ switch (family) {
30
+ case "serif":
31
+ return 'Georgia, "Times New Roman", Times, serif';
32
+ case "mono":
33
+ return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
34
+ case "sans-serif":
35
+ default:
36
+ return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Build the composer/footer section of the panel.
42
+ * Extracted for reuse and plugin override support.
43
+ */
44
+ export const buildComposer = (context: ComposerBuildContext): ComposerElements => {
45
+ const { config } = context;
46
+
47
+ const footer = createElement(
48
+ "div",
49
+ "tvw-border-t-cw-divider tvw-bg-cw-surface tvw-px-6 tvw-py-4"
50
+ );
51
+
52
+ const suggestions = createElement(
53
+ "div",
54
+ "tvw-mb-3 tvw-flex tvw-flex-wrap tvw-gap-2"
55
+ );
56
+
57
+ // Determine gap based on voice recognition
58
+ const voiceRecognitionEnabledForGap =
59
+ config?.voiceRecognition?.enabled === true;
60
+ const hasSpeechRecognitionForGap =
61
+ typeof window !== "undefined" &&
62
+ (typeof (window as any).webkitSpeechRecognition !== "undefined" ||
63
+ typeof (window as any).SpeechRecognition !== "undefined");
64
+ const shouldUseSmallGap =
65
+ voiceRecognitionEnabledForGap && hasSpeechRecognitionForGap;
66
+ const gapClass = shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3";
67
+
68
+ const composerForm = createElement(
69
+ "form",
70
+ `tvw-flex tvw-items-end ${gapClass} tvw-rounded-2xl tvw-border tvw-border-gray-200 tvw-bg-cw-input-background tvw-px-4 tvw-py-3`
71
+ ) as HTMLFormElement;
72
+ // Prevent form from getting focus styles
73
+ composerForm.style.outline = "none";
74
+
75
+ const textarea = createElement("textarea") as HTMLTextAreaElement;
76
+ textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
77
+ textarea.className =
78
+ "tvw-min-h-[48px] tvw-flex-1 tvw-resize-none tvw-border-none tvw-bg-transparent tvw-text-sm tvw-text-cw-primary focus:tvw-outline-none focus:tvw-border-none";
79
+ textarea.rows = 1;
80
+
81
+ // Apply font family and weight from config
82
+ const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
83
+ const fontWeight = config?.theme?.inputFontWeight ?? "400";
84
+
85
+ textarea.style.fontFamily = getFontFamilyValue(fontFamily);
86
+ textarea.style.fontWeight = fontWeight;
87
+
88
+ // Explicitly remove border and outline on focus to prevent browser defaults
89
+ textarea.style.border = "none";
90
+ textarea.style.outline = "none";
91
+ textarea.style.borderWidth = "0";
92
+ textarea.style.borderStyle = "none";
93
+ textarea.style.borderColor = "transparent";
94
+ textarea.addEventListener("focus", () => {
95
+ textarea.style.border = "none";
96
+ textarea.style.outline = "none";
97
+ textarea.style.borderWidth = "0";
98
+ textarea.style.borderStyle = "none";
99
+ textarea.style.borderColor = "transparent";
100
+ textarea.style.boxShadow = "none";
101
+ });
102
+ textarea.addEventListener("blur", () => {
103
+ textarea.style.border = "none";
104
+ textarea.style.outline = "none";
105
+ });
106
+
107
+ // Send button configuration
108
+ const sendButtonConfig = config?.sendButton ?? {};
109
+ const useIcon = sendButtonConfig.useIcon ?? false;
110
+ const iconText = sendButtonConfig.iconText ?? "↑";
111
+ const iconName = sendButtonConfig.iconName;
112
+ const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
113
+ const showTooltip = sendButtonConfig.showTooltip ?? false;
114
+ const buttonSize = sendButtonConfig.size ?? "40px";
115
+ const backgroundColor = sendButtonConfig.backgroundColor;
116
+ const textColor = sendButtonConfig.textColor;
117
+
118
+ // Create wrapper for tooltip positioning
119
+ const sendButtonWrapper = createElement("div", "tvw-send-button-wrapper");
120
+
121
+ const sendButton = createElement(
122
+ "button",
123
+ useIcon
124
+ ? "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
125
+ : "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold disabled:tvw-opacity-50 tvw-cursor-pointer"
126
+ ) as HTMLButtonElement;
127
+
128
+ sendButton.type = "submit";
129
+
130
+ if (useIcon) {
131
+ // Icon mode: circular button
132
+ sendButton.style.width = buttonSize;
133
+ sendButton.style.height = buttonSize;
134
+ sendButton.style.minWidth = buttonSize;
135
+ sendButton.style.minHeight = buttonSize;
136
+ sendButton.style.fontSize = "18px";
137
+ sendButton.style.lineHeight = "1";
138
+
139
+ // Clear any existing content
140
+ sendButton.innerHTML = "";
141
+
142
+ // Use Lucide icon if iconName is provided, otherwise fall back to iconText
143
+ if (iconName) {
144
+ const iconSize = parseFloat(buttonSize) || 24;
145
+ const iconColor =
146
+ textColor && typeof textColor === "string" && textColor.trim()
147
+ ? textColor.trim()
148
+ : "currentColor";
149
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
150
+ if (iconSvg) {
151
+ sendButton.appendChild(iconSvg);
152
+ sendButton.style.color = iconColor;
153
+ } else {
154
+ // Fallback to text if icon fails to render
155
+ sendButton.textContent = iconText;
156
+ if (textColor) {
157
+ sendButton.style.color = textColor;
158
+ } else {
159
+ sendButton.classList.add("tvw-text-white");
160
+ }
161
+ }
162
+ } else {
163
+ sendButton.textContent = iconText;
164
+ if (textColor) {
165
+ sendButton.style.color = textColor;
166
+ } else {
167
+ sendButton.classList.add("tvw-text-white");
168
+ }
169
+ }
170
+
171
+ if (backgroundColor) {
172
+ sendButton.style.backgroundColor = backgroundColor;
173
+ } else {
174
+ sendButton.classList.add("tvw-bg-cw-primary");
175
+ }
176
+ } else {
177
+ // Text mode: existing behavior
178
+ sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
179
+ if (textColor) {
180
+ sendButton.style.color = textColor;
181
+ } else {
182
+ sendButton.classList.add("tvw-text-white");
183
+ }
184
+ }
185
+
186
+ // Apply existing styling from config
187
+ if (sendButtonConfig.borderWidth) {
188
+ sendButton.style.borderWidth = sendButtonConfig.borderWidth;
189
+ sendButton.style.borderStyle = "solid";
190
+ }
191
+ if (sendButtonConfig.borderColor) {
192
+ sendButton.style.borderColor = sendButtonConfig.borderColor;
193
+ }
194
+
195
+ // Apply padding styling (works in both icon and text mode)
196
+ if (sendButtonConfig.paddingX) {
197
+ sendButton.style.paddingLeft = sendButtonConfig.paddingX;
198
+ sendButton.style.paddingRight = sendButtonConfig.paddingX;
199
+ } else {
200
+ sendButton.style.paddingLeft = "";
201
+ sendButton.style.paddingRight = "";
202
+ }
203
+ if (sendButtonConfig.paddingY) {
204
+ sendButton.style.paddingTop = sendButtonConfig.paddingY;
205
+ sendButton.style.paddingBottom = sendButtonConfig.paddingY;
206
+ } else {
207
+ sendButton.style.paddingTop = "";
208
+ sendButton.style.paddingBottom = "";
209
+ }
210
+
211
+ // Add tooltip if enabled
212
+ if (showTooltip && tooltipText) {
213
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
214
+ tooltip.textContent = tooltipText;
215
+ sendButtonWrapper.appendChild(tooltip);
216
+ }
217
+
218
+ sendButtonWrapper.appendChild(sendButton);
219
+
220
+ // Voice recognition mic button
221
+ const voiceRecognitionConfig = config?.voiceRecognition ?? {};
222
+ const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
223
+ let micButton: HTMLButtonElement | null = null;
224
+ let micButtonWrapper: HTMLElement | null = null;
225
+
226
+ // Check browser support for speech recognition
227
+ const hasSpeechRecognition =
228
+ typeof window !== "undefined" &&
229
+ (typeof (window as any).webkitSpeechRecognition !== "undefined" ||
230
+ typeof (window as any).SpeechRecognition !== "undefined");
231
+
232
+ if (voiceRecognitionEnabled && hasSpeechRecognition) {
233
+ micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
234
+ micButton = createElement(
235
+ "button",
236
+ "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
237
+ ) as HTMLButtonElement;
238
+
239
+ micButton.type = "button";
240
+ micButton.setAttribute("aria-label", "Start voice recognition");
241
+
242
+ const micIconName = voiceRecognitionConfig.iconName ?? "mic";
243
+ const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
244
+ const micIconSizeNum = parseFloat(micIconSize) || 24;
245
+
246
+ // Use dedicated colors from voice recognition config, fallback to send button colors
247
+ const micBackgroundColor =
248
+ voiceRecognitionConfig.backgroundColor ?? backgroundColor;
249
+ const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
250
+
251
+ micButton.style.width = micIconSize;
252
+ micButton.style.height = micIconSize;
253
+ micButton.style.minWidth = micIconSize;
254
+ micButton.style.minHeight = micIconSize;
255
+ micButton.style.fontSize = "18px";
256
+ micButton.style.lineHeight = "1";
257
+
258
+ // Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
259
+ const iconColorValue = micIconColor || "currentColor";
260
+ const micIconSvg = renderLucideIcon(
261
+ micIconName,
262
+ micIconSizeNum,
263
+ iconColorValue,
264
+ 1.5
265
+ );
266
+ if (micIconSvg) {
267
+ micButton.appendChild(micIconSvg);
268
+ micButton.style.color = iconColorValue;
269
+ } else {
270
+ // Fallback to text if icon fails
271
+ micButton.textContent = "🎤";
272
+ micButton.style.color = iconColorValue;
273
+ }
274
+
275
+ // Apply background color
276
+ if (micBackgroundColor) {
277
+ micButton.style.backgroundColor = micBackgroundColor;
278
+ } else {
279
+ micButton.classList.add("tvw-bg-cw-primary");
280
+ }
281
+
282
+ // Apply icon/text color
283
+ if (micIconColor) {
284
+ micButton.style.color = micIconColor;
285
+ } else if (!micIconColor && !textColor) {
286
+ micButton.classList.add("tvw-text-white");
287
+ }
288
+
289
+ // Apply border styling
290
+ if (voiceRecognitionConfig.borderWidth) {
291
+ micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
292
+ micButton.style.borderStyle = "solid";
293
+ }
294
+ if (voiceRecognitionConfig.borderColor) {
295
+ micButton.style.borderColor = voiceRecognitionConfig.borderColor;
296
+ }
297
+
298
+ // Apply padding styling
299
+ if (voiceRecognitionConfig.paddingX) {
300
+ micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
301
+ micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
302
+ }
303
+ if (voiceRecognitionConfig.paddingY) {
304
+ micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
305
+ micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
306
+ }
307
+
308
+ micButtonWrapper.appendChild(micButton);
309
+
310
+ // Add tooltip if enabled
311
+ const micTooltipText =
312
+ voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
313
+ const showMicTooltip = voiceRecognitionConfig.showTooltip ?? false;
314
+ if (showMicTooltip && micTooltipText) {
315
+ const tooltip = createElement("div", "tvw-send-button-tooltip");
316
+ tooltip.textContent = micTooltipText;
317
+ micButtonWrapper.appendChild(tooltip);
318
+ }
319
+ }
320
+
321
+ // Focus textarea when composer form container is clicked
322
+ composerForm.addEventListener("click", (e) => {
323
+ // Don't focus if clicking on the send button, mic button, or their wrappers
324
+ if (
325
+ e.target !== sendButton &&
326
+ e.target !== sendButtonWrapper &&
327
+ e.target !== micButton &&
328
+ e.target !== micButtonWrapper
329
+ ) {
330
+ textarea.focus();
331
+ }
332
+ });
333
+
334
+ // Append elements: textarea, mic button (if exists), send button
335
+ composerForm.append(textarea);
336
+ if (micButtonWrapper) {
337
+ composerForm.append(micButtonWrapper);
338
+ }
339
+ composerForm.append(sendButtonWrapper);
340
+
341
+ const statusText = createElement(
342
+ "div",
343
+ "tvw-mt-2 tvw-text-right tvw-text-xs tvw-text-cw-muted"
344
+ );
345
+
346
+ // Apply status indicator config
347
+ const statusConfig = config?.statusIndicator ?? {};
348
+ const isVisible = statusConfig.visible ?? true;
349
+ statusText.style.display = isVisible ? "" : "none";
350
+ statusText.textContent = statusConfig.idleText ?? "Online";
351
+
352
+ footer.append(suggestions, composerForm, statusText);
353
+
354
+ return {
355
+ footer,
356
+ suggestions,
357
+ composerForm,
358
+ textarea,
359
+ sendButton,
360
+ sendButtonWrapper,
361
+ micButton,
362
+ micButtonWrapper,
363
+ statusText
364
+ };
365
+ };
366
+
@@ -167,3 +167,4 @@ export const enhanceWithForms = (
167
167
 
168
168
 
169
169
 
170
+