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.
@@ -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;
@@ -180,13 +181,3 @@ export interface HostClassOptions {
180
181
  theme?: 'light' | 'dark' | 'auto';
181
182
  orientation?: 'left' | 'right';
182
183
  }
183
- export interface KeyboardListenerCallback {
184
- (state: {
185
- isOpen: boolean;
186
- height: number;
187
- }): void;
188
- }
189
- export interface KeyboardDetector {
190
- addListener: (callback: KeyboardListenerCallback) => void;
191
- removeListener: (callback: KeyboardListenerCallback) => void;
192
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "andy-widget-react",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
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.1"
70
+ "andy-widget-core": "2.2.1"
71
71
  }
72
72
  }
package/src/AndyChat.tsx CHANGED
@@ -19,8 +19,6 @@ import {
19
19
  getFooterUrl,
20
20
  getDefaultBubbleLabel,
21
21
  getUiStrings,
22
- getKeyboardDetector,
23
- updateKeyboardHeight,
24
22
  isMobileDevice
25
23
  } from 'andy-widget-core'
26
24
 
@@ -95,6 +93,8 @@ const AndyChat: React.FC<AndyChatProps> = ({
95
93
  const [isOpen, setIsOpen] = useState(initialOpen)
96
94
  const [messages, setMessages] = useState<Message[]>([])
97
95
  const [isLoading, setIsLoading] = useState(false)
96
+ const [replyStarted, setReplyStarted] = useState(false)
97
+ const activeReplyRef = useRef<AbortController | null>(null)
98
98
  const [sessionId, setSessionId] = useState(() => generateSessionId())
99
99
  const handoffRef = useRef<ReturnType<typeof createBrowserHandoff>>(null)
100
100
  const pendingHandoffIdsRef = useRef<string[]>([])
@@ -118,6 +118,9 @@ const AndyChat: React.FC<AndyChatProps> = ({
118
118
  handoffRef.current?.stop()
119
119
  const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
120
120
  if (projection.offline) return
121
+ const paused = projection.control === 'requested' || projection.control === 'human'
122
+ if (paused) activeReplyRef.current?.abort()
123
+ else if (activeReplyRef.current) return
121
124
  setSessionId(projection.identity.sessionId)
122
125
  pendingHandoffIdsRef.current = projection.pendingProjectionIds || []
123
126
  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 }))
@@ -128,8 +131,10 @@ const AndyChat: React.FC<AndyChatProps> = ({
128
131
  : projectedMessages)
129
132
  })
130
133
  handoffRef.current = handoff
134
+ if (handoff) setSessionId(handoff.identity.sessionId)
131
135
  return () => {
132
- handoff?.stop()
136
+ activeReplyRef.current?.abort()
137
+ handoffRef.current?.stop()
133
138
  handoffRef.current = null
134
139
  }
135
140
  }, [config?.bootstrap, config?.id])
@@ -185,24 +190,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
185
190
 
186
191
  setShadowReady(true)
187
192
 
188
- const keyboardDetector = getKeyboardDetector()
189
- const keyboardListener = ({ isOpen: kbOpen, height }: { isOpen: boolean; height: number }) => {
190
- updateKeyboardHeight(containerRef.current, height)
191
-
192
- const chatWindow = shadowRef.current?.querySelector('.andy-chat-window')
193
- if (chatWindow) {
194
- if (kbOpen && height > 0) {
195
- chatWindow.classList.add('andy-chat-window--keyboard-open')
196
- } else {
197
- chatWindow.classList.remove('andy-chat-window--keyboard-open')
198
- }
199
- }
200
- }
201
-
202
- keyboardDetector.addListener(keyboardListener)
203
-
204
193
  return () => {
205
- keyboardDetector.removeListener(keyboardListener)
206
194
  shadowRef.current = null
207
195
  portalRef.current = null
208
196
  setShadowReady(false)
@@ -487,7 +475,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
487
475
  }, [isCollectingUserInfo, messages])
488
476
 
