@xcelsior/ui-chat 1.0.7 → 2.0.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.
Files changed (96) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/index.d.mts +69 -69
  3. package/dist/index.d.ts +69 -69
  4. package/dist/index.js +2458 -627
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +2457 -628
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +6 -5
  9. package/src/components/BrandIcons.stories.tsx +95 -0
  10. package/src/components/BrandIcons.tsx +84 -0
  11. package/src/components/Chat.stories.tsx +149 -16
  12. package/src/components/Chat.tsx +116 -96
  13. package/src/components/ChatHeader.tsx +124 -69
  14. package/src/components/ChatInput.tsx +253 -104
  15. package/src/components/ChatWidget.tsx +209 -63
  16. package/src/components/ConversationRating.stories.tsx +33 -0
  17. package/src/components/ConversationRating.tsx +156 -0
  18. package/src/components/MarkdownMessage.tsx +202 -0
  19. package/src/components/MessageItem.stories.tsx +253 -55
  20. package/src/components/MessageItem.tsx +222 -59
  21. package/src/components/MessageList.tsx +164 -35
  22. package/src/components/PreChatForm.tsx +236 -96
  23. package/src/components/ThinkingIndicator.tsx +370 -0
  24. package/src/components/TypingIndicator.tsx +27 -11
  25. package/src/hooks/useDraggablePosition.ts +91 -0
  26. package/src/hooks/useMessages.ts +12 -13
  27. package/src/hooks/useResizableWidget.ts +324 -0
  28. package/src/index.tsx +5 -0
  29. package/src/types.ts +51 -5
  30. package/src/utils/markdown-styles.ts +140 -0
  31. package/storybook-static/assets/BrandIcons-Cjy5INAp.js +4 -0
  32. package/storybook-static/assets/BrandIcons.stories-BeVC6svr.js +64 -0
  33. package/storybook-static/assets/Chat.stories-J_Yp51wU.js +803 -0
  34. package/storybook-static/assets/Color-YHDXOIA2-BMnd3YrF.js +1 -0
  35. package/storybook-static/assets/ConversationRating.stories-B5_QddHN.js +12 -0
  36. package/storybook-static/assets/DocsRenderer-CFRXHY34-i_W8iCu9.js +575 -0
  37. package/storybook-static/assets/MessageItem-DAaKZ9s9.js +14 -0
  38. package/storybook-static/assets/MessageItem.stories-Ckr1_scc.js +255 -0
  39. package/storybook-static/assets/ToastContext-Bty1K7ya.js +1 -0
  40. package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
  41. package/storybook-static/assets/en-US-BukEqXxE.js +1 -0
  42. package/storybook-static/assets/entry-preview-docs-DHohToDm.js +46 -0
  43. package/storybook-static/assets/entry-preview-oDnntGcx.js +2 -0
  44. package/storybook-static/assets/iframe-CGBtu2Se.js +211 -0
  45. package/storybook-static/assets/index--qcDGAq6.js +1 -0
  46. package/storybook-static/assets/index-BLHw34Di.js +24 -0
  47. package/storybook-static/assets/index-B_4m48Mv.js +1 -0
  48. package/storybook-static/assets/index-DgH-xKnr.js +11 -0
  49. package/storybook-static/assets/index-DrFu-skq.js +6 -0
  50. package/storybook-static/assets/index-DrdPSA1J.js +240 -0
  51. package/storybook-static/assets/index-jvNEZhzf.js +1 -0
  52. package/storybook-static/assets/index-yBjzXJbu.js +9 -0
  53. package/storybook-static/assets/jsx-runtime-Cf8x2fCZ.js +9 -0
  54. package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
  55. package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
  56. package/storybook-static/assets/preview-BRpahs9B.js +2 -0
  57. package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
  58. package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
  59. package/storybook-static/assets/preview-DD_OYowb.js +1 -0
  60. package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
  61. package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
  62. package/storybook-static/assets/preview-DUOvJmsz.js +1 -0
  63. package/storybook-static/assets/preview-DcGwT3kv.css +1 -0
  64. package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
  65. package/storybook-static/assets/react-18-CALspjOX.js +1 -0
  66. package/storybook-static/assets/test-utils-BE0XkMtV.js +9 -0
  67. package/storybook-static/favicon.svg +1 -0
  68. package/storybook-static/iframe.html +666 -0
  69. package/storybook-static/index.html +177 -0
  70. package/storybook-static/index.json +1 -0
  71. package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  72. package/storybook-static/nunito-sans-bold.woff2 +0 -0
  73. package/storybook-static/nunito-sans-italic.woff2 +0 -0
  74. package/storybook-static/nunito-sans-regular.woff2 +0 -0
  75. package/storybook-static/project.json +1 -0
  76. package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
  77. package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
  78. package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
  79. package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
  80. package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
  81. package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
  82. package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
  83. package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
  84. package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
  85. package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
  86. package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
  87. package/storybook-static/sb-common-assets/favicon.svg +1 -0
  88. package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  89. package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  90. package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  91. package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  92. package/storybook-static/sb-manager/globals-module-info.js +1052 -0
  93. package/storybook-static/sb-manager/globals-runtime.js +42127 -0
  94. package/storybook-static/sb-manager/globals.js +48 -0
  95. package/storybook-static/sb-manager/runtime.js +12048 -0
  96. package/.turbo/turbo-lint.log +0 -5
@@ -0,0 +1,370 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { XcelsiorAvatar } from './BrandIcons';
3
+ import type { IChatTheme } from '../types';
4
+
5
+ // ─── Phrase Pools ────────────────────────────────────────────────────────────
6
+ // Each pool is tied to a detected conversation context.
7
+ // Phrases should feel casual, human, and short (≤4 words ideal).
8
+
9
+ const PHRASE_POOLS: Record<string, string[]> = {
10
+ // ── Greetings & small talk ──
11
+ greeting: [
12
+ 'Hey there!',
13
+ 'Hello!',
14
+ 'Hi! One moment',
15
+ 'Welcome!',
16
+ 'Nice to meet you',
17
+ ],
18
+ farewell: [
19
+ 'Wrapping up',
20
+ 'One last thing',
21
+ 'Almost done',
22
+ ],
23
+
24
+ // ── Sales & pricing ──
25
+ pricing: [
26
+ 'Crunching numbers',
27
+ 'Checking our plans',
28
+ 'Let me look into that',
29
+ 'Pulling up pricing',
30
+ 'Running the numbers',
31
+ 'Checking options',
32
+ ],
33
+ quote: [
34
+ 'Putting this together',
35
+ 'Working on your quote',
36
+ 'Gathering the details',
37
+ 'Tailoring this for you',
38
+ ],
39
+
40
+ // ── Scheduling & booking ──
41
+ booking: [
42
+ 'Checking the calendar',
43
+ 'Finding good times',
44
+ 'Pulling up availability',
45
+ 'Let me check slots',
46
+ ],
47
+
48
+ // ── Technical questions ──
49
+ technical: [
50
+ 'Diving into the docs',
51
+ 'Interesting question',
52
+ 'Let me look that up',
53
+ 'Checking the specs',
54
+ 'Hmm, good one',
55
+ 'Researching this',
56
+ ],
57
+ code: [
58
+ 'Reading the code',
59
+ 'Checking the repo',
60
+ 'Let me trace that',
61
+ 'Debugging in my head',
62
+ ],
63
+
64
+ // ── Support & issues ──
65
+ support: [
66
+ 'On it!',
67
+ 'Let me help',
68
+ 'Looking into this',
69
+ 'Checking for you',
70
+ 'I got you',
71
+ 'Investigating',
72
+ ],
73
+ frustrated: [
74
+ 'I hear you',
75
+ 'Let me fix this',
76
+ 'Sorry about that',
77
+ 'Working on it now',
78
+ 'Bear with me',
79
+ ],
80
+
81
+ // ── Services & capabilities ──
82
+ services: [
83
+ 'Great question',
84
+ 'Let me explain',
85
+ 'Pulling up details',
86
+ 'Good to know you ask',
87
+ ],
88
+ portfolio: [
89
+ 'Checking our work',
90
+ 'Pulling up examples',
91
+ 'Let me show you',
92
+ 'Finding case studies',
93
+ ],
94
+
95
+ // ── About the company ──
96
+ about: [
97
+ 'Glad you asked!',
98
+ 'Let me tell you',
99
+ 'Good question',
100
+ 'Here we go',
101
+ ],
102
+
103
+ // ── Comparison & decisions ──
104
+ comparison: [
105
+ 'Weighing the options',
106
+ 'Let me compare',
107
+ 'Thinking through this',
108
+ 'Good point',
109
+ ],
110
+
111
+ // ── Follow-up & continuation ──
112
+ followUp: [
113
+ 'Let me dig deeper',
114
+ 'More details coming',
115
+ 'Building on that',
116
+ 'Expanding on this',
117
+ ],
118
+
119
+ // ── General / fallback ──
120
+ general: [
121
+ 'Hmm, let me think',
122
+ 'One sec',
123
+ 'On it',
124
+ 'Working on it',
125
+ 'Almost there',
126
+ 'Bear with me',
127
+ 'Let me check',
128
+ 'Good question',
129
+ 'Hang tight',
130
+ 'Looking into it',
131
+ 'Let me see',
132
+ 'Thinking',
133
+ 'Just a moment',
134
+ 'Figuring this out',
135
+ ],
136
+ };
137
+
138
+ // ─── Context Detection ───────────────────────────────────────────────────────
139
+ // Ordered by specificity — first match wins.
140
+
141
+ interface ContextRule {
142
+ key: string;
143
+ pattern: RegExp;
144
+ }
145
+
146
+ const CONTEXT_RULES: ContextRule[] = [
147
+ // Frustration / urgency (check first — trumps topic)
148
+ { key: 'frustrated', pattern: /frustrat|angry|annoyed|terrible|worst|useless|waste|stupid|wtf|seriously|ridiculous|unacceptable|disappointing/ },
149
+
150
+ // Greetings (start of message)
151
+ { key: 'greeting', pattern: /^(hi\b|hey\b|hello\b|good morning|good afternoon|good evening|g'day|howdy|yo\b|sup\b|what's up|greetings)/ },
152
+ { key: 'farewell', pattern: /\b(thanks|thank you|bye|goodbye|cheers|that's all|that's it|no more|all good|perfect thanks)\b/ },
153
+
154
+ // Booking & scheduling
155
+ { key: 'booking', pattern: /\b(book|schedule|meeting|appointment|calendar|available time|free slot|set up a call|arrange|consultation time|when can)\b/ },
156
+
157
+ // Pricing & quotes
158
+ { key: 'quote', pattern: /\b(quote|proposal|estimate|project cost|custom price|tailor|bespoke)\b/ },
159
+ { key: 'pricing', pattern: /\b(price|cost|how much|pricing|rate|fee|budget|afford|charge|per hour|hourly|package|plan|tier|subscription)\b/ },
160
+
161
+ // Technical / code
162
+ { key: 'code', pattern: /\b(code|github|repo|commit|deploy|ci\/cd|docker|aws|lambda|api endpoint|sdk|npm|yarn|pnpm|typescript|react|next\.?js)\b/ },
163
+ { key: 'technical', pattern: /\b(api|integrate|integration|stack|server|database|backend|frontend|infrastructure|architecture|performance|scalab|migration|devops|cloud)\b/ },
164
+
165
+ // Support & bugs
166
+ { key: 'support', pattern: /\b(bug|error|issue|broken|not working|help|support|problem|fix|crash|down|fail|stuck|trouble|can't|cannot|doesn't work|won't)\b/ },
167
+
168
+ // Services & portfolio
169
+ { key: 'portfolio', pattern: /\b(portfolio|case stud|example|previous work|client|project you|showcase|demo|sample)\b/ },
170
+ { key: 'services', pattern: /\b(service|what do you|what can you|offer|do you do|capabilit|specializ|expertise|solution|consult|develop|design|build|create)\b/ },
171
+
172
+ // About company
173
+ { key: 'about', pattern: /\b(who are|about you|your team|your company|where are you|location|office|founded|history|values|mission)\b/ },
174
+
175
+ // Comparison / decision-making
176
+ { key: 'comparison', pattern: /\b(compare|vs|versus|difference|better|which one|alternative|competitor|pros and cons|trade.?off|should i|recommend)\b/ },
177
+
178
+ // Follow-up patterns (vague follow-ups to prior answers)
179
+ { key: 'followUp', pattern: /\b(more detail|tell me more|elaborate|explain further|what about|and also|another question|one more|can you also|what else|go on)\b/ },
180
+ ];
181
+
182
+ function detectContext(message?: string): string {
183
+ if (!message) return 'general';
184
+ const lower = message.toLowerCase().trim();
185
+
186
+ for (const rule of CONTEXT_RULES) {
187
+ if (rule.pattern.test(lower)) return rule.key;
188
+ }
189
+
190
+ // Short messages (1-3 words) that didn't match anything → likely casual
191
+ if (lower.split(/\s+/).length <= 3) return 'general';
192
+
193
+ // Questions tend to be exploratory
194
+ if (lower.endsWith('?')) return 'general';
195
+
196
+ return 'general';
197
+ }
198
+
199
+ // ─── Shuffle Utility ─────────────────────────────────────────────────────────
200
+
201
+ function getShuffledPhrases(context: string): string[] {
202
+ const pool = PHRASE_POOLS[context] || PHRASE_POOLS.general;
203
+ const contextPhrases = [...pool];
204
+
205
+ // Mix in 2-3 general phrases for variety (avoid duplicates)
206
+ const extras = PHRASE_POOLS.general
207
+ .filter((p) => !contextPhrases.includes(p))
208
+ .sort(() => Math.random() - 0.5)
209
+ .slice(0, 3);
210
+
211
+ const combined = [...contextPhrases, ...extras];
212
+
213
+ // Fisher-Yates shuffle
214
+ for (let i = combined.length - 1; i > 0; i--) {
215
+ const j = Math.floor(Math.random() * (i + 1));
216
+ [combined[i], combined[j]] = [combined[j], combined[i]];
217
+ }
218
+ return combined;
219
+ }
220
+
221
+ // ─── Theme Helpers ───────────────────────────────────────────────────────────
222
+
223
+ function computeIsLightTheme(bg?: string): boolean {
224
+ if (!bg?.startsWith('#')) return false;
225
+ const hex = bg.replace('#', '');
226
+ const r = parseInt(hex.substring(0, 2), 16);
227
+ const g = parseInt(hex.substring(2, 4), 16);
228
+ const b = parseInt(hex.substring(4, 6), 16);
229
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
230
+ }
231
+
232
+ // ─── Component ───────────────────────────────────────────────────────────────
233
+
234
+ export interface ThinkingIndicatorProps {
235
+ /** Last message the user sent — used to pick contextual phrases */
236
+ lastUserMessage?: string;
237
+ /** Chat theme for color matching */
238
+ theme?: IChatTheme;
239
+ /** Show avatar next to the indicator */
240
+ showAvatar?: boolean;
241
+ }
242
+
243
+ export function ThinkingIndicator({
244
+ lastUserMessage,
245
+ theme,
246
+ showAvatar = true,
247
+ }: ThinkingIndicatorProps) {
248
+ const isLightTheme = computeIsLightTheme(theme?.background);
249
+ const primaryColor = theme?.primary || '#337eff';
250
+ const textMuted = theme?.textMuted || (isLightTheme ? 'rgba(0,0,0,0.4)' : 'rgba(247,247,248,0.45)');
251
+
252
+ const [phraseIndex, setPhraseIndex] = useState(0);
253
+ const [displayText, setDisplayText] = useState('');
254
+ const [isDeleting, setIsDeleting] = useState(false);
255
+ const phrasesRef = useRef<string[]>(getShuffledPhrases(detectContext(lastUserMessage)));
256
+
257
+ // Re-shuffle when user sends a new message
258
+ useEffect(() => {
259
+ phrasesRef.current = getShuffledPhrases(detectContext(lastUserMessage));
260
+ setPhraseIndex(0);
261
+ setDisplayText('');
262
+ setIsDeleting(false);
263
+ }, [lastUserMessage]);
264
+
265
+ // Typewriter effect
266
+ useEffect(() => {
267
+ const phrases = phrasesRef.current;
268
+ const phrase = phrases[phraseIndex % phrases.length];
269
+ let timeout: NodeJS.Timeout;
270
+
271
+ if (!isDeleting) {
272
+ if (displayText.length < phrase.length) {
273
+ // Typing forward — variable speed for natural feel
274
+ timeout = setTimeout(
275
+ () => setDisplayText(phrase.slice(0, displayText.length + 1)),
276
+ 30 + Math.random() * 30,
277
+ );
278
+ } else {
279
+ // Pause at full phrase before deleting
280
+ timeout = setTimeout(() => setIsDeleting(true), 1800);
281
+ }
282
+ } else {
283
+ if (displayText.length > 0) {
284
+ // Deleting — faster than typing
285
+ timeout = setTimeout(
286
+ () => setDisplayText(displayText.slice(0, -1)),
287
+ 18,
288
+ );
289
+ } else {
290
+ // Advance to next phrase
291
+ setIsDeleting(false);
292
+ setPhraseIndex((prev) => (prev + 1) % phrasesRef.current.length);
293
+ }
294
+ }
295
+
296
+ return () => clearTimeout(timeout);
297
+ }, [displayText, isDeleting, phraseIndex]);
298
+
299
+ return (
300
+ <div
301
+ className="flex gap-2.5 mb-3"
302
+ role="status"
303
+ aria-live="polite"
304
+ aria-label="Xcelsior is thinking"
305
+ >
306
+ {showAvatar && (
307
+ <div className="flex-shrink-0 mt-auto mb-5">
308
+ <XcelsiorAvatar size={28} />
309
+ </div>
310
+ )}
311
+ <div className="flex flex-col items-start">
312
+ <div
313
+ style={{
314
+ backgroundColor: isLightTheme
315
+ ? 'rgba(0,0,0,0.04)'
316
+ : 'rgba(255,255,255,0.04)',
317
+ borderRadius: '18px 18px 18px 4px',
318
+ boxShadow: isLightTheme
319
+ ? 'inset 0 0 0 1px rgba(0,0,0,0.06)'
320
+ : 'inset 0 0 0 0.5px rgba(255,255,255,0.06), inset 0 1px 0 0 rgba(255,255,255,0.08)',
321
+ padding: '12px 16px',
322
+ minWidth: 160,
323
+ }}
324
+ >
325
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
326
+ {/* Pulsing dots */}
327
+ <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
328
+ {[0, 1, 2].map((i) => (
329
+ <span
330
+ key={i}
331
+ style={{
332
+ width: 5,
333
+ height: 5,
334
+ borderRadius: '50%',
335
+ backgroundColor: primaryColor,
336
+ display: 'inline-block',
337
+ animation: `thinkingPulse 1.4s ease-in-out ${i * 0.2}s infinite`,
338
+ }}
339
+ />
340
+ ))}
341
+ </div>
342
+
343
+ {/* Typewriter text + blinking cursor */}
344
+ <span
345
+ style={{
346
+ fontSize: 12,
347
+ color: textMuted,
348
+ letterSpacing: '0.015em',
349
+ fontStyle: 'italic',
350
+ }}
351
+ >
352
+ {displayText}
353
+ <span
354
+ style={{
355
+ display: 'inline-block',
356
+ width: 1,
357
+ height: 13,
358
+ backgroundColor: textMuted,
359
+ marginLeft: 1,
360
+ animation: 'cursorBlink 0.8s step-end infinite',
361
+ verticalAlign: 'text-bottom',
362
+ }}
363
+ />
364
+ </span>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ );
370
+ }
@@ -9,20 +9,36 @@ export function TypingIndicator({ isTyping, userName }: TypingIndicatorProps) {
9
9
  }
10
10
 
11
11
  return (
12
- <div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
12
+ <div
13
+ className="px-4 py-2"
14
+ style={{
15
+ borderTop: '1px solid rgba(255,255,255,0.06)',
16
+ backgroundColor: 'rgba(0,0,0,0.15)',
17
+ }}
18
+ >
13
19
  <div className="flex items-center gap-2">
14
20
  <div className="flex gap-1">
15
- <span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
16
- <span
17
- className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
18
- style={{ animationDelay: '0.1s' }}
19
- />
20
- <span
21
- className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
22
- style={{ animationDelay: '0.2s' }}
23
- />
21
+ {[0, 1, 2].map((i) => (
22
+ <span
23
+ key={i}
24
+ className="rounded-full animate-bounce"
25
+ style={{
26
+ width: 5,
27
+ height: 5,
28
+ backgroundColor: 'rgba(51,126,255,0.6)',
29
+ animationDelay: `${i * 0.1}s`,
30
+ animationDuration: '0.8s',
31
+ }}
32
+ />
33
+ ))}
24
34
  </div>
25
- <span className="text-xs text-gray-600 dark:text-gray-400">
35
+ <span
36
+ style={{
37
+ fontSize: '12px',
38
+ letterSpacing: '0.015em',
39
+ color: 'rgba(247,247,248,0.45)',
40
+ }}
41
+ >
26
42
  {userName ? `${userName} is typing...` : 'Someone is typing...'}
27
43
  </span>
28
44
  </div>
@@ -0,0 +1,91 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import type { WidgetPosition } from '../types';
3
+
4
+ const STORAGE_KEY = 'xcelsior-chat-position';
5
+
6
+ function getStoredPosition(): 'left' | 'right' {
7
+ try {
8
+ const stored = localStorage.getItem(STORAGE_KEY);
9
+ if (stored === 'left' || stored === 'right') return stored;
10
+ } catch {
11
+ // localStorage not available
12
+ }
13
+ return 'right';
14
+ }
15
+
16
+ function storePosition(position: 'left' | 'right') {
17
+ try {
18
+ localStorage.setItem(STORAGE_KEY, position);
19
+ } catch {
20
+ // localStorage not available
21
+ }
22
+ }
23
+
24
+ export function useDraggablePosition(configPosition: WidgetPosition = 'auto') {
25
+ const resolvedDefault = configPosition === 'auto' ? getStoredPosition() : configPosition;
26
+ const [position, setPosition] = useState<'left' | 'right'>(resolvedDefault);
27
+ const [isDragging, setIsDragging] = useState(false);
28
+ const dragStartX = useRef(0);
29
+ const fabRef = useRef<HTMLButtonElement>(null);
30
+ const hasShownHint = useRef(false);
31
+ const [showHint, setShowHint] = useState(false);
32
+
33
+ // Show bounce hint on first visit
34
+ useEffect(() => {
35
+ try {
36
+ const hasVisited = localStorage.getItem('xcelsior-chat-hint-shown');
37
+ if (!hasVisited && !hasShownHint.current) {
38
+ hasShownHint.current = true;
39
+ setShowHint(true);
40
+ const timer = setTimeout(() => {
41
+ setShowHint(false);
42
+ localStorage.setItem('xcelsior-chat-hint-shown', 'true');
43
+ }, 2000);
44
+ return () => clearTimeout(timer);
45
+ }
46
+ } catch {
47
+ // localStorage not available
48
+ }
49
+ }, []);
50
+
51
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
52
+ dragStartX.current = e.clientX;
53
+ setIsDragging(true);
54
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
55
+ }, []);
56
+
57
+ const handlePointerMove = useCallback((_e: React.PointerEvent) => {
58
+ // Visual feedback could be added here (e.g., opacity change)
59
+ }, []);
60
+
61
+ const handlePointerUp = useCallback(
62
+ (e: React.PointerEvent) => {
63
+ setIsDragging(false);
64
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
65
+
66
+ const deltaX = e.clientX - dragStartX.current;
67
+ // Only snap if dragged more than 50px horizontally
68
+ if (Math.abs(deltaX) > 50) {
69
+ const screenMid = window.innerWidth / 2;
70
+ const newPosition = e.clientX < screenMid ? 'left' : 'right';
71
+ if (newPosition !== position) {
72
+ setPosition(newPosition);
73
+ storePosition(newPosition);
74
+ }
75
+ }
76
+ },
77
+ [position]
78
+ );
79
+
80
+ return {
81
+ position,
82
+ isDragging,
83
+ showHint,
84
+ fabRef,
85
+ handlers: {
86
+ onPointerDown: handlePointerDown,
87
+ onPointerMove: handlePointerMove,
88
+ onPointerUp: handlePointerUp,
89
+ },
90
+ };
91
+ }
@@ -1,20 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
- import type { IMessage, IChatConfig } from '../types';
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
- export interface UseMessagesReturn {
7
- messages: IMessage[];
8
- addMessage: (message: IMessage) => void;
9
- updateMessageStatus: (messageId: string, status: IMessage['status']) => void;
10
- clearMessages: () => void;
11
- isLoading: boolean;
12
- error: Error | null;
13
- loadMore: () => Promise<void>;
14
- hasMore: boolean;
15
- isLoadingMore: boolean;
16
- }
17
-
18
6
  export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig): UseMessagesReturn {
19
7
  const [messages, setMessages] = useState<IMessage[]>([]);
20
8
  const [isLoading, setIsLoading] = useState(false);
@@ -22,6 +10,7 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
22
10
  const [nextPageToken, setNextPageToken] = useState<string | undefined>(undefined);
23
11
  const [hasMore, setHasMore] = useState(true);
24
12
  const [isLoadingMore, setIsLoadingMore] = useState(false);
13
+ const [isBotThinking, setIsBotThinking] = useState(false);
25
14
 
26
15
  // Extract stable references from config
27
16
  const { httpApiUrl, conversationId, headers, onError, toast } = config;
@@ -89,6 +78,11 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
89
78
  return [...prev, newMessage];
90
79
  });
91
80
 
81
+ // Clear bot thinking state when bot or system message arrives
82
+ if (newMessage.senderType === 'bot' || newMessage.senderType === 'system') {
83
+ setIsBotThinking(false);
84
+ }
85
+
92
86
  // Notify parent component about new message
93
87
  onMessageReceived?.(newMessage);
94
88
  }
@@ -102,6 +96,10 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
102
96
  }
103
97
  return [...prev, message];
104
98
  });
99
+ // Show bot thinking indicator immediately when customer sends a message
100
+ if (message.senderType === 'customer') {
101
+ setIsBotThinking(true);
102
+ }
105
103
  }, []);
106
104
 
107
105
  const updateMessageStatus = useCallback((messageId: string, status: IMessage['status']) => {
@@ -161,5 +159,6 @@ export function useMessages(websocket: UseWebSocketReturn, config: IChatConfig):
161
159
  loadMore,
162
160
  hasMore,
163
161
  isLoadingMore,
162
+ isBotThinking,
164
163
  };
165
164
  }