@xcelsior/ui-chat 2.0.2 → 2.0.4

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.
Files changed (31) hide show
  1. package/.storybook/preview.tsx +2 -1
  2. package/dist/index.d.mts +3 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +49 -53
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +77 -81
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/components/Chat.tsx +35 -74
  10. package/src/components/ChatWidget.tsx +0 -1
  11. package/src/hooks/useMessages.ts +22 -1
  12. package/src/hooks/useWebSocket.ts +18 -1
  13. package/storybook-static/assets/Chat.stories-BkbpOOSG.js +830 -0
  14. package/storybook-static/assets/{Color-YHDXOIA2-BMnd3YrF.js → Color-YHDXOIA2-CSuNIR0a.js} +1 -1
  15. package/storybook-static/assets/{DocsRenderer-CFRXHY34-i_W8iCu9.js → DocsRenderer-CFRXHY34-dpuOKTQp.js} +3 -3
  16. package/storybook-static/assets/{MessageItem-DAaKZ9s9.js → MessageItem-Dlb6dSKL.js} +9 -9
  17. package/storybook-static/assets/MessageItem.stories-CsxqSqu-.js +422 -0
  18. package/storybook-static/assets/{entry-preview-oDnntGcx.js → entry-preview-C_-WO6GJ.js} +1 -1
  19. package/storybook-static/assets/{iframe-CGBtu2Se.js → iframe-BXTccXxS.js} +2 -2
  20. package/storybook-static/assets/preview-B8y-wc-n.css +1 -0
  21. package/storybook-static/assets/preview-CC4t7T7W.js +1 -0
  22. package/storybook-static/assets/{preview-BRpahs9B.js → preview-Cyx3pE7Q.js} +2 -2
  23. package/storybook-static/iframe.html +1 -1
  24. package/storybook-static/index.json +1 -1
  25. package/storybook-static/project.json +1 -1
  26. package/tsconfig.json +4 -0
  27. package/storybook-static/assets/Chat.stories-J_Yp51wU.js +0 -803
  28. package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +0 -255
  29. package/storybook-static/assets/ToastContext-Bty1K7ya.js +0 -1
  30. package/storybook-static/assets/preview-DUOvJmsz.js +0 -1
  31. package/storybook-static/assets/preview-DcGwT3kv.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcelsior/ui-chat",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "license": "MIT",
@@ -21,7 +21,8 @@
21
21
  "storybook": "^8.6.14",
22
22
  "@tailwindcss/postcss": "^4.1.13",
23
23
  "tsup": "^8.0.2",
24
- "typescript": "^5.3.3"
24
+ "typescript": "^5.3.3",
25
+ "@xcelsior/design-system": "1.0.10"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "tailwindcss": "^4",
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { ChatWidget } from './ChatWidget';
3
3
  import { PreChatForm } from './PreChatForm';
4
4
  import { ChatBubbleIcon } from './BrandIcons';