489
477
  const handleNewChat = () => {
490
- setSessionId(generateSessionId())
478
+ activeReplyRef.current?.abort()
479
+ activeReplyRef.current = null
480
+ setIsLoading(false)
481
+ setReplyStarted(false)
482
+ handoffRef.current = handoffRef.current?.restart() || null
483
+ pendingHandoffIdsRef.current = []
484
+ setSessionId(handoffRef.current?.identity.sessionId || generateSessionId())
491
485
 
492
486
  if (config?.welcomeMessage) {
493
487
  const greeting = createGreetingMessage(config)
@@ -513,12 +507,14 @@ const AndyChat: React.FC<AndyChatProps> = ({
513
507
  const sendUserMessage = useCallback(
514
508
  async (rawMessageText: string, options: { hidePredefined?: boolean } = {}) => {
515
509
  const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : ''
516
- if (!messageText) {
510
+ if (!messageText || activeReplyRef.current) {
517
511
  return
518
512
  }
519
513
 
520
514
  const { hidePredefined = true } = options
521
515
  const timestamp = Date.now()
516
+ const controller = new AbortController()
517
+ activeReplyRef.current = controller
522
518
 
523
519
  const userMessage: Message = {
524
520
  id: timestamp,
@@ -535,19 +531,29 @@ const AndyChat: React.FC<AndyChatProps> = ({
535
531
 
536
532
  try {
537
533
  setIsLoading(true)
538
- const response = await sendMessageToAPI(embedId!, sessionId, messageText)
534
+ setReplyStarted(false)
535
+ const showReply = (content: string, isStreaming: boolean) => {
536
+ if (controller.signal.aborted) return
537
+ setReplyStarted(true)
538
+ const reply: Message = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming }
539
+ setMessages(prev => prev.some(message => message.id === reply.id)
540
+ ? prev.map(message => message.id === reply.id ? reply : message)
541
+ : [...prev, reply])
542
+ }
543
+ const response = await sendMessageToAPI(embedId!, sessionId, messageText, {
544
+ signal: controller.signal,
545
+ onText: text => showReply(text, true)
546
+ })
547
+ if (controller.signal.aborted) return
539
548
  if (response.status === 'waiting') {
540
- setMessages(prev => prev.some(message => message.isWaiting) ? prev : [...prev, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }])
549
+ setMessages(prev => {
550
+ const visible = prev.filter(message => message.id !== timestamp + 1)
551
+ return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
552
+ })
541
553
  return
542
554
  }
543
555
 
544
- const botMessage: Message = {
545
- id: timestamp + 1,
546
- content: response.text,
547
- sender: 'bot',
548
- timestamp: new Date().toISOString()
549
- }
550
- setMessages(prev => [...prev, botMessage])
556
+ showReply(response.text, false)
551
557
 
552
558
  if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
553
559
  setPendingMessage(messageText)
@@ -563,6 +569,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
563
569
  setMessages(prev => [...prev, formMessage])
564
570
  }
565
571
  } catch {
572
+ if (controller.signal.aborted) return
566
573
  console.error('AndyChat: Message request failed.')
567
574
  const errorMessage: Message = {
568
575
  id: timestamp + 3,
@@ -571,9 +578,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
571
578
  timestamp: new Date().toISOString(),
572
579
  isError: true
573
580
  }
574
- setMessages(prev => [...prev, errorMessage])
581
+ setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage])
575
582
  } finally {
576
- setIsLoading(false)
583
+ if (activeReplyRef.current === controller) {
584
+ activeReplyRef.current = null
585
+ setIsLoading(false)
586
+ setReplyStarted(false)
587
+ }
577
588
  }
578
589
  },
579
590
  [config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]
@@ -963,7 +974,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
963
974
  </span>
964
975
  </div>
965
976
  <div className='andy-message__content'>
966
- <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />
977
+ {message.isStreaming ? <div className='andy-message__stream'>{message.content}</div> : <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />}
967
978
  </div>
968
979
  </div>
969
980
  )}
@@ -978,7 +989,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
978
989
  )
979
990
  })}
980
991
 
981
- {isLoading && (
992
+ {isLoading && !replyStarted && (
982
993
  <div className='andy-message-container andy-message-container--bot andy-message-container--typing'>
983
994
  <div className='andy-message andy-message--bot andy-message--typing'>
984
995
  <div className='andy-typing-indicator'>
@@ -1026,6 +1037,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1026
1037
  placeholder={config ? strings.inputPlaceholder : '...'}
1027
1038
  aria-label={strings.messageInput}
1028
1039
  onKeyDown={e => {
1040
+ if (e.key === 'Enter' && activeReplyRef.current) { e.preventDefault(); return }
1029
1041
  if (e.key === 'Enter' && (e.target as HTMLInputElement).value.trim()) {
1030
1042
  handleSendMessage((e.target as HTMLInputElement).value)
1031
1043
  ;(e.target as HTMLInputElement).value = ''
@@ -1039,6 +1051,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1039
1051
  id='andy-send-button'
1040
1052
  aria-label={strings.sendMessage}
1041
1053
  onClick={() => {
1054
+ if (activeReplyRef.current) return
1042
1055
  const input = shadowRef.current?.getElementById(
1043
1056
  'andy-message-input'
1044
1057
  ) as HTMLInputElement | null
@@ -1047,7 +1060,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1047
1060
  input.value = ''
1048
1061
  }
1049
1062
  }}
1050
- disabled={isCollectingUserInfo || !config}
1063
+ disabled={isCollectingUserInfo || isLoading || !config}
1051
1064
  >
1052
1065
  <svg
1053
1066
  className='andy-input__button-icon'
@@ -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
@@ -221,14 +222,3 @@ export interface HostClassOptions {
221
222
  theme?: 'light' | 'dark' | 'auto'
222
223
  orientation?: 'left' | 'right'
223
224
  }
224
-
225
- // Keyboard detector listener callback
226
- export interface KeyboardListenerCallback {
227
- (state: { isOpen: boolean; height: number }): void
228
- }
229
-
230
- // Keyboard detector interface
231
- export interface KeyboardDetector {
232
- addListener: (callback: KeyboardListenerCallback) => void
233
- removeListener: (callback: KeyboardListenerCallback) => void
234
- }