andy-widget-react 2.1.0 → 2.2.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.
@@ -54,6 +54,7 @@ export interface Message {
54
54
  isError?: boolean;
55
55
  isHuman?: boolean;
56
56
  isWaiting?: boolean;
57
+ isStreaming?: boolean;
57
58
  }
58
59
  export interface UserInfo {
59
60
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "andy-widget-react",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Official React Widget for embeddable Andy agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -67,6 +67,6 @@
67
67
  "react-dom": "^18.0.0 || ^19.0.0"
68
68
  },
69
69
  "dependencies": {
70
- "andy-widget-core": "2.1.0"
70
+ "andy-widget-core": "2.2.0"
71
71
  }
72
72
  }
package/src/AndyChat.tsx CHANGED
@@ -95,6 +95,8 @@ const AndyChat: React.FC<AndyChatProps> = ({
95
95
  const [isOpen, setIsOpen] = useState(initialOpen)
96
96
  const [messages, setMessages] = useState<Message[]>([])
97
97
  const [isLoading, setIsLoading] = useState(false)
98
+ const [replyStarted, setReplyStarted] = useState(false)
99
+ const activeReplyRef = useRef<AbortController | null>(null)
98
100
  const [sessionId, setSessionId] = useState(() => generateSessionId())
99
101
  const handoffRef = useRef<ReturnType<typeof createBrowserHandoff>>(null)
100
102
  const pendingHandoffIdsRef = useRef<string[]>([])
@@ -118,16 +120,23 @@ const AndyChat: React.FC<AndyChatProps> = ({
118
120
  handoffRef.current?.stop()
119
121
  const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
120
122
  if (projection.offline) return
123
+ const paused = projection.control === 'requested' || projection.control === 'human'
124
+ if (paused) activeReplyRef.current?.abort()
125
+ else if (activeReplyRef.current) return
121
126
  setSessionId(projection.identity.sessionId)
122
127
  pendingHandoffIdsRef.current = projection.pendingProjectionIds || []
123
128
  const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' as const : 'bot' as const, timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }))
129
+ // An empty agent projection does not mean the client-only greeting is gone.
130
+ if (projection.control === 'agent' && projectedMessages.length === 0) return
124
131
  setMessages(projection.control === 'requested' || projection.control === 'human'
125
132
  ? [...projectedMessages, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
126
133
  : projectedMessages)
127
134
  })
128
135
  handoffRef.current = handoff
136
+ if (handoff) setSessionId(handoff.identity.sessionId)
129
137
  return () => {
130
- handoff?.stop()
138
+ activeReplyRef.current?.abort()
139
+ handoffRef.current?.stop()
131
140
  handoffRef.current = null
132
141
  }
133
142
  }, [config?.bootstrap, config?.id])
@@ -485,7 +494,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
485
494
  }, [isCollectingUserInfo, messages])
486
495
 
487
496
  const handleNewChat = () => {
488
- setSessionId(generateSessionId())
497
+ activeReplyRef.current?.abort()
498
+ activeReplyRef.current = null
499
+ setIsLoading(false)
500
+ setReplyStarted(false)
501
+ handoffRef.current = handoffRef.current?.restart() || null
502
+ pendingHandoffIdsRef.current = []
503
+ setSessionId(handoffRef.current?.identity.sessionId || generateSessionId())
489
504
 
490
505
  if (config?.welcomeMessage) {
491
506
  const greeting = createGreetingMessage(config)
@@ -511,12 +526,14 @@ const AndyChat: React.FC<AndyChatProps> = ({
511
526
  const sendUserMessage = useCallback(
512
527
  async (rawMessageText: string, options: { hidePredefined?: boolean } = {}) => {
513
528
  const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : ''
514
- if (!messageText) {
529
+ if (!messageText || activeReplyRef.current) {
515
530
  return
516
531
  }
517
532
 
518
533
  const { hidePredefined = true } = options
519
534
  const timestamp = Date.now()
535
+ const controller = new AbortController()
536
+ activeReplyRef.current = controller
520
537
 
521
538
  const userMessage: Message = {
522
539
  id: timestamp,
@@ -533,19 +550,29 @@ const AndyChat: React.FC<AndyChatProps> = ({
533
550
 
534
551
  try {
535
552
  setIsLoading(true)
536
- const response = await sendMessageToAPI(embedId!, sessionId, messageText)
553
+ setReplyStarted(false)
554
+ const showReply = (content: string, isStreaming: boolean) => {
555
+ if (controller.signal.aborted) return
556
+ setReplyStarted(true)
557
+ const reply: Message = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming }
558
+ setMessages(prev => prev.some(message => message.id === reply.id)
559
+ ? prev.map(message => message.id === reply.id ? reply : message)
560
+ : [...prev, reply])
561
+ }
562
+ const response = await sendMessageToAPI(embedId!, sessionId, messageText, {
563
+ signal: controller.signal,
564
+ onText: text => showReply(text, true)
565
+ })
566
+ if (controller.signal.aborted) return
537
567
  if (response.status === 'waiting') {
538
- setMessages(prev => prev.some(message => message.isWaiting) ? prev : [...prev, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }])
568
+ setMessages(prev => {
569
+ const visible = prev.filter(message => message.id !== timestamp + 1)
570
+ return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
571
+ })
539
572
  return
540
573
  }
541
574
 
542
- const botMessage: Message = {
543
- id: timestamp + 1,
544
- content: response.text,
545
- sender: 'bot',
546
- timestamp: new Date().toISOString()
547
- }
548
- setMessages(prev => [...prev, botMessage])
575
+ showReply(response.text, false)
549
576
 
550
577
  if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
551
578
  setPendingMessage(messageText)
@@ -561,6 +588,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
561
588
  setMessages(prev => [...prev, formMessage])
562
589
  }
563
590
  } catch {
591
+ if (controller.signal.aborted) return
564
592
  console.error('AndyChat: Message request failed.')
565
593
  const errorMessage: Message = {
566
594
  id: timestamp + 3,
@@ -569,9 +597,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
569
597
  timestamp: new Date().toISOString(),
570
598
  isError: true
571
599
  }
572
- setMessages(prev => [...prev, errorMessage])
600
+ setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage])
573
601
  } finally {
574
- setIsLoading(false)
602
+ if (activeReplyRef.current === controller) {
603
+ activeReplyRef.current = null
604
+ setIsLoading(false)
605
+ setReplyStarted(false)
606
+ }
575
607
  }
576
608
  },
577
609
  [config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]
@@ -696,7 +728,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
696
728
  bubble = document.createElement('button')
697
729
  bubble.type = 'button'
698
730
  bubble.className = 'andy-chat-bubble'
699
- bubble.setAttribute('aria-label', 'Open chat')
731
+ bubble.setAttribute('aria-label', strings.openChat)
700
732
 
701
733
  const tooltipHTML = tooltip
702
734
  ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
@@ -713,6 +745,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
713
745
  const bubbleLogoUrl = config?.logoUrl || CHAT_BUBBLE_DEFAULT_ICON_URL
714
746
 
715
747
  if (bubble) {
748
+ bubble.setAttribute('aria-label', isOpen ? strings.closeChat : strings.openChat)
716
749
  bubble.className = 'andy-chat-bubble'
717
750
  if (isPillStyle) {
718
751
  bubble.classList.add('andy-chat-bubble--pill')
@@ -775,7 +808,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
775
808
  type='button'
776
809
  className='andy-header__back'
777
810
  id='andy-back-button'
778
- aria-label='Go back'
811
+ aria-label={strings.goBack}
779
812
  onClick={() => setIsOpen(false)}
780
813
  >
781
814
  <svg
@@ -821,7 +854,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
821
854
  type='button'
822
855
  className='andy-header__close'
823
856
  id='andy-close-button'
824
- aria-label='Close chat'
857
+ aria-label={strings.closeChat}
825
858
  onClick={() => setIsOpen(false)}
826
859
  >
827
860
  <svg
@@ -960,7 +993,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
960
993
  </span>
961
994
  </div>
962
995
  <div className='andy-message__content'>
963
- <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />
996
+ {message.isStreaming ? <div className='andy-message__stream'>{message.content}</div> : <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />}
964
997
  </div>
965
998
  </div>
966
999
  )}
@@ -975,7 +1008,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
975
1008
  )
976
1009
  })}
977
1010
 
978
- {isLoading && (
1011
+ {isLoading && !replyStarted && (
979
1012
  <div className='andy-message-container andy-message-container--bot andy-message-container--typing'>
980
1013
  <div className='andy-message andy-message--bot andy-message--typing'>
981
1014
  <div className='andy-typing-indicator'>
@@ -1021,8 +1054,9 @@ const AndyChat: React.FC<AndyChatProps> = ({
1021
1054
  className='andy-input__field'
1022
1055
  id='andy-message-input'
1023
1056
  placeholder={config ? strings.inputPlaceholder : '...'}
1024
- aria-label='Message input'
1057
+ aria-label={strings.messageInput}
1025
1058
  onKeyDown={e => {
1059
+ if (e.key === 'Enter' && activeReplyRef.current) { e.preventDefault(); return }
1026
1060
  if (e.key === 'Enter' && (e.target as HTMLInputElement).value.trim()) {
1027
1061
  handleSendMessage((e.target as HTMLInputElement).value)
1028
1062
  ;(e.target as HTMLInputElement).value = ''
@@ -1034,8 +1068,9 @@ const AndyChat: React.FC<AndyChatProps> = ({
1034
1068
  type='button'
1035
1069
  className='andy-input__button'
1036
1070
  id='andy-send-button'
1037
- aria-label='Send message'
1071
+ aria-label={strings.sendMessage}
1038
1072
  onClick={() => {
1073
+ if (activeReplyRef.current) return
1039
1074
  const input = shadowRef.current?.getElementById(
1040
1075
  'andy-message-input'
1041
1076
  ) as HTMLInputElement | null
@@ -1044,7 +1079,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1044
1079
  input.value = ''
1045
1080
  }
1046
1081
  }}
1047
- disabled={isCollectingUserInfo || !config}
1082
+ disabled={isCollectingUserInfo || isLoading || !config}
1048
1083
  >
1049
1084
  <svg
1050
1085
  className='andy-input__button-icon'
@@ -1084,7 +1119,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1084
1119
  className={`andy-welcome-popup ${config?.orientation === 'left' ? 'andychat-popup-left' : ''}`}
1085
1120
  style={{ display: 'block' }}
1086
1121
  role='dialog'
1087
- aria-label='Welcome message'
1122
+ aria-label={strings.welcomeMessage}
1088
1123
  >
1089
1124
  <div className='andy-welcome-popup__content'>
1090
1125
  <div className='andy-welcome-popup__message'>
@@ -1095,7 +1130,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1095
1130
  <button
1096
1131
  type='button'
1097
1132
  className='andy-welcome-popup__close'
1098
- aria-label='Close welcome message'
1133
+ aria-label={strings.closeWelcomeMessage}
1099
1134
  onClick={() => setShowWelcomePopup(false)}
1100
1135
  >
1101
1136
  <svg
@@ -25,14 +25,15 @@ const MessageInput: React.FC<MessageInputProps> = ({
25
25
 
26
26
  try {
27
27
  setIsSubmitting(true)
28
- await onSendMessage(trimmedMessage)
29
28
  setMessage('')
29
+ await onSendMessage(trimmedMessage)
30
30
 
31
31
  if (inputRef.current) {
32
32
  inputRef.current.focus()
33
33
  }
34
34
  } catch (error) {
35
35
  console.error('Error sending message:', error)
36
+ setMessage(current => current || trimmedMessage)
36
37
  } finally {
37
38
  setIsSubmitting(false)
38
39
  }
@@ -61,7 +62,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
61
62
  onChange={handleInputChange}
62
63
  onKeyDown={handleKeyDown}
63
64
  placeholder={placeholder}
64
- disabled={isInputDisabled}
65
+ disabled={disabled}
65
66
  maxLength={1000}
66
67
  autoComplete='off'
67
68
  id='andy-input'
@@ -17,7 +17,7 @@ const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
17
17
 
18
18
  const messageClasses = ['andy-message', `andy-message--${message.sender}`].filter(Boolean).join(' ')
19
19
 
20
- const messageContent = isBot ? parseMarkdown(message.content) : message.content
20
+ const messageContent = isBot && !message.isStreaming ? parseMarkdown(message.content) : message.content
21
21
 
22
22
  if (isBot) {
23
23
  return (
@@ -46,7 +46,9 @@ const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
46
46
  <span className='andy-bot-header__name'>{botName || 'Andy bot'}</span>
47
47
  </div>
48
48
  <div className={messageClasses}>
49
- <div className='andy-message__content' dangerouslySetInnerHTML={{ __html: messageContent }} />
49
+ {message.isStreaming
50
+ ? <div className='andy-message__content andy-message__stream'>{message.content}</div>
51
+ : <div className='andy-message__content' dangerouslySetInnerHTML={{ __html: messageContent }} />}
50
52
  </div>
51
53
  </div>
52
54
  )
@@ -105,7 +107,7 @@ const MessageList: React.FC<MessageListProps> = ({ messages, isLoading, logoUrl,
105
107
  />
106
108
  ))}
107
109
 
108
- {isLoading && <TypingIndicator />}
110
+ {isLoading && !messages.some(message => message.isStreaming) && <TypingIndicator />}
109
111
 
110
112
  {messages.length === 0 && !isLoading && (
111
113
  <div className='andy-empty-state'>
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from 'react'
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
2
  import {
3
3
  fetchChatbotConfig,
4
4
  generateSessionId,
@@ -31,6 +31,12 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
31
31
  const [showingUserInfoForm, setShowingUserInfoForm] = useState(false)
32
32
  const [welcomePopupShown, setWelcomePopupShown] = useState(false)
33
33
 
34
+ const activeReplyRef = useRef<AbortController | null>(null)
35
+ useEffect(() => () => {
36
+ activeReplyRef.current?.abort()
37
+ activeReplyRef.current = null
38
+ }, [embedId])
39
+
34
40
  const welcomeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
35
41
  const userInfoTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
36
42
 
@@ -127,10 +133,12 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
127
133
 
128
134
  const sendMessage = useCallback(
129
135
  async (messageText: string) => {
130
- if (!messageText.trim() || !config || !sessionId) {
136
+ if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
131
137
  return
132
138
  }
133
139
 
140
+ const controller = new AbortController()
141
+ activeReplyRef.current = controller
134
142
  const userMessage: Message = {
135
143
  id: Date.now(),
136
144
  content: messageText,
@@ -140,21 +148,26 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
140
148
 
141
149
  setMessages(prev => [...prev, userMessage])
142
150
 
151
+ const replyId = Date.now() + 1
152
+ const showReply = (content: string, isStreaming: boolean) => {
153
+ if (controller.signal.aborted) return
154
+ const reply: Message = { id: replyId, content, sender: 'bot', timestamp: new Date().toISOString(), isStreaming }
155
+ setMessages(prev => prev.some(message => message.id === replyId)
156
+ ? prev.map(message => message.id === replyId ? reply : message)
157
+ : [...prev, reply])
158
+ }
143
159
  try {
144
- const response = await sendMessageToAPI(embedId, sessionId, messageText)
145
- if (response.status === 'waiting') return response
146
-
147
- const botMessage: Message = {
148
- id: Date.now() + 1,
149
- content: response.text || 'I apologize, but I encountered an error processing your request.',
150
- sender: 'bot',
151
- timestamp: new Date().toISOString()
160
+ const response = await sendMessageToAPI(embedId, sessionId, messageText, { signal: controller.signal, onText: text => showReply(text, true) })
161
+ if (controller.signal.aborted) return
162
+ if (response.status === 'waiting') {
163
+ setMessages(prev => prev.filter(message => message.id !== replyId))
164
+ return response
152
165
  }
153
-
154
- setMessages(prev => [...prev, botMessage])
166
+ showReply(response.text, false)
155
167
 
156
168
  return response
157
169
  } catch (err) {
170
+ if (controller.signal.aborted) return
158
171
  console.error('Error sending message:', err)
159
172
 
160
173
  const errorMessage: Message = {
@@ -165,8 +178,10 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
165
178
  isError: true
166
179
  }
167
180
 
168
- setMessages(prev => [...prev, errorMessage])
181
+ setMessages(prev => [...prev.filter(message => message.id !== replyId), errorMessage])
169
182
  throw err
183
+ } finally {
184
+ if (activeReplyRef.current === controller) activeReplyRef.current = null
170
185
  }
171
186
  },
172
187
  [embedId, sessionId, config]
@@ -220,6 +235,8 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
220
235
  )
221
236
 
222
237
  const startNewSession = useCallback(() => {
238
+ activeReplyRef.current?.abort()
239
+ activeReplyRef.current = null
223
240
  const newSessionId = generateSessionId()
224
241
  setSessionId(newSessionId)
225
242
 
@@ -66,6 +66,7 @@ export interface Message {
66
66
  isError?: boolean
67
67
  isHuman?: boolean
68
68
  isWaiting?: boolean
69
+ isStreaming?: boolean
69
70
  }
70
71
 
71
72
  // User info for form submission