andy-widget-react 2.1.1 → 2.2.1

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/dist/index.cjs CHANGED
@@ -762,6 +762,84 @@ const initializeConfig = (packageName = 'unknown') => {
762
762
  }
763
763
  };
764
764
 
765
+ /** Stream a reply while the server restores this session's conversation history. */
766
+ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) => {
767
+ const response = await fetch(`${getApiBaseUrl()}/chat/stream`, {
768
+ method: 'POST',
769
+ headers: { 'Content-Type': 'application/json' },
770
+ signal: options.signal,
771
+ body: JSON.stringify({
772
+ chatbotId: embedId,
773
+ sessionId,
774
+ historySource: 'session',
775
+ messages: [{ role: 'user', parts: [{ type: 'text', text: message }] }]
776
+ })
777
+ });
778
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
779
+ const data = await response.json().catch(() => null);
780
+ throw new Error(data?.error || data?.message || 'Chat is temporarily unavailable.')
781
+ }
782
+ if (!response.body) throw new Error('Missing reply stream.')
783
+
784
+ const reader = response.body.getReader();
785
+ const decoder = new TextDecoder();
786
+ let buffer = '';
787
+ let eventData = [];
788
+ let text = '';
789
+ let published = '';
790
+ let complete = false;
791
+ let waiting = false;
792
+ const event = () => {
793
+ if (!eventData.length) return
794
+ const data = eventData.join('\n');
795
+ eventData = [];
796
+ if (data === '[DONE]') { complete = true; return }
797
+ const part = JSON.parse(data);
798
+ if (part.type === 'error' || part.type === 'abort') {
799
+ throw new Error(part.errorText || 'Reply interrupted.')
800
+ }
801
+ if (part.type === 'data-handoff' && part.data?.status === 'waiting') {
802
+ waiting = true;
803
+ return
804
+ }
805
+ if (part.type === 'text-start' && text && !text.endsWith('\n')) text += '\n';
806
+ if (part.type === 'text-delta') {
807
+ if (typeof part.delta !== 'string') throw new Error('Invalid reply text.')
808
+ text += part.delta;
809
+ }
810
+ };
811
+ const line = value => {
812
+ const cleaned = value.endsWith('\r') ? value.slice(0, -1) : value;
813
+ if (!cleaned) event();
814
+ else if (cleaned.startsWith('data:')) eventData.push(cleaned.slice(5).replace(/^ /, ''));
815
+ };
816
+ try {
817
+ while (!complete) {
818
+ const { value, done } = await reader.read();
819
+ buffer += decoder.decode(value, { stream: !done });
820
+ let newline;
821
+ while ((newline = buffer.indexOf('\n')) !== -1) {
822
+ line(buffer.slice(0, newline));
823
+ buffer = buffer.slice(newline + 1);
824
+ if (complete) break
825
+ }
826
+ // Publish once per received chunk, including the first useful text.
827
+ if (!waiting && text.trim() && text !== published) {
828
+ published = text;
829
+ options.onText?.(text);
830
+ }
831
+ if (done) break
832
+ }
833
+ if (!complete) throw new Error('Reply stream ended before completion.')
834
+ if (waiting) return { status: 'waiting' }
835
+ if (!text.trim()) throw new Error('The reply was empty.')
836
+ return { status: 'agent', text }
837
+ } finally {
838
+ await reader.cancel().catch(() => {});
839
+ reader.releaseLock();
840
+ }
841
+ };
842
+
765
843
  /**
766
844
  * AndyChat API client
767
845
  * Handles all communication with the AndyChat backend API
@@ -986,60 +1064,6 @@ const fetchChatbotConfig = async embedId => {
986
1064
  }
987
1065
  };
988
1066
 
989
- /**
990
- * Sends a user message to the API
991
- * @param {string} embedId - The chatbot embed ID
992
- * @param {string} sessionId - The current session ID
993
- * @param {string} message - The user's message
994
- * @returns {Promise<string>} The bot's response text
995
- */
996
- const sendMessageForWidget = async (embedId, sessionId, message) => {
997
- try {
998
- const apiBaseUrl = getApiBaseUrl();
999
- const response = await fetch(`${apiBaseUrl}/messages`, {
1000
- method: 'POST',
1001
- headers: { 'Content-Type': 'application/json' },
1002
- body: JSON.stringify({
1003
- chatbotId: embedId,
1004
- sessionId: sessionId,
1005
- currentUserMessage: {
1006
- role: 'user',
1007
- content: message
1008
- }
1009
- })
1010
- });
1011
-
1012
- if (response.ok) {
1013
- const data = await response.json();
1014
- if (data.error || data.limitStatus) {
1015
- throw new Error(data.error || 'Chat is temporarily unavailable.')
1016
- }
1017
- if (data.handoffStatus === 'paused') {
1018
- return { status: 'waiting' }
1019
- }
1020
- if (typeof data.botResponse !== 'string' || !data.botResponse.trim()) {
1021
- throw new Error('Unexpected response format from server.')
1022
- }
1023
- return { status: 'agent', text: data.botResponse }
1024
- } else {
1025
- // Handle HTTP errors (e.g., 500, 404)
1026
- let errorData;
1027
- try {
1028
- errorData = await response.json();
1029
- } catch (e) {
1030
- // If parsing error JSON fails, use status text
1031
- // Also, if response.text() is available and gives more info, consider using it.
1032
- const responseText = response.text ? await response.text() : '';
1033
- throw new Error(response.statusText || `Server returned an error. ${responseText}`.trim())
1034
- }
1035
- throw new Error(errorData.error || errorData.message || response.statusText || 'Server returned an error.')
1036
- }
1037
- } catch (error) {
1038
- console.error('AndyChat: Message request failed.');
1039
- throw error
1040
- }
1041
- };
1042
-
1043
1067
  /**
1044
1068
  * Visitor information collection is disabled until Andy exposes a durable public submission
1045
1069
  * contract. This explicit failure prevents a Widget from reporting a mocked success.
@@ -1473,9 +1497,6 @@ const generateDesignTokenVariablesString = () => {
1473
1497
  --andy-chat-avatar-size: 32px;
1474
1498
  --andy-chat-avatar-size-small: 24px;
1475
1499
 
1476
- /* Keyboard handling */
1477
- --andy-keyboard-height: 0px;
1478
-
1479
1500
  /* === THEMABLE VARIABLES (Default Light Theme) === */
1480
1501
  --andy-themable-background: var(--andy-color-background);
1481
1502
  --andy-themable-background-secondary: var(--andy-color-white);
@@ -2133,6 +2154,10 @@ const getCoreStructuralCssString = () => {
2133
2154
  }
2134
2155
 
2135
2156
  /* Message Sub-Components */
2157
+ .andy-message__stream {
2158
+ white-space: pre-wrap;
2159
+ }
2160
+
2136
2161
  .andy-message__content {
2137
2162
  line-height: 1.4;
2138
2163
  margin: 0;
@@ -2855,11 +2880,6 @@ const getCoreStructuralCssString = () => {
2855
2880
  }
2856
2881
  }
2857
2882
 
2858
- /* No special handling for keyboard - let browser handle it naturally */
2859
- .andy-chat-window--keyboard-open {
2860
- /* Browser handles positioning automatically with our margin-based approach */
2861
- }
2862
-
2863
2883
  /* Ensure the window content is properly laid out */
2864
2884
  .andy-chat-window__messages {
2865
2885
  flex: 1 1 auto;
@@ -2977,17 +2997,12 @@ const getCoreStructuralCssString = () => {
2977
2997
  }
2978
2998
  }
2979
2999
 
2980
- /* Desktop keyboard handling (for testing) */
3000
+ /* Desktop */
2981
3001
  @media (min-width: 521px) {
2982
3002
  /* Desktop retains original positioning behavior */
2983
3003
  .andy-chat-window {
2984
3004
  transition: transform 0.3s ease-out;
2985
3005
  }
2986
-
2987
- /* Optional: Add keyboard handling for desktop if needed */
2988
- .andy-chat-window--keyboard-open {
2989
- /* No transform on desktop - keyboard doesn't affect viewport */
2990
- }
2991
3006
  }
2992
3007
 
2993
3008
  /* Orientation */
@@ -3479,244 +3494,6 @@ const UI_STRINGS = {
3479
3494
  */
3480
3495
  const getUiStrings = lang => UI_STRINGS[lang] || UI_STRINGS.en;
3481
3496
 
3482
- /**
3483
- * Keyboard detection utility for handling mobile virtual keyboards
3484
- * Provides cross-browser support for detecting keyboard open/close events
3485
- */
3486
-
3487
- class KeyboardDetector {
3488
- constructor() {
3489
- this.isKeyboardOpen = false;
3490
- this.keyboardHeight = 0;
3491
- this.listeners = new Set();
3492
- this.inputElement = null;
3493
- this.viewportHeight = window.innerHeight;
3494
- this.visualViewport = window.visualViewport;
3495
-
3496
- this.initializeDetection();
3497
- }
3498
-
3499
- /**
3500
- * Initialize keyboard detection
3501
- */
3502
- initializeDetection() {
3503
- // Use Visual Viewport API if available (most modern browsers)
3504
- if (this.visualViewport) {
3505
- this.visualViewport.addEventListener('resize', this.handleViewportResize.bind(this));
3506
- this.visualViewport.addEventListener('scroll', this.handleViewportScroll.bind(this));
3507
- } else {
3508
- // Fallback for older browsers
3509
- window.addEventListener('resize', this.handleWindowResize.bind(this));
3510
- }
3511
-
3512
- // Additional detection via focus/blur events
3513
- document.addEventListener('focusin', this.handleFocusIn.bind(this), true);
3514
- document.addEventListener('focusout', this.handleFocusOut.bind(this), true);
3515
-
3516
- // Orientation change handler
3517
- window.addEventListener('orientationchange', this.handleOrientationChange.bind(this));
3518
- }
3519
-
3520
- /**
3521
- * Handle visual viewport resize (modern browsers)
3522
- */
3523
- handleViewportResize() {
3524
- const currentHeight = this.visualViewport.height;
3525
- const heightDifference = window.innerHeight - currentHeight;
3526
-
3527
- // If viewport shrunk by more than 100px, keyboard is likely open
3528
- if (heightDifference > 100) {
3529
- this.setKeyboardOpen(true, heightDifference);
3530
- } else if (heightDifference < 50) {
3531
- this.setKeyboardOpen(false, 0);
3532
- }
3533
- }
3534
-
3535
- /**
3536
- * Handle visual viewport scroll
3537
- */
3538
- handleViewportScroll() {
3539
- // On iOS, viewport scrolls when keyboard opens
3540
- if (this.visualViewport.offsetTop > 0) {
3541
- this.setKeyboardOpen(true, this.visualViewport.offsetTop);
3542
- }
3543
- }
3544
-
3545
- /**
3546
- * Handle window resize (fallback for older browsers)
3547
- */
3548
- handleWindowResize() {
3549
- const currentHeight = window.innerHeight;
3550
- const heightDifference = this.viewportHeight - currentHeight;
3551
-
3552
- // Significant height reduction likely means keyboard opened
3553
- if (heightDifference > 100) {
3554
- this.setKeyboardOpen(true, heightDifference);
3555
- } else if (Math.abs(heightDifference) < 50) {
3556
- this.setKeyboardOpen(false, 0);
3557
- this.viewportHeight = currentHeight;
3558
- }
3559
- }
3560
-
3561
- /**
3562
- * Handle input focus
3563
- */
3564
- handleFocusIn(e) {
3565
- const isInput = ['input', 'textarea'].includes(e.target.tagName.toLowerCase());
3566
- if (isInput) {
3567
- this.inputElement = e.target;
3568
- // Delay to allow keyboard animation
3569
- setTimeout(() => {
3570
- if (this.inputElement === e.target) {
3571
- this.checkKeyboardStatus();
3572
- }
3573
- }, 300);
3574
- }
3575
- }
3576
-
3577
- /**
3578
- * Handle input blur
3579
- */
3580
- handleFocusOut(e) {
3581
- const isInput = ['input', 'textarea'].includes(e.target.tagName.toLowerCase());
3582
- if (isInput && this.inputElement === e.target) {
3583
- this.inputElement = null;
3584
- // Delay to allow keyboard animation
3585
- setTimeout(() => {
3586
- if (!this.inputElement) {
3587
- this.setKeyboardOpen(false, 0);
3588
- }
3589
- }, 300);
3590
- }
3591
- }
3592
-
3593
- /**
3594
- * Handle orientation change
3595
- */
3596
- handleOrientationChange() {
3597
- // Reset viewport height after orientation change
3598
- setTimeout(() => {
3599
- this.viewportHeight = window.innerHeight;
3600
- this.checkKeyboardStatus();
3601
- }, 500);
3602
- }
3603
-
3604
- /**
3605
- * Check keyboard status (for browsers without Visual Viewport API)
3606
- */
3607
- checkKeyboardStatus() {
3608
- if (!this.visualViewport) {
3609
- const currentHeight = window.innerHeight;
3610
- const heightDifference = this.viewportHeight - currentHeight;
3611
-
3612
- if (this.inputElement && heightDifference > 100) {
3613
- this.setKeyboardOpen(true, heightDifference);
3614
- }
3615
- }
3616
- }
3617
-
3618
- /**
3619
- * Set keyboard state and notify listeners
3620
- */
3621
- setKeyboardOpen(isOpen, height) {
3622
- const changed = this.isKeyboardOpen !== isOpen || this.keyboardHeight !== height;
3623
-
3624
- this.isKeyboardOpen = isOpen;
3625
- this.keyboardHeight = height;
3626
-
3627
- if (changed) {
3628
- this.notifyListeners();
3629
- }
3630
- }
3631
-
3632
- /**
3633
- * Add listener for keyboard state changes
3634
- */
3635
- addListener(callback) {
3636
- this.listeners.add(callback);
3637
- // Immediately notify with current state
3638
- callback({
3639
- isOpen: this.isKeyboardOpen,
3640
- height: this.keyboardHeight
3641
- });
3642
- }
3643
-
3644
- /**
3645
- * Remove listener
3646
- */
3647
- removeListener(callback) {
3648
- this.listeners.delete(callback);
3649
- }
3650
-
3651
- /**
3652
- * Notify all listeners of state change
3653
- */
3654
- notifyListeners() {
3655
- const state = {
3656
- isOpen: this.isKeyboardOpen,
3657
- height: this.keyboardHeight
3658
- };
3659
-
3660
- this.listeners.forEach(callback => {
3661
- try {
3662
- callback(state);
3663
- } catch (error) {
3664
- console.error('Error in keyboard detector listener:', error);
3665
- }
3666
- });
3667
- }
3668
-
3669
- /**
3670
- * Get current keyboard state
3671
- */
3672
- getState() {
3673
- return {
3674
- isOpen: this.isKeyboardOpen,
3675
- height: this.keyboardHeight
3676
- }
3677
- }
3678
-
3679
- /**
3680
- * Cleanup
3681
- */
3682
- destroy() {
3683
- if (this.visualViewport) {
3684
- this.visualViewport.removeEventListener('resize', this.handleViewportResize.bind(this));
3685
- this.visualViewport.removeEventListener('scroll', this.handleViewportScroll.bind(this));
3686
- } else {
3687
- window.removeEventListener('resize', this.handleWindowResize.bind(this));
3688
- }
3689
-
3690
- document.removeEventListener('focusin', this.handleFocusIn.bind(this), true);
3691
- document.removeEventListener('focusout', this.handleFocusOut.bind(this), true);
3692
- window.removeEventListener('orientationchange', this.handleOrientationChange.bind(this));
3693
-
3694
- this.listeners.clear();
3695
- }
3696
- }
3697
-
3698
- // Create singleton instance
3699
- let keyboardDetectorInstance = null;
3700
-
3701
- /**
3702
- * Get or create keyboard detector instance
3703
- */
3704
- function getKeyboardDetector() {
3705
- if (!keyboardDetectorInstance) {
3706
- keyboardDetectorInstance = new KeyboardDetector();
3707
- }
3708
- return keyboardDetectorInstance
3709
- }
3710
-
3711
- /**
3712
- * Update CSS custom property on host element
3713
- */
3714
- function updateKeyboardHeight(hostElement, height) {
3715
- if (hostElement) {
3716
- hostElement.style.setProperty('--andy-keyboard-height', `${height}px`);
3717
- }
3718
- }
3719
-
3720
3497
  /**
3721
3498
  * Mobile detection utility for AndyChat
3722
3499
  * Provides reliable mobile device detection with multiple methods
@@ -3771,12 +3548,12 @@ const callConvex = async (convexUrl, kind, path, wireArgs, keepalive = false) =>
3771
3548
  const sortMessages = messages => [...messages].sort((left, right) =>
3772
3549
  left.createdAt - right.createdAt || String(left.id).localeCompare(String(right.id)));
3773
3550
 
3774
- const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3551
+ const createBrowserHandoff = (chatbotId, bootstrap, onProjection, fresh = false) => {
3775
3552
  if (!bootstrap?.convexUrl || !bootstrap.surfaceKey || !bootstrap.surfaceProof) return null
3776
3553
  const storageKey = identityKey(chatbotId);
3777
3554
  let identity;
3778
3555
  try {
3779
- const stored = JSON.parse(window.sessionStorage.getItem(storageKey) || 'null');
3556
+ const stored = fresh ? null : JSON.parse(window.sessionStorage.getItem(storageKey) || 'null');
3780
3557
  identity = stored?.sessionId && stored?.capability && stored.surfaceKey === bootstrap.surfaceKey
3781
3558
  ? { sessionId: stored.sessionId, capability: stored.capability }
3782
3559
  : randomIdentity();
@@ -3865,6 +3642,14 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3865
3642
  if (active) onProjection({ offline: true, identity, messages: [] });
3866
3643
  }
3867
3644
  }, 15000);
3645
+ const stop = () => {
3646
+ if (!active) return
3647
+ active = false;
3648
+ window.clearInterval(timer);
3649
+ document.removeEventListener('visibilitychange', onVisibility);
3650
+ window.removeEventListener('pagehide', release);
3651
+ void release();
3652
+ };
3868
3653
  return {
3869
3654
  identity: { sessionId: identity.sessionId },
3870
3655
  recordVisitorMessage: content => {
@@ -3886,14 +3671,11 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3886
3671
  acknowledging.delete(messageId);
3887
3672
  }
3888
3673
  },
3889
- stop: () => {
3890
- if (!active) return
3891
- active = false;
3892
- window.clearInterval(timer);
3893
- document.removeEventListener('visibilitychange', onVisibility);
3894
- window.removeEventListener('pagehide', release);
3895
- void release();
3896
- }
3674
+ restart: () => {
3675
+ stop();
3676
+ return createBrowserHandoff(chatbotId, bootstrap, onProjection, true)
3677
+ },
3678
+ stop
3897
3679
  }
3898
3680
  };
3899
3681
 
@@ -3907,6 +3689,8 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3907
3689
  const [isOpen, setIsOpen] = react.useState(initialOpen);
3908
3690
  const [messages, setMessages] = react.useState([]);
3909
3691
  const [isLoading, setIsLoading] = react.useState(false);
3692
+ const [replyStarted, setReplyStarted] = react.useState(false);
3693
+ const activeReplyRef = react.useRef(null);
3910
3694
  const [sessionId, setSessionId] = react.useState(() => generateSessionId());
3911
3695
  const handoffRef = react.useRef(null);
3912
3696
  const pendingHandoffIdsRef = react.useRef([]);
@@ -3930,6 +3714,11 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3930
3714
  const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
3931
3715
  if (projection.offline)
3932
3716
  return;
3717
+ const paused = projection.control === 'requested' || projection.control === 'human';
3718
+ if (paused)
3719
+ activeReplyRef.current?.abort();
3720
+ else if (activeReplyRef.current)
3721
+ return;
3933
3722
  setSessionId(projection.identity.sessionId);
3934
3723
  pendingHandoffIdsRef.current = projection.pendingProjectionIds || [];
3935
3724
  const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' : 'bot', timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }));
@@ -3941,8 +3730,11 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3941
3730
  : projectedMessages);
3942
3731
  });
3943
3732
  handoffRef.current = handoff;
3733
+ if (handoff)
3734
+ setSessionId(handoff.identity.sessionId);
3944
3735
  return () => {
3945
- handoff?.stop();
3736
+ activeReplyRef.current?.abort();
3737
+ handoffRef.current?.stop();
3946
3738
  handoffRef.current = null;
3947
3739
  };
3948
3740
  }, [config?.bootstrap, config?.id]);
@@ -3991,22 +3783,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3991
3783
  portalRef.current = portalTarget;
3992
3784
  }
3993
3785
  setShadowReady(true);
3994
- const keyboardDetector = getKeyboardDetector();
3995
- const keyboardListener = ({ isOpen: kbOpen, height }) => {
3996
- updateKeyboardHeight(containerRef.current, height);
3997
- const chatWindow = shadowRef.current?.querySelector('.andy-chat-window');
3998
- if (chatWindow) {
3999
- if (kbOpen && height > 0) {
4000
- chatWindow.classList.add('andy-chat-window--keyboard-open');
4001
- }
4002
- else {
4003
- chatWindow.classList.remove('andy-chat-window--keyboard-open');
4004
- }
4005
- }
4006
- };
4007
- keyboardDetector.addListener(keyboardListener);
4008
3786
  return () => {
4009
- keyboardDetector.removeListener(keyboardListener);
4010
3787
  shadowRef.current = null;
4011
3788
  portalRef.current = null;
4012
3789
  setShadowReady(false);
@@ -4264,11 +4041,13 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4264
4041
  }, [isCollectingUserInfo, messages]);
4265
4042
  const sendUserMessage = react.useCallback(async (rawMessageText, options = {}) => {
4266
4043
  const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : '';
4267
- if (!messageText) {
4044
+ if (!messageText || activeReplyRef.current) {
4268
4045
  return;
4269
4046
  }
4270
4047
  const { hidePredefined = true } = options;
4271
4048
  const timestamp = Date.now();
4049
+ const controller = new AbortController();
4050
+ activeReplyRef.current = controller;
4272
4051
  const userMessage = {
4273
4052
  id: timestamp,
4274
4053
  content: messageText,
@@ -4282,18 +4061,30 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4282
4061
  }
4283
4062
  try {
4284
4063
  setIsLoading(true);
4285
- const response = await sendMessageForWidget(embedId, sessionId, messageText);
4064
+ setReplyStarted(false);
4065
+ const showReply = (content, isStreaming) => {
4066
+ if (controller.signal.aborted)
4067
+ return;
4068
+ setReplyStarted(true);
4069
+ const reply = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming };
4070
+ setMessages(prev => prev.some(message => message.id === reply.id)
4071
+ ? prev.map(message => message.id === reply.id ? reply : message)
4072
+ : [...prev, reply]);
4073
+ };
4074
+ const response = await sendMessageForWidget(embedId, sessionId, messageText, {
4075
+ signal: controller.signal,
4076
+ onText: text => showReply(text, true)
4077
+ });
4078
+ if (controller.signal.aborted)
4079
+ return;
4286
4080
  if (response.status === 'waiting') {
4287
- setMessages(prev => prev.some(message => message.isWaiting) ? prev : [...prev, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]);
4081
+ setMessages(prev => {
4082
+ const visible = prev.filter(message => message.id !== timestamp + 1);
4083
+ return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }];
4084
+ });
4288
4085
  return;
4289
4086
  }
4290
- const botMessage = {
4291
- id: timestamp + 1,
4292
- content: response.text,
4293
- sender: 'bot',
4294
- timestamp: new Date().toISOString()
4295
- };
4296
- setMessages(prev => [...prev, botMessage]);
4087
+ showReply(response.text, false);
4297
4088
  if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
4298
4089
  setPendingMessage(messageText);
4299
4090
  setIsCollectingUserInfo(true);
@@ -4308,6 +4099,8 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4308
4099
  }
4309
4100
  }
4310
4101
  catch {
4102
+ if (controller.signal.aborted)
4103
+ return;
4311
4104
  console.error('AndyChat: Message request failed.');
4312
4105
  const errorMessage = {
4313
4106
  id: timestamp + 3,
@@ -4316,10 +4109,14 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4316
4109
  timestamp: new Date().toISOString(),
4317
4110
  isError: true
4318
4111
  };
4319
- setMessages(prev => [...prev, errorMessage]);
4112
+ setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage]);
4320
4113
  }
4321
4114
  finally {
4322
- setIsLoading(false);
4115
+ if (activeReplyRef.current === controller) {
4116
+ activeReplyRef.current = null;
4117
+ setIsLoading(false);
4118
+ setReplyStarted(false);
4119
+ }
4323
4120
  }
4324
4121
  }, [config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]);
4325
4122
  const handleSendMessage = react.useCallback((messageText) => {
@@ -4481,21 +4278,27 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4481
4278
  if (message.isLoading) {
4482
4279
  return (jsxRuntime.jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--loading', children: jsxRuntime.jsx("div", { className: 'andy-message andy-message--bot', children: jsxRuntime.jsxs("div", { className: 'andy-typing-indicator', children: [jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }, message.id));
4483
4280
  }
4484
- return (jsxRuntime.jsxs("div", { className: `andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`, children: [message.sender === 'bot' && (jsxRuntime.jsxs("div", { className: `andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`, children: [jsxRuntime.jsxs("div", { className: 'andy-bot-header', children: [jsxRuntime.jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsxRuntime.jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsxRuntime.jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsxRuntime.jsx("span", { className: 'andy-bot-header__name', children: message.isHuman ? strings.teamName : config?.name || 'Andy bot' })] }), jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsxRuntime.jsx("div", { className: 'andy-message andy-message--user', children: jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("p", { children: message.content }) }) }))] }, message.id));
4485
- }), isLoading && (jsxRuntime.jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--typing', children: jsxRuntime.jsx("div", { className: 'andy-message andy-message--bot andy-message--typing', children: jsxRuntime.jsxs("div", { className: 'andy-typing-indicator', children: [jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }))] }), jsxRuntime.jsxs("div", { className: 'andy-chat-window__bottom', children: [activeQuestions &&
4281
+ return (jsxRuntime.jsxs("div", { className: `andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`, children: [message.sender === 'bot' && (jsxRuntime.jsxs("div", { className: `andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`, children: [jsxRuntime.jsxs("div", { className: 'andy-bot-header', children: [jsxRuntime.jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsxRuntime.jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsxRuntime.jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsxRuntime.jsx("span", { className: 'andy-bot-header__name', children: message.isHuman ? strings.teamName : config?.name || 'Andy bot' })] }), jsxRuntime.jsx("div", { className: 'andy-message__content', children: message.isStreaming ? jsxRuntime.jsx("div", { className: 'andy-message__stream', children: message.content }) : jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsxRuntime.jsx("div", { className: 'andy-message andy-message--user', children: jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("p", { children: message.content }) }) }))] }, message.id));
4282
+ }), isLoading && !replyStarted && (jsxRuntime.jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--typing', children: jsxRuntime.jsx("div", { className: 'andy-message andy-message--bot andy-message--typing', children: jsxRuntime.jsxs("div", { className: 'andy-typing-indicator', children: [jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' }), jsxRuntime.jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }))] }), jsxRuntime.jsxs("div", { className: 'andy-chat-window__bottom', children: [activeQuestions &&
4486
4283
  activeQuestions.length > 0 &&
4487
4284
  showPredefinedQuestions && (jsxRuntime.jsxs("div", { className: 'andy-predefined-questions', id: 'andy-predefined-questions', children: [jsxRuntime.jsx("div", { className: 'andy-predefined-questions__header', children: jsxRuntime.jsx("p", { className: 'andy-predefined-questions__title', children: strings.predefinedQuestionsTitle }) }), jsxRuntime.jsx("div", { className: 'andy-predefined-questions__list', children: activeQuestions.map((q, index) => (jsxRuntime.jsxs("button", { type: 'button', className: 'andy-predefined-question', "data-question": q.question, disabled: isCollectingUserInfo || isLoading || !config, children: [jsxRuntime.jsx("span", { className: 'andy-predefined-question__icon', children: "\uD83D\uDCAC" }), jsxRuntime.jsx("span", { className: 'andy-predefined-question__text', children: q.question })] }, index))) })] })), jsxRuntime.jsx("div", { className: 'andy-chat-window__input', children: jsxRuntime.jsx("div", { className: 'andy-input-container', children: jsxRuntime.jsxs("div", { className: 'andy-input', children: [jsxRuntime.jsx("input", { type: 'text', className: 'andy-input__field', id: 'andy-message-input', placeholder: config ? strings.inputPlaceholder : '...', "aria-label": strings.messageInput, onKeyDown: e => {
4285
+ if (e.key === 'Enter' && activeReplyRef.current) {
4286
+ e.preventDefault();
4287
+ return;
4288
+ }
4488
4289
  if (e.key === 'Enter' && e.target.value.trim()) {
4489
4290
  handleSendMessage(e.target.value);
4490
4291
  e.target.value = '';
4491
4292
  }
4492
4293
  }, disabled: isCollectingUserInfo || !config }), jsxRuntime.jsx("button", { type: 'button', className: 'andy-input__button', id: 'andy-send-button', "aria-label": strings.sendMessage, onClick: () => {
4294
+ if (activeReplyRef.current)
4295
+ return;
4493
4296
  const input = shadowRef.current?.getElementById('andy-message-input');
4494
4297
  if (input && input.value.trim()) {
4495
4298
  handleSendMessage(input.value);
4496
4299
  input.value = '';
4497
4300
  }
4498
- }, disabled: isCollectingUserInfo || !config, children: jsxRuntime.jsx("svg", { className: 'andy-input__button-icon', width: '18', height: '18', viewBox: '0 0 24 24', fill: 'currentColor', xmlns: 'http://www.w3.org/2000/svg', children: jsxRuntime.jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' }) }) })] }) }) }), config?.showBranding !== false && (jsxRuntime.jsx("footer", { className: 'andy-chat-window__footer', children: jsxRuntime.jsx("a", { href: getFooterUrl(), target: '_blank', rel: 'noopener noreferrer', className: 'andy-chat-window__footer-link', children: jsxRuntime.jsx("span", { className: 'andy-chat-window__footer-text', children: FOOTER_TEXT }) }) }))] })] }), portalRef.current), showWelcomePopup &&
4301
+ }, disabled: isCollectingUserInfo || isLoading || !config, children: jsxRuntime.jsx("svg", { className: 'andy-input__button-icon', width: '18', height: '18', viewBox: '0 0 24 24', fill: 'currentColor', xmlns: 'http://www.w3.org/2000/svg', children: jsxRuntime.jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' }) }) })] }) }) }), config?.showBranding !== false && (jsxRuntime.jsx("footer", { className: 'andy-chat-window__footer', children: jsxRuntime.jsx("a", { href: getFooterUrl(), target: '_blank', rel: 'noopener noreferrer', className: 'andy-chat-window__footer-link', children: jsxRuntime.jsx("span", { className: 'andy-chat-window__footer-text', children: FOOTER_TEXT }) }) }))] })] }), portalRef.current), showWelcomePopup &&
4499
4302
  !isOpen &&
4500
4303
  ReactDOM.createPortal(jsxRuntime.jsxs("div", { className: `andy-welcome-popup ${config?.orientation === 'left' ? 'andychat-popup-left' : ''}`, style: { display: 'block' }, role: 'dialog', "aria-label": strings.welcomeMessage, children: [jsxRuntime.jsxs("div", { className: 'andy-welcome-popup__content', children: [jsxRuntime.jsx("div", { className: 'andy-welcome-popup__message', children: config?.showWelcomePopup?.message ||
4501
4304
  config?.welcomeMessage ||
@@ -4516,6 +4319,11 @@ const useAndyChat = (embedId) => {
4516
4319
  const [showingWelcomePopup, setShowingWelcomePopup] = react.useState(false);
4517
4320
  const [showingUserInfoForm, setShowingUserInfoForm] = react.useState(false);
4518
4321
  const [welcomePopupShown, setWelcomePopupShown] = react.useState(false);
4322
+ const activeReplyRef = react.useRef(null);
4323
+ react.useEffect(() => () => {
4324
+ activeReplyRef.current?.abort();
4325
+ activeReplyRef.current = null;
4326
+ }, [embedId]);
4519
4327
  const welcomeTimeoutRef = react.useRef(null);
4520
4328
  const userInfoTimeoutRef = react.useRef(null);
4521
4329
  const initializeChatbot = react.useCallback(async () => {
@@ -4587,9 +4395,11 @@ const useAndyChat = (embedId) => {
4587
4395
  });
4588
4396
  }, [config, welcomePopupShown, userInfoCollected]);
4589
4397
  const sendMessage = react.useCallback(async (messageText) => {
4590
- if (!messageText.trim() || !config || !sessionId) {
4398
+ if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4591
4399
  return;
4592
4400
  }
4401
+ const controller = new AbortController();
4402
+ activeReplyRef.current = controller;
4593
4403
  const userMessage = {
4594
4404
  id: Date.now(),
4595
4405
  content: messageText,
@@ -4597,20 +4407,29 @@ const useAndyChat = (embedId) => {
4597
4407
  timestamp: new Date().toISOString()
4598
4408
  };
4599
4409
  setMessages(prev => [...prev, userMessage]);
4410
+ const replyId = Date.now() + 1;
4411
+ const showReply = (content, isStreaming) => {
4412
+ if (controller.signal.aborted)
4413
+ return;
4414
+ const reply = { id: replyId, content, sender: 'bot', timestamp: new Date().toISOString(), isStreaming };
4415
+ setMessages(prev => prev.some(message => message.id === replyId)
4416
+ ? prev.map(message => message.id === replyId ? reply : message)
4417
+ : [...prev, reply]);
4418
+ };
4600
4419
  try {
4601
- const response = await sendMessageForWidget(embedId, sessionId, messageText);
4602
- if (response.status === 'waiting')
4420
+ const response = await sendMessageForWidget(embedId, sessionId, messageText, { signal: controller.signal, onText: text => showReply(text, true) });
4421
+ if (controller.signal.aborted)
4422
+ return;
4423
+ if (response.status === 'waiting') {
4424
+ setMessages(prev => prev.filter(message => message.id !== replyId));
4603
4425
  return response;
4604
- const botMessage = {
4605
- id: Date.now() + 1,
4606
- content: response.text || 'I apologize, but I encountered an error processing your request.',
4607
- sender: 'bot',
4608
- timestamp: new Date().toISOString()
4609
- };
4610
- setMessages(prev => [...prev, botMessage]);
4426
+ }
4427
+ showReply(response.text, false);
4611
4428
  return response;
4612
4429
  }
4613
4430
  catch (err) {
4431
+ if (controller.signal.aborted)
4432
+ return;
4614
4433
  console.error('Error sending message:', err);
4615
4434
  const errorMessage = {
4616
4435
  id: Date.now() + 1,
@@ -4619,9 +4438,13 @@ const useAndyChat = (embedId) => {
4619
4438
  timestamp: new Date().toISOString(),
4620
4439
  isError: true
4621
4440
  };
4622
- setMessages(prev => [...prev, errorMessage]);
4441
+ setMessages(prev => [...prev.filter(message => message.id !== replyId), errorMessage]);
4623
4442
  throw err;
4624
4443
  }
4444
+ finally {
4445
+ if (activeReplyRef.current === controller)
4446
+ activeReplyRef.current = null;
4447
+ }
4625
4448
  }, [embedId, sessionId, config]);
4626
4449
  const showFaq = react.useCallback(() => {
4627
4450
  setShowingFaq(true);
@@ -4660,6 +4483,8 @@ const useAndyChat = (embedId) => {
4660
4483
  }
4661
4484
  }, [embedId, sessionId]);
4662
4485
  const startNewSession = react.useCallback(() => {
4486
+ activeReplyRef.current?.abort();
4487
+ activeReplyRef.current = null;
4663
4488
  const newSessionId = generateSessionId();
4664
4489
  setSessionId(newSessionId);
4665
4490
  {
@@ -4729,9 +4554,11 @@ const MessageComponent = ({ message, botName }) => {
4729
4554
  .filter(Boolean)
4730
4555
  .join(' ');
4731
4556
  const messageClasses = ['andy-message', `andy-message--${message.sender}`].filter(Boolean).join(' ');
4732
- const messageContent = isBot ? parseMarkdown(message.content) : message.content;
4557
+ const messageContent = isBot && !message.isStreaming ? parseMarkdown(message.content) : message.content;
4733
4558
  if (isBot) {
4734
- return (jsxRuntime.jsxs("div", { className: containerClasses, children: [jsxRuntime.jsxs("div", { className: 'andy-bot-header', children: [jsxRuntime.jsx("div", { className: 'andy-bot-header__icon', children: jsxRuntime.jsxs("svg", { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: [jsxRuntime.jsx("path", { d: 'M12 6V2H8' }), jsxRuntime.jsx("path", { d: 'm8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z' }), jsxRuntime.jsx("path", { d: 'M2 12h2' }), jsxRuntime.jsx("path", { d: 'M9 11v2' }), jsxRuntime.jsx("path", { d: 'M15 11v2' }), jsxRuntime.jsx("path", { d: 'M20 12h2' })] }) }), jsxRuntime.jsx("span", { className: 'andy-bot-header__name', children: botName || 'Andy bot' })] }), jsxRuntime.jsx("div", { className: messageClasses, children: jsxRuntime.jsx("div", { className: 'andy-message__content', dangerouslySetInnerHTML: { __html: messageContent } }) })] }));
4559
+ return (jsxRuntime.jsxs("div", { className: containerClasses, children: [jsxRuntime.jsxs("div", { className: 'andy-bot-header', children: [jsxRuntime.jsx("div", { className: 'andy-bot-header__icon', children: jsxRuntime.jsxs("svg", { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: [jsxRuntime.jsx("path", { d: 'M12 6V2H8' }), jsxRuntime.jsx("path", { d: 'm8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z' }), jsxRuntime.jsx("path", { d: 'M2 12h2' }), jsxRuntime.jsx("path", { d: 'M9 11v2' }), jsxRuntime.jsx("path", { d: 'M15 11v2' }), jsxRuntime.jsx("path", { d: 'M20 12h2' })] }) }), jsxRuntime.jsx("span", { className: 'andy-bot-header__name', children: botName || 'Andy bot' })] }), jsxRuntime.jsx("div", { className: messageClasses, children: message.isStreaming
4560
+ ? jsxRuntime.jsx("div", { className: 'andy-message__content andy-message__stream', children: message.content })
4561
+ : jsxRuntime.jsx("div", { className: 'andy-message__content', dangerouslySetInnerHTML: { __html: messageContent } }) })] }));
4735
4562
  }
4736
4563
  return (jsxRuntime.jsx("div", { className: containerClasses, children: jsxRuntime.jsx("div", { className: messageClasses, children: jsxRuntime.jsx("div", { className: 'andy-message__content', children: jsxRuntime.jsx("p", { children: messageContent }) }) }) }));
4737
4564
  };
@@ -4746,7 +4573,7 @@ const MessageList = ({ messages, isLoading, logoUrl, botName }) => {
4746
4573
  });
4747
4574
  }
4748
4575
  }, [messages, isLoading]);
4749
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [messages.map(message => (jsxRuntime.jsx(MessageComponent, { message: message, logoUrl: logoUrl, botName: botName }, message.id || message.timestamp))), isLoading && jsxRuntime.jsx(TypingIndicator, {}), messages.length === 0 && !isLoading && (jsxRuntime.jsxs("div", { className: 'andy-empty-state', children: [jsxRuntime.jsx("div", { className: 'andy-empty-state__icon', children: "\uD83D\uDCAC" }), jsxRuntime.jsx("div", { className: 'andy-empty-state__text', children: "Start a conversation by typing a message below" })] })), jsxRuntime.jsx("div", { ref: messagesEndRef })] }));
4576
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [messages.map(message => (jsxRuntime.jsx(MessageComponent, { message: message, logoUrl: logoUrl, botName: botName }, message.id || message.timestamp))), isLoading && !messages.some(message => message.isStreaming) && jsxRuntime.jsx(TypingIndicator, {}), messages.length === 0 && !isLoading && (jsxRuntime.jsxs("div", { className: 'andy-empty-state', children: [jsxRuntime.jsx("div", { className: 'andy-empty-state__icon', children: "\uD83D\uDCAC" }), jsxRuntime.jsx("div", { className: 'andy-empty-state__text', children: "Start a conversation by typing a message below" })] })), jsxRuntime.jsx("div", { ref: messagesEndRef })] }));
4750
4577
  };
4751
4578
 
4752
4579
  const MessageInput = ({ onSendMessage, disabled = false, placeholder = 'Type your message here...' }) => {
@@ -4761,14 +4588,15 @@ const MessageInput = ({ onSendMessage, disabled = false, placeholder = 'Type you
4761
4588
  }
4762
4589
  try {
4763
4590
  setIsSubmitting(true);
4764
- await onSendMessage(trimmedMessage);
4765
4591
  setMessage('');
4592
+ await onSendMessage(trimmedMessage);
4766
4593
  if (inputRef.current) {
4767
4594
  inputRef.current.focus();
4768
4595
  }
4769
4596
  }
4770
4597
  catch (error) {
4771
4598
  console.error('Error sending message:', error);
4599
+ setMessage(current => current || trimmedMessage);
4772
4600
  }
4773
4601
  finally {
4774
4602
  setIsSubmitting(false);
@@ -4785,7 +4613,7 @@ const MessageInput = ({ onSendMessage, disabled = false, placeholder = 'Type you
4785
4613
  };
4786
4614
  const isInputDisabled = disabled || isSubmitting;
4787
4615
  const canSend = message.trim().length > 0 && !isInputDisabled;
4788
- return (jsxRuntime.jsxs("div", { className: 'andy-input', children: [jsxRuntime.jsx("input", { ref: inputRef, type: 'text', value: message, onChange: handleInputChange, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: isInputDisabled, maxLength: 1000, autoComplete: 'off', id: 'andy-input' }), jsxRuntime.jsx("button", { type: 'button', onClick: () => handleSubmit({ preventDefault: () => { } }), disabled: !canSend, id: 'andy-send', children: isSubmitting ? ('...') : (jsxRuntime.jsx("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsxRuntime.jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z', fill: 'white' }) })) })] }));
4616
+ return (jsxRuntime.jsxs("div", { className: 'andy-input', children: [jsxRuntime.jsx("input", { ref: inputRef, type: 'text', value: message, onChange: handleInputChange, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: disabled, maxLength: 1000, autoComplete: 'off', id: 'andy-input' }), jsxRuntime.jsx("button", { type: 'button', onClick: () => handleSubmit({ preventDefault: () => { } }), disabled: !canSend, id: 'andy-send', children: isSubmitting ? ('...') : (jsxRuntime.jsx("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsxRuntime.jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z', fill: 'white' }) })) })] }));
4789
4617
  };
4790
4618
 
4791
4619
  const ChatWindow = ({ config, messages, isLoading, onClose, onSendMessage }) => {