@xcelsior/ui-chat 1.0.8 → 2.0.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.
Files changed (96) hide show
  1. package/dist/index.d.mts +69 -69
  2. package/dist/index.d.ts +69 -69
  3. package/dist/index.js +2458 -627
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2457 -628
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +6 -5
  8. package/src/components/BrandIcons.stories.tsx +95 -0
  9. package/src/components/BrandIcons.tsx +84 -0
  10. package/src/components/Chat.stories.tsx +149 -16
  11. package/src/components/Chat.tsx +116 -96
  12. package/src/components/ChatHeader.tsx +124 -69
  13. package/src/components/ChatInput.tsx +253 -104
  14. package/src/components/ChatWidget.tsx +209 -63
  15. package/src/components/ConversationRating.stories.tsx +33 -0
  16. package/src/components/ConversationRating.tsx +156 -0
  17. package/src/components/MarkdownMessage.tsx +202 -0
  18. package/src/components/MessageItem.stories.tsx +253 -55
  19. package/src/components/MessageItem.tsx +223 -60
  20. package/src/components/MessageList.tsx +164 -35
  21. package/src/components/PreChatForm.tsx +236 -96
  22. package/src/components/ThinkingIndicator.tsx +370 -0
  23. package/src/components/TypingIndicator.tsx +27 -11
  24. package/src/hooks/useDraggablePosition.ts +91 -0
  25. package/src/hooks/useMessages.ts +12 -13
  26. package/src/hooks/useResizableWidget.ts +324 -0
  27. package/src/index.tsx +5 -0
  28. package/src/types.ts +51 -5
  29. package/src/utils/markdown-styles.ts +140 -0
  30. package/storybook-static/assets/BrandIcons-Cjy5INAp.js +4 -0
  31. package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +64 -0
  32. package/storybook-static/assets/Chat.stories-J_Yp51wU.js +803 -0
  33. package/storybook-static/assets/Color-YHDXOIA2-BMnd3YrF.js +1 -0
  34. package/storybook-static/assets/ConversationRating.stories-B5_QddHN.js +12 -0
  35. package/storybook-static/assets/DocsRenderer-CFRXHY34-i_W8iCu9.js +575 -0
  36. package/storybook-static/assets/MessageItem-DAaKZ9s9.js +14 -0
  37. package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +255 -0
  38. package/storybook-static/assets/ToastContext-Bty1K7ya.js +1 -0
  39. package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
  40. package/storybook-static/assets/en-US-BukEqXxE.js +1 -0
  41. package/storybook-static/assets/entry-preview-docs-DHohToDm.js +46 -0
  42. package/storybook-static/assets/entry-preview-oDnntGcx.js +2 -0
  43. package/storybook-static/assets/iframe-CGBtu2Se.js +211 -0
  44. package/storybook-static/assets/index--qcDGAq6.js +1 -0
  45. package/storybook-static/assets/index-BLHw34Di.js +24 -0
  46. package/storybook-static/assets/index-B_4m48Mv.js +1 -0
  47. package/storybook-static/assets/index-DgH-xKnr.js +11 -0
  48. package/storybook-static/assets/index-DrFu-skq.js +6 -0
  49. package/storybook-static/assets/index-DrdPSA1J.js +240 -0
  50. package/storybook-static/assets/index-jvNEZhzf.js +1 -0
  51. package/storybook-static/assets/index-yBjzXJbu.js +9 -0
  52. package/storybook-static/assets/jsx-runtime-Cf8x2fCZ.js +9 -0
  53. package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
  54. package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
  55. package/storybook-static/assets/preview-BRpahs9B.js +2 -0
  56. package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
  57. package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
  58. package/storybook-static/assets/preview-DD_OYowb.js +1 -0
  59. package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
  60. package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
  61. package/storybook-static/assets/preview-DUOvJmsz.js +1 -0
  62. package/storybook-static/assets/preview-DcGwT3kv.css +1 -0
  63. package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
  64. package/storybook-static/assets/react-18-CALspjOX.js +1 -0
  65. package/storybook-static/assets/test-utils-BE0XkMtV.js +9 -0
  66. package/storybook-static/favicon.svg +1 -0
  67. package/storybook-static/iframe.html +666 -0
  68. package/storybook-static/index.html +177 -0
  69. package/storybook-static/index.json +1 -0
  70. package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  71. package/storybook-static/nunito-sans-bold.woff2 +0 -0
  72. package/storybook-static/nunito-sans-italic.woff2 +0 -0
  73. package/storybook-static/nunito-sans-regular.woff2 +0 -0
  74. package/storybook-static/project.json +1 -0
  75. package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
  76. package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
  77. package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
  78. package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
  79. package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
  80. package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
  81. package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
  82. package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
  83. package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
  84. package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
  85. package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
  86. package/storybook-static/sb-common-assets/favicon.svg +1 -0
  87. package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  88. package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  89. package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  90. package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  91. package/storybook-static/sb-manager/globals-module-info.js +1052 -0
  92. package/storybook-static/sb-manager/globals-runtime.js +42127 -0
  93. package/storybook-static/sb-manager/globals.js +48 -0
  94. package/storybook-static/sb-manager/runtime.js +12048 -0
  95. package/.turbo/turbo-build.log +0 -22
  96. package/.turbo/turbo-lint.log +0 -5
@@ -1,12 +1,14 @@
1
1
  import { formatDistanceToNow } from 'date-fns';
2
- import ReactMarkdown from 'react-markdown';
3
- import type { IMessage, IUser } from '../types';
2
+ import type { IMessage, IUser, IChatTheme } from '../types';
3
+ import { XcelsiorAvatar } from './BrandIcons';
4
+ import { MarkdownMessage } from './MarkdownMessage';
4
5
 
5
6
  interface MessageItemProps {
6
7
  message: IMessage;
7
8
  currentUser: IUser;
8
9
  showAvatar?: boolean;
9
10
  showTimestamp?: boolean;
11
+ theme?: IChatTheme;
10
12
  }
11
13
 
12
14
  export function MessageItem({
@@ -14,81 +16,171 @@ export function MessageItem({
14
16
  currentUser,
15
17
  showAvatar = true,
16
18
  showTimestamp = true,
19
+ theme,
17
20
  }: MessageItemProps) {
18
21
  const isOwnMessage = message.senderType === currentUser.type;
19
22
  const isSystemMessage = message.senderType === 'system';
20
23
  const isAIMessage = message.metadata?.isAI === true;
24
+ const isBotMessage = message.senderType === 'bot';
21
25
 
22
- // System messages are centered and styled differently
26
+ const bgColor = theme?.background || '#00001a';
27
+ const isLightTheme = (() => {
28
+ if (!bgColor.startsWith('#')) return false;
29
+ const hex = bgColor.replace('#', '');
30
+ const r = parseInt(hex.substring(0, 2), 16);
31
+ const g = parseInt(hex.substring(2, 4), 16);
32
+ const b = parseInt(hex.substring(4, 6), 16);
33
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
34
+ })();
35
+
36
+ const primaryColor = theme?.primary || '#337eff';
37
+ const primaryStrong = theme?.primaryStrong || '#005eff';
38
+ const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
39
+ const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.35)' : 'rgba(247,247,248,0.35)');
40
+
41
+ // System messages — centered pill with ultra-subtle surface
23
42
  if (isSystemMessage) {
24
43
  return (
25
- <div className="flex justify-center my-4">
26
- <div className="px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-full">
27
- <p className="text-xs text-gray-600 dark:text-gray-400">{message.content}</p>
44
+ <div className="flex justify-center my-3">
45
+ <div
46
+ className="px-4 py-1.5 rounded-full"
47
+ style={{
48
+ backgroundColor: isLightTheme ? 'rgba(0,0,0,0.04)' : 'rgba(255,255,255,0.03)',
49
+ boxShadow: isLightTheme
50
+ ? 'inset 0 0 0 1px rgba(0,0,0,0.06)'
51
+ : 'inset 0 0 0 0.5px rgba(255,255,255,0.06)',
52
+ }}
53
+ >
54
+ <p
55
+ style={{
56
+ fontSize: '11px',
57
+ letterSpacing: '0.019em',
58
+ color: textMuted,
59
+ }}
60
+ >
61
+ {message.content}
62
+ </p>
28
63
  </div>
29
64
  </div>
30
65
  );
31
66
  }
32
67
 
33
- // Determine the avatar icon to display
34
- const getAvatarIcon = () => {
35
- if (isAIMessage) {
36
- return '🤖'; // Robot icon for AI messages
37
- }
68
+ // Determine label for non-customer messages
69
+ const getSenderLabel = () => {
70
+ if (isBotMessage || isAIMessage) return 'AI Assistant';
38
71
  if (message.senderType === 'agent') {
39
- return '🎧'; // Headset icon for human agents
72
+ return (message.metadata?.agentName as string) || 'Support Agent';
40
73
  }
41
- return '👤'; // User icon for customers
74
+ return null;
75
+ };
76
+
77
+ const senderLabel = !isOwnMessage ? getSenderLabel() : null;
78
+
79
+ // Own message: blue gradient pill (matches website primary CTA)
80
+ const ownBubbleStyle: React.CSSProperties = {
81
+ background: `linear-gradient(135deg, ${primaryColor}, ${primaryStrong})`,
82
+ color: '#ffffff',
83
+ borderRadius: '18px 18px 4px 18px',
84
+ boxShadow: `0 2px 12px -3px ${primaryColor}40`,
42
85
  };
43
86
 
87
+ // Other message: surface with subtle inset border (adapts to theme)
88
+ const otherBubbleStyle: React.CSSProperties = isLightTheme
89
+ ? {
90
+ backgroundColor: 'rgba(0,0,0,0.04)',
91
+ color: textColor,
92
+ borderRadius: '18px 18px 18px 4px',
93
+ boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.06)',
94
+ }
95
+ : {
96
+ backgroundColor: 'rgba(255,255,255,0.04)',
97
+ color: textColor,
98
+ borderRadius: '18px 18px 18px 4px',
99
+ boxShadow:
100
+ 'inset 0 0 0 0.5px rgba(255,255,255,0.06), inset 0 1px 0 0 rgba(255,255,255,0.08)',
101
+ };
102
+
44
103
  return (
45
- <div className={`flex gap-2 mb-4 ${!isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
46
- {showAvatar && (
47
- <div className="flex-shrink-0">
48
- <div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-medium">
49
- {getAvatarIcon()}
50
- </div>
104
+ <div
105
+ className={`flex gap-2.5 mb-3 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
106
+ >
107
+ {/* Avatar */}
108
+ {showAvatar && !isOwnMessage && (
109
+ <div className="flex-shrink-0 mt-auto mb-5">
110
+ {isBotMessage || isAIMessage ? (
111
+ <XcelsiorAvatar size={28} />
112
+ ) : (
113
+ <div
114
+ className="h-7 w-7 rounded-full flex items-center justify-center"
115
+ style={{
116
+ background: isLightTheme
117
+ ? `linear-gradient(135deg, ${primaryColor}30, rgba(0,0,0,0.04))`
118
+ : `linear-gradient(135deg, ${primaryColor}60, rgba(255,255,255,0.06))`,
119
+ boxShadow: isLightTheme
120
+ ? 'inset 0 0 0 1px rgba(0,0,0,0.08)'
121
+ : 'inset 0 0 0 0.5px rgba(255,255,255,0.1)',
122
+ }}
123
+ >
124
+ <svg
125
+ width="14"
126
+ height="14"
127
+ viewBox="0 0 24 24"
128
+ fill="none"
129
+ stroke={isLightTheme ? primaryColor : 'white'}
130
+ strokeWidth="2"
131
+ strokeLinecap="round"
132
+ strokeLinejoin="round"
133
+ aria-hidden="true"
134
+ >
135
+ <title>Agent</title>
136
+ <path d="M3 18v-6a9 9 0 0 1 18 0v6" />
137
+ <path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" />
138
+ </svg>
139
+ </div>
140
+ )}
51
141
  </div>
52
142
  )}
53
143
 
144
+ {/* Spacer for own messages to maintain alignment */}
145
+ {showAvatar && isOwnMessage && <div className="w-7 flex-shrink-0" />}
146
+
54
147
  <div
55
- className={`flex flex-col max-w-[70%] ${!isOwnMessage ? 'items-end' : 'items-start'}`}
148
+ className={`flex flex-col max-w-[75%] ${isOwnMessage ? 'items-end' : 'items-start'}`}
56
149
  >
150
+ {/* Sender label */}
151
+ {senderLabel && (
152
+ <span
153
+ className="mb-1 px-1 font-medium"
154
+ style={{
155
+ color: isLightTheme ? 'rgba(0,0,0,0.45)' : 'rgba(247,247,248,0.4)',
156
+ fontSize: '11px',
157
+ letterSpacing: '0.019em',
158
+ }}
159
+ >
160
+ {senderLabel}
161
+ </span>
162
+ )}
163
+
164
+ {/* Message bubble */}
57
165
  <div
58
- className={`rounded-2xl px-4 py-2 ${
59
- isOwnMessage
60
- ? 'bg-blue-600 text-white'
61
- : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
62
- }`}
166
+ className="px-4 py-2.5"
167
+ style={{
168
+ ...( isOwnMessage ? ownBubbleStyle : otherBubbleStyle ),
169
+ fontSize: '14px',
170
+ lineHeight: '1.5',
171
+ letterSpacing: '0.006em',
172
+ }}
63
173
  >
64
174
  {message.messageType === 'text' && (
65
- <ReactMarkdown
66
- components={{
67
- p: ({ children }) => <p className="mb-0">{children}</p>,
68
- img: ({ src, alt, ...props }) => (
69
- <img
70
- {...props}
71
- src={src}
72
- alt={alt}
73
- className="max-w-full h-auto rounded-lg shadow-sm my-2"
74
- loading="lazy"
75
- />
76
- ),
77
- a: ({ href, children, ...props }) => (
78
- <a
79
- {...props}
80
- href={href}
81
- target="_blank"
82
- rel="noopener noreferrer"
83
- className={`${isOwnMessage ? 'text-blue-200 hover:text-blue-100' : 'text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300'} underline`}
84
- >
85
- {children}
86
- </a>
87
- ),
88
- }}
89
- >
90
- {message.content}
91
- </ReactMarkdown>
175
+ isOwnMessage ? (
176
+ // User messages: plain text — no markdown parsing
177
+ <span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
178
+ {message.content}
179
+ </span>
180
+ ) : (
181
+ // Bot / agent messages: full markdown rendering
182
+ <MarkdownMessage content={message.content} theme={theme} />
183
+ )
92
184
  )}
93
185
  {message.messageType === 'image' && (
94
186
  <div>
@@ -102,12 +194,31 @@ export function MessageItem({
102
194
  )}
103
195
  {message.messageType === 'file' && (
104
196
  <div className="flex items-center gap-2">
105
- <span className="text-2xl">📎</span>
197
+ <svg
198
+ width="18"
199
+ height="18"
200
+ viewBox="0 0 24 24"
201
+ fill="none"
202
+ stroke="currentColor"
203
+ strokeWidth="1.75"
204
+ strokeLinecap="round"
205
+ strokeLinejoin="round"
206
+ aria-hidden="true"
207
+ >
208
+ <title>File</title>
209
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
210
+ </svg>
106
211
  <a
107
212
  href={message.content}
108
213
  target="_blank"
109
214
  rel="noopener noreferrer"
110
- className={`${isOwnMessage ? 'text-blue-200 hover:text-blue-100' : 'text-blue-600 hover:text-blue-700 dark:text-blue-400'} underline`}
215
+ style={{
216
+ color: isOwnMessage
217
+ ? 'rgba(255,255,255,0.85)'
218
+ : primaryColor,
219
+ textDecoration: 'underline',
220
+ textUnderlineOffset: '2px',
221
+ }}
111
222
  >
112
223
  {(message.metadata?.fileName as any) || 'Download file'}
113
224
  </a>
@@ -115,21 +226,73 @@ export function MessageItem({
115
226
  )}
116
227
  </div>
117
228
 
229
+ {/* Timestamp + status */}
118
230
  {showTimestamp && (
119
231
  <div
120
- className={`flex items-center gap-2 mt-1 px-2 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
232
+ className={`flex items-center gap-1.5 mt-1 px-1 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
121
233
  >
122
- <span className="text-xs text-gray-500 dark:text-gray-400">
234
+ <span
235
+ style={{
236
+ fontSize: '11px',
237
+ letterSpacing: '0.019em',
238
+ color: textMuted,
239
+ }}
240
+ >
123
241
  {formatDistanceToNow(new Date(message.createdAt), {
124
242
  addSuffix: true,
125
243
  })}
126
244
  </span>
127
245
  {isOwnMessage && message.status && (
128
- <span className="text-xs">
129
- {message.status === 'sent' && '✓'}
130
- {message.status === 'delivered' && '✓✓'}
246
+ <span style={{ color: textMuted }}>
247
+ {message.status === 'sent' && (
248
+ <svg
249
+ width="14"
250
+ height="14"
251
+ viewBox="0 0 24 24"
252
+ fill="none"
253
+ stroke="currentColor"
254
+ strokeWidth="2.5"
255
+ strokeLinecap="round"
256
+ strokeLinejoin="round"
257
+ aria-hidden="true"
258
+ >
259
+ <title>Sent</title>
260
+ <polyline points="20 6 9 17 4 12" />
261
+ </svg>
262
+ )}
263
+ {message.status === 'delivered' && (
264
+ <svg
265
+ width="14"
266
+ height="14"
267
+ viewBox="0 0 24 24"
268
+ fill="none"
269
+ stroke="currentColor"
270
+ strokeWidth="2.5"
271
+ strokeLinecap="round"
272
+ strokeLinejoin="round"
273
+ aria-hidden="true"
274
+ >
275
+ <title>Delivered</title>
276
+ <polyline points="18 6 7 17 2 12" />
277
+ <polyline points="22 6 11 17" />
278
+ </svg>
279
+ )}
131
280
  {message.status === 'read' && (
132
- <span className="text-blue-600">✓✓</span>
281
+ <svg
282
+ width="14"
283
+ height="14"
284
+ viewBox="0 0 24 24"
285
+ fill="none"
286
+ stroke={primaryColor}
287
+ strokeWidth="2.5"
288
+ strokeLinecap="round"
289
+ strokeLinejoin="round"
290
+ aria-hidden="true"
291
+ >
292
+ <title>Read</title>
293
+ <polyline points="18 6 7 17 2 12" />
294
+ <polyline points="22 6 11 17" />
295
+ </svg>
133
296
  )}
134
297
  </span>
135
298
  )}
@@ -1,7 +1,12 @@
1
- import { useEffect, useRef, useCallback } from 'react';
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
2
3
  import { Spinner } from '@xcelsior/design-system';
4
+
5
+ import { XcelsiorAvatar, XcelsiorSymbol } from './BrandIcons';
3
6
  import { MessageItem } from './MessageItem';
4
- import type { IMessage, IUser } from '../types';
7
+ import { ThinkingIndicator } from './ThinkingIndicator';
8
+
9
+ import type { IMessage, IUser, IChatTheme } from '../types';
5
10
 
6
11
  interface MessageListProps {
7
12
  messages: IMessage[];
@@ -13,6 +18,11 @@ interface MessageListProps {
13
18
  onLoadMore?: () => void;
14
19
  hasMore?: boolean;
15
20
  isLoadingMore?: boolean;
21
+ theme?: IChatTheme;
22
+ /** Called when a quick-start action button is clicked — sends the text as a message */
23
+ onQuickAction?: (text: string) => void;
24
+ /** True when user sent a message and bot response hasn't arrived yet */
25
+ isBotThinking?: boolean;
16
26
  }
17
27
 
18
28
  export function MessageList({
@@ -25,6 +35,9 @@ export function MessageList({
25
35
  onLoadMore,
26
36
  hasMore = false,
27
37
  isLoadingMore = false,
38
+ theme,
39
+ onQuickAction,
40
+ isBotThinking = false,
28
41
  }: MessageListProps) {
29
42
  const messagesEndRef = useRef<HTMLDivElement>(null);
30
43
  const containerRef = useRef<HTMLDivElement>(null);
@@ -34,11 +47,23 @@ export function MessageList({
34
47
  const hasInitialScrolledRef = useRef(false);
35
48
  const isUserScrollingRef = useRef(false);
36
49
 
50
+ const bgColor = theme?.background || '#00001a';
51
+ const isLightTheme = (() => {
52
+ if (!bgColor.startsWith('#')) return false;
53
+ const hex = bgColor.replace('#', '');
54
+ const r = parseInt(hex.substring(0, 2), 16);
55
+ const g = parseInt(hex.substring(2, 4), 16);
56
+ const b = parseInt(hex.substring(4, 6), 16);
57
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
58
+ })();
59
+
60
+ const primaryColor = theme?.primary || '#337eff';
61
+ const textColor = theme?.text || (isLightTheme ? '#1a1a2e' : '#f7f7f8');
62
+ const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.4)' : 'rgba(247,247,248,0.45)');
63
+
37
64
  // Auto-scroll to bottom when new messages arrive
38
65
  useEffect(() => {
39
66
  if (autoScroll && messagesEndRef.current) {
40
- // Only auto-scroll if we're adding new messages (not loading older ones)
41
- // Skip auto-scroll if we're loading more (older messages)
42
67
  if (messages.length > prevLengthRef.current && !isLoadingMore) {
43
68
  messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
44
69
  }
@@ -54,17 +79,14 @@ export function MessageList({
54
79
  !isLoading &&
55
80
  !hasInitialScrolledRef.current
56
81
  ) {
57
- // Use setTimeout to ensure DOM is fully rendered
58
82
  setTimeout(() => {
59
83
  messagesEndRef.current?.scrollIntoView({ behavior: 'auto' });
60
- // Enable user scrolling after initial scroll completes
61
84
  setTimeout(() => {
62
85
  isUserScrollingRef.current = true;
63
86
  }, 200);
64
87
  }, 100);
65
88
  hasInitialScrolledRef.current = true;
66
89
  } else if (!isLoading && messages.length === 0 && !hasInitialScrolledRef.current) {
67
- // If there are no messages, enable user scrolling immediately
68
90
  isUserScrollingRef.current = true;
69
91
  hasInitialScrolledRef.current = true;
70
92
  }
@@ -85,12 +107,9 @@ export function MessageList({
85
107
  // Infinite scroll: detect when user scrolls near the top
86
108
  const handleScroll = useCallback(() => {
87
109
  if (!containerRef.current || !onLoadMore || !hasMore || isLoadingMore) return;
88
-
89
- // Only trigger load more if user has actually scrolled (prevents automatic trigger during initial load)
90
110
  if (!isUserScrollingRef.current) return;
91
111
 
92
112
  const { scrollTop } = containerRef.current;
93
- // Trigger load more when user scrolls within 100px of the top
94
113
  if (scrollTop < 100) {
95
114
  onLoadMore();
96
115
  }
@@ -113,16 +132,86 @@ export function MessageList({
113
132
  );
114
133
  }
115
134
 
135
+ // Empty state — premium, minimal, matching Xcelsior website feel
116
136
  if (messages.length === 0) {
117
137
  return (
118
- <div className="flex flex-col items-center justify-center h-full text-center p-8">
119
- <div className="text-6xl mb-4">💬</div>
120
- <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
121
- No messages yet
138
+ <div className="flex flex-col items-center justify-center h-full text-center" style={{ padding: '40px 32px' }}>
139
+ <h3
140
+ className="font-semibold mb-2"
141
+ style={{
142
+ color: textColor,
143
+ fontSize: '17px',
144
+ letterSpacing: '-0.01em',
145
+ }}
146
+ >
147
+ How can we help?
122
148
  </h3>
123
- <p className="text-sm text-gray-600 dark:text-gray-400">
124
- Start the conversation by sending a message below
149
+ <p
150
+ className="max-w-[240px]"
151
+ style={{
152
+ color: textMuted,
153
+ fontSize: '13px',
154
+ lineHeight: '1.5',
155
+ letterSpacing: '0.015em',
156
+ marginBottom: 20,
157
+ }}
158
+ >
159
+ Ask us anything. We are here to help you get the most out of Xcelsior.
125
160
  </p>
161
+
162
+ {/* Quick-start action buttons */}
163
+ <div className="flex flex-wrap justify-center gap-2">
164
+ {[
165
+ { label: 'Our services', message: 'What services does Xcelsior offer?' },
166
+ { label: 'Get a quote', message: 'I would like to get a quote for a project' },
167
+ { label: 'Support', message: 'I need help with something' },
168
+ ].map((action) => (
169
+ <button
170
+ key={action.label}
171
+ type="button"
172
+ onClick={() => onQuickAction?.(action.message)}
173
+ style={{
174
+ padding: '6px 14px',
175
+ borderRadius: '999px',
176
+ cursor: 'pointer',
177
+ transition: 'all 150ms ease',
178
+ backgroundColor: isLightTheme
179
+ ? 'rgba(0,0,0,0.04)'
180
+ : 'rgba(255,255,255,0.04)',
181
+ border: isLightTheme
182
+ ? '1px solid rgba(0,0,0,0.1)'
183
+ : '1px solid rgba(255,255,255,0.1)',
184
+ color: isLightTheme
185
+ ? 'rgba(0,0,0,0.6)'
186
+ : 'rgba(247,247,248,0.6)',
187
+ fontSize: '12px',
188
+ letterSpacing: '0.015em',
189
+ }}
190
+ onMouseEnter={(e) => {
191
+ e.currentTarget.style.backgroundColor = isLightTheme
192
+ ? 'rgba(0,0,0,0.08)'
193
+ : 'rgba(255,255,255,0.08)';
194
+ e.currentTarget.style.color = isLightTheme
195
+ ? 'rgba(0,0,0,0.8)'
196
+ : 'rgba(247,247,248,0.8)';
197
+ e.currentTarget.style.borderColor = primaryColor;
198
+ }}
199
+ onMouseLeave={(e) => {
200
+ e.currentTarget.style.backgroundColor = isLightTheme
201
+ ? 'rgba(0,0,0,0.04)'
202
+ : 'rgba(255,255,255,0.04)';
203
+ e.currentTarget.style.color = isLightTheme
204
+ ? 'rgba(0,0,0,0.6)'
205
+ : 'rgba(247,247,248,0.6)';
206
+ e.currentTarget.style.borderColor = isLightTheme
207
+ ? 'rgba(0,0,0,0.1)'
208
+ : 'rgba(255,255,255,0.1)';
209
+ }}
210
+ >
211
+ {action.label}
212
+ </button>
213
+ ))}
214
+ </div>
126
215
  </div>
127
216
  );
128
217
  }
@@ -130,12 +219,22 @@ export function MessageList({
130
219
  return (
131
220
  <div
132
221
  ref={containerRef}
133
- className="flex-1 overflow-y-auto p-4 space-y-2"
222
+ className="flex-1 overflow-y-auto px-4 py-3"
134
223
  style={{ scrollBehavior: 'smooth' }}
135
224
  >
225
+ <style>{`
226
+ @keyframes thinkingPulse {
227
+ 0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
228
+ 30% { opacity: 1; transform: scale(1); }
229
+ }
230
+ @keyframes cursorBlink {
231
+ 0%, 100% { opacity: 1; }
232
+ 50% { opacity: 0; }
233
+ }
234
+ `}</style>
136
235
  {/* Loading indicator at the top for infinite scroll */}
137
236
  {isLoadingMore && (
138
- <div className="flex justify-center py-4">
237
+ <div className="flex justify-center py-3">
139
238
  <Spinner size="sm" />
140
239
  </div>
141
240
  )}
@@ -150,32 +249,54 @@ export function MessageList({
150
249
  currentUser={currentUser}
151
250
  showAvatar={true}
152
251
  showTimestamp={true}
252
+ theme={theme}
153
253
  />
154
254
  ))}
155
255
 
256
+ {/* Typing indicator — matching bot message bubble style */}
156
257
  {isTyping && (
157
- <div className="flex gap-2 mb-4">
158
- <div className="flex-shrink-0">
159
- <div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-medium">
160
- 🎧
161
- </div>
258
+ <div className="flex gap-2.5 mb-3">
259
+ <div className="flex-shrink-0 mt-auto mb-5">
260
+ <XcelsiorAvatar size={28} />
162
261
  </div>
163
262
  <div className="flex flex-col items-start">
164
- <div className="rounded-2xl px-4 py-3 bg-gray-100 dark:bg-gray-800">
165
- <div className="flex gap-1">
166
- <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
167
- <span
168
- className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
169
- style={{ animationDelay: '0.1s' }}
170
- />
171
- <span
172
- className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
173
- style={{ animationDelay: '0.2s' }}
174
- />
263
+ <div
264
+ className="px-4 py-3"
265
+ style={{
266
+ backgroundColor: isLightTheme
267
+ ? 'rgba(0,0,0,0.04)'
268
+ : 'rgba(255,255,255,0.04)',
269
+ borderRadius: '18px 18px 18px 4px',
270
+ boxShadow: isLightTheme
271
+ ? 'inset 0 0 0 1px rgba(0,0,0,0.06)'
272
+ : 'inset 0 0 0 0.5px rgba(255,255,255,0.06), inset 0 1px 0 0 rgba(255,255,255,0.08)',
273
+ }}
274
+ >
275
+ <div className="flex gap-1.5 items-center" style={{ height: 16 }}>
276
+ {[0, 1, 2].map((i) => (
277
+ <span
278
+ key={i}
279
+ className="rounded-full animate-bounce"
280
+ style={{
281
+ width: 5,
282
+ height: 5,
283
+ backgroundColor: `${primaryColor}80`,
284
+ animationDelay: `${i * 0.15}s`,
285
+ animationDuration: '0.8s',
286
+ }}
287
+ />
288
+ ))}
175
289
  </div>
176
290
  </div>
177
291
  {typingUser && (
178
- <span className="text-xs text-gray-500 dark:text-gray-400 mt-1 px-2">
292
+ <span
293
+ className="mt-1 px-1"
294
+ style={{
295
+ color: textMuted,
296
+ fontSize: '11px',
297
+ letterSpacing: '0.019em',
298
+ }}
299
+ >
179
300
  {typingUser} is typing...
180
301
  </span>
181
302
  )}
@@ -183,6 +304,14 @@ export function MessageList({
183
304
  </div>
184
305
  )}
185
306
 
307
+ {/* Bot thinking indicator — Claude-like typewriter with rotating phrases */}
308
+ {isBotThinking && !isTyping && (
309
+ <ThinkingIndicator
310
+ theme={theme}
311
+ lastUserMessage={messages.filter((m) => m.senderType === 'customer').pop()?.content}
312
+ />
313
+ )}
314
+
186
315
  <div ref={messagesEndRef} />
187
316
  </div>
188
317
  );