@@ -45,73 +45,44 @@ export function Chat({
45
45
  const [userInfo, setUserInfo] = useState<IUser | null>(null);
46
46
  const [conversationId, setConversationId] = useState<string>('');
47
47
  const [isLoading, setIsLoading] = useState(true);
48
- const [isAnimating, setIsAnimating] = useState(false);
49
- const [showWidget, setShowWidget] = useState(false);
50
48
 
51
49
  const identityMode = config.identityCollection || 'progressive';
52
50
  const { position, isDragging, showHint, handlers } = useDraggablePosition(config.position);
51
+ const sessionInitializedRef = useRef(false);
53
52
 
54
- const { currentState, setState: setStateRaw } = useChatWidgetState({
53
+ const { currentState, setState } = useChatWidgetState({
55
54
  state,
56
55
  defaultState,
57
56
  onStateChange,
58
57
  });
59
58
 
60
- // Wrap setState with animation handling
61
- const setState = useCallback(
62
- (newState: ChatWidgetState) => {
63
- if (newState === 'open' && currentState === 'minimized') {
64
- // Opening: show widget immediately, trigger animation
65
- setShowWidget(true);
66
- setIsAnimating(true);
67
- setStateRaw(newState);
68
- // Let the animation class apply on next frame
69
- requestAnimationFrame(() => {
70
- requestAnimationFrame(() => {
71
- setIsAnimating(false);
72
- });
73
- });
74
- } else if (
75
- (newState === 'minimized' || newState === 'closed') &&
76
- currentState === 'open'
77
- ) {
78
- // Closing: animate out, then change state
79
- setIsAnimating(true);
80
- setTimeout(() => {
81
- setShowWidget(false);
82
- setIsAnimating(false);
83
- setStateRaw(newState);
84
- }, 200);
85
- } else {
86
- setStateRaw(newState);
87
- }
88
- },
89
- [currentState, setStateRaw]
90
- );
91
-
92
- // Sync showWidget with state
93
- useEffect(() => {
94
- if (currentState === 'open') {
95
- setShowWidget(true);
96
- }
97
- }, [currentState]);
59
+ // Extract primitives from config for stable dependency array
60
+ const configConversationId = config.conversationId;
61
+ const configUserEmail = config.currentUser?.email;
62
+ const configUserName = config.currentUser?.name;
63
+ const configUserAvatar = config.currentUser?.avatar;
64
+ const configUserStatus = config.currentUser?.status;
98
65
 
99
66
  // Initialize session
100
67
  useEffect(() => {
68
+ // Guard: only run once unless identity actually changes
69
+ if (sessionInitializedRef.current) return;
70
+
101
71
  const initializeSession = () => {
102
72
  try {
103
- if (config.currentUser?.email && config.currentUser?.name) {
104
- const convId = config.conversationId || generateSessionId();
73
+ if (configUserEmail && configUserName) {
74
+ const convId = configConversationId || generateSessionId();
105
75
  const user: IUser = {
106
- name: config.currentUser.name,
107
- email: config.currentUser.email,
108
- avatar: config.currentUser.avatar,
76
+ name: configUserName,
77
+ email: configUserEmail,
78
+ avatar: configUserAvatar,
109
79
  type: 'customer',
110
- status: config.currentUser.status,
80
+ status: configUserStatus,
111
81
  };
112
82
  setUserInfo(user);
113
83
  setConversationId(convId);
114
84
  setIsLoading(false);
85
+ sessionInitializedRef.current = true;
115
86
  return;
116
87
  }
117
88
 
@@ -130,26 +101,29 @@ export function Chat({
130
101
  setUserInfo(user);
131
102
  setConversationId(storedData.conversationId);
132
103
  setIsLoading(false);
104
+ sessionInitializedRef.current = true;
133
105
  return;
134
106
  }
135
107
  }
136
108
 
137
- const convId = config.conversationId || generateSessionId();
109
+ const convId = configConversationId || generateSessionId();
138
110
  setConversationId(convId);
139
111
 
140
112
  if (identityMode === 'progressive' || identityMode === 'none') {
141
113
  setUserInfo(null);
142
114
  }
115
+ sessionInitializedRef.current = true;
143
116
  } catch (error) {
144
117
  console.error('Error initializing chat session:', error);
145
- setConversationId(config.conversationId || generateSessionId());
118
+ setConversationId(configConversationId || generateSessionId());
119
+ sessionInitializedRef.current = true;
146
120
  } finally {
147
121
  setIsLoading(false);
148
122
  }
149
123
  };
150
124
 
151
125
  initializeSession();
152
- }, [config, storageKeyPrefix, identityMode]);
126
+ }, [configConversationId, configUserEmail, configUserName, configUserAvatar, configUserStatus, storageKeyPrefix, identityMode]);
153
127
 
154
128
  const handlePreChatSubmit = useCallback(
155
129
  (name: string, email: string) => {
@@ -245,29 +219,16 @@ export function Chat({
245
219
  currentUser: userInfo || undefined,
246
220
  };
247
221
 
248
- // Animation styles for open/close
249
- const widgetAnimationStyle: React.CSSProperties =
250
- showWidget && !isAnimating
251
- ? {
252
- opacity: 1,
253
- transform: 'translateY(0) scale(1)',
254
- transition: 'opacity 0.25s ease-out, transform 0.25s ease-out',
255
- }
256
- : {
257
- opacity: 0,
258
- transform: 'translateY(12px) scale(0.97)',
259
- transition: 'opacity 0.2s ease-in, transform 0.2s ease-in',
260
- };
261
-
222
+ // Render ChatWidget directly — no wrapper div.
223
+ // A wrapper with CSS `transform` (even identity) creates a new containing block,
224
+ // which breaks `position: fixed` on the ChatWidget.
262
225
  return (
263
- <div style={widgetAnimationStyle}>
264
- <ChatWidget
265
- config={fullConfig}
266
- className={className}
267
- onClose={() => setState('closed')}
268
- onMinimize={() => setState('minimized')}
269
- resolvedPosition={position}
270
- />
271
- </div>
226
+ <ChatWidget
227
+ config={fullConfig}
228
+ className={className}
229
+ onClose={() => setState('closed')}
230
+ onMinimize={() => setState('minimized')}
231
+ resolvedPosition={position}
232
+ />
272
233
  );
273
234
  }
@@ -147,7 +147,6 @@ export function ChatWidget({
147
147
  const containerStyle: React.CSSProperties = isFullPage
148
148
  ? { backgroundColor: bgColor, color: textColor }
149
149
  : {
150
- position: 'relative',
151
150
  width,
152
151
  height,
153
152
  maxHeight: 'calc(100vh - 100px)',
@@ -1,8 +1,11 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { IMessage, IChatConfig, UseMessagesReturn } from '../types';
3
3
  import type { UseWebSocketReturn } from './useWebSocket';
4
4
  import { fetchMessages } from '../utils/api';
5
5
 
6
+ /** Max time (ms) to show the bot thinking indicator before auto-clearing */
7
+ const BOT_THINKING_TIMEOUT = 45_000;
8
+
6
9
  export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig): UseMessagesReturn {
7
10
  const [messages, setMessages] = useState<IMessage[]>([]);
8
11
  const [isLoading, setIsLoading] = useState(false);
@@ -11,6 +14,7 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
11
14
  const [hasMore, setHasMore] = useState(true);
12
15
  const [isLoadingMore, setIsLoadingMore] = useState(false);
13
16
  const [isBotThinking, setIsBotThinking] = useState(false);
17
+ const botThinkingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14
18
 
15
19
  // Extract stable references from config
16
20
  const { httpApiUrl, conversationId, headers, onError, toast } = config;
@@ -81,6 +85,10 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
81
85
  // Clear bot thinking state when bot or system message arrives
82
86
  if (newMessage.senderType === 'bot' || newMessage.senderType === 'system') {
83
87
  setIsBotThinking(false);
88
+ if (botThinkingTimerRef.current) {
89
+ clearTimeout(botThinkingTimerRef.current);
90
+ botThinkingTimerRef.current = null;
91
+ }
84
92
  }
85
93
 
86
94
  // Notify parent component about new message
@@ -99,6 +107,12 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
99
107
  // Show bot thinking indicator immediately when customer sends a message
100
108
  if (message.senderType === 'customer') {
101
109
  setIsBotThinking(true);
110
+
111
+ // Safety timeout — clear thinking state if no bot response arrives
112
+ if (botThinkingTimerRef.current) clearTimeout(botThinkingTimerRef.current);
113
+ botThinkingTimerRef.current = setTimeout(() => {
114
+ setIsBotThinking(false);
115
+ }, BOT_THINKING_TIMEOUT);
102
116
  }
103
117
  }, []);
104
118
 
@@ -149,6 +163,13 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
149
163
  onError,
150
164
  ]);
151
165
 
166
+ // Clean up timer on unmount
167
+ useEffect(() => {
168
+ return () => {
169
+ if (botThinkingTimerRef.current) clearTimeout(botThinkingTimerRef.current);
170
+ };
171
+ }, []);
172
+
152
173
  return {
153
174
  messages,
154
175
  addMessage,
@@ -12,6 +12,9 @@ export interface UseWebSocketReturn {
12
12
  /**
13
13
  * Hook for WebSocket connection in chat widget.
14
14
  * Can use an external WebSocket connection (for agents) via the externalWebSocket prop.
15
+ *
16
+ * Handles React Strict Mode (dev) gracefully — tracks whether the effect has been
17
+ * cleaned up so that an aborted connection doesn't trigger reconnection.
15
18
  */
16
19
  export function useWebSocket(
17
20
  config: IChatConfig,
@@ -24,6 +27,7 @@ export function useWebSocket(
24
27
  const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
25
28
  const reconnectAttemptsRef = useRef(0);
26
29
  const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(null);
30
+ const abortedRef = useRef(false);
27
31
  const maxReconnectAttempts = 5;
28
32
  const reconnectDelay = 3000;
29
33
 
@@ -71,6 +75,9 @@ export function useWebSocket(
71
75
 
72
76
  // biome-ignore lint/correctness/useExhaustiveDependencies: dependencies managed manually
73
77
  const connect = useCallback(() => {
78
+ // Skip if the effect was already cleaned up (React Strict Mode)
79
+ if (abortedRef.current) return;
80
+
74
81
  console.log('connecting to WebSocket...', config.currentUser, config.conversationId);
75
82
  try {
76
83
  // Clean up existing connection
@@ -96,6 +103,10 @@ export function useWebSocket(
96
103
  const ws = new WebSocket(url.toString());
97
104
 
98
105
  ws.onopen = () => {
106
+ if (abortedRef.current) {
107
+ ws.close(1000, 'Effect cleaned up');
108
+ return;
109
+ }
99
110
  console.log('WebSocket connected');
100
111
  setIsConnected(true);
101
112
  setError(null);
@@ -104,6 +115,7 @@ export function useWebSocket(
104
115
  };
105
116
 
106
117
  ws.onerror = (event) => {
118
+ if (abortedRef.current) return;
107
119
  console.error('WebSocket error:', event);
108
120
  const err = new Error('WebSocket connection error');
109
121
  setError(err);
@@ -111,6 +123,7 @@ export function useWebSocket(
111
123
  };
112
124
 
113
125
  ws.onclose = (event) => {
126
+ if (abortedRef.current) return;
114
127
  console.log('WebSocket closed:', event.code, event.reason);
115
128
  setIsConnected(false);
116
129
  config.onConnectionChange?.(false);
@@ -165,6 +178,7 @@ export function useWebSocket(
165
178
 
166
179
  const reconnect = useCallback(() => {
167
180
  reconnectAttemptsRef.current = 0;
181
+ abortedRef.current = false;
168
182
  connect();
169
183
  }, [connect]);
170
184
 
@@ -176,10 +190,13 @@ export function useWebSocket(
176
190
  return cleanup;
177
191
  }
178
192
 
193
+ // Reset abort flag for this effect cycle
194
+ abortedRef.current = false;
179
195
  connect();
180
196
 
181
- // Cleanup on unmount
197
+ // Cleanup on unmount (or React Strict Mode re-run)
182
198
  return () => {
199
+ abortedRef.current = true;
183
200
  if (reconnectTimeoutRef.current) {
184
201
  clearTimeout(reconnectTimeoutRef.current);
185
202
  }