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