@aslaluroba/help-center-react 3.2.1 → 3.2.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.
@@ -5,6 +5,7 @@ interface ChatWindowFooterProps {
5
5
  handleSendMessage: (attachmentIds: string[]) => void;
6
6
  isLoading: boolean;
7
7
  onEnsureSession: () => Promise<string>;
8
+ sessionId?: string | null;
8
9
  }
9
10
  declare const ChatWindowFooter: React.FC<ChatWindowFooterProps>;
10
11
  export default ChatWindowFooter;
@@ -7,6 +7,7 @@ interface ChatWindowProps {
7
7
  assistantStatus: string;
8
8
  needsAgent: boolean;
9
9
  isAblyConnected: boolean;
10
+ sessionId?: string | null;
10
11
  }
11
- export declare const ChatWindow: React.MemoExoticComponent<({ onSendMessage, onEnsureSession, messages, assistantStatus, needsAgent }: ChatWindowProps) => import("react/jsx-runtime").JSX.Element>;
12
+ export declare const ChatWindow: React.MemoExoticComponent<({ onSendMessage, onEnsureSession, messages, assistantStatus, needsAgent, sessionId, }: ChatWindowProps) => import("react/jsx-runtime").JSX.Element>;
12
13
  export {};
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "dist/index.js",
4
4
  "module": "dist/index.esm.js",
5
5
  "types": "dist/index.d.ts",
6
- "version": "3.2.1",
6
+ "version": "3.2.4",
7
7
  "description": "BabylAI Help Center Widget for React and Next.js",
8
8
  "private": false,
9
9
  "exports": {
@@ -12,12 +12,14 @@ interface AgentResponseProps {
12
12
  const seenMessagesRef = new Set<number>();
13
13
 
14
14
  const AgentResponse = ({ senderType, messageContent, messageId, onType }: AgentResponseProps) => {
15
+ // Ensure messageContent is always a string to prevent errors
16
+ const safeMessageContent = messageContent ?? '';
15
17
  const shouldAnimate = (senderType === 2 || senderType === 3) && !seenMessagesRef.has(messageId);
16
- const animatedText = useTypewriter(messageContent, 20, onType);
17
- const finalMessage = shouldAnimate ? animatedText : messageContent;
18
+ const animatedText = useTypewriter(safeMessageContent, 20, onType);
19
+ const finalMessage = shouldAnimate ? animatedText : safeMessageContent;
18
20
 
19
21
  // Mark message as "seen" after full animation
20
- if (shouldAnimate && finalMessage === messageContent) {
22
+ if (shouldAnimate && finalMessage === safeMessageContent) {
21
23
  seenMessagesRef.add(messageId);
22
24
  }
23
25
 
@@ -5,7 +5,8 @@ import { useLocalTranslation } from '../../useLocalTranslation';
5
5
  import { ImagePreviewDialog } from './image-preview-dialog';
6
6
 
7
7
  interface ImageAttachmentProps {
8
- fileId: string;
8
+ fileId?: string; // File ID (for user-sent messages, requires presignDownload)
9
+ imageUrl?: string; // Direct URL (for received messages from Ably)
9
10
  className?: string;
10
11
  enablePreview?: boolean;
11
12
  onClick?: () => void;
@@ -13,32 +14,43 @@ interface ImageAttachmentProps {
13
14
 
14
15
  export const ImageAttachment: React.FC<ImageAttachmentProps> = ({
15
16
  fileId,
17
+ imageUrl: propImageUrl,
16
18
  className,
17
19
  enablePreview = true,
18
20
  onClick,
19
21
  }) => {
20
22
  const { i18n } = useLocalTranslation();
21
- const [imageUrl, setImageUrl] = useState<string | null>(null);
22
- const [loading, setLoading] = useState(true);
23
+ const [imageUrl, setImageUrl] = useState<string | null>(propImageUrl || null);
24
+ const [loading, setLoading] = useState(!propImageUrl && !!fileId);
23
25
  const [error, setError] = useState(false);
24
26
  const [isPreviewOpen, setIsPreviewOpen] = useState(false);
25
27
 
26
28
  useEffect(() => {
27
- const fetchImageUrl = async () => {
28
- try {
29
- setLoading(true);
30
- setError(false);
31
- const response = await presignDownload(fileId, i18n.language);
32
- setImageUrl(response.downloadUrl);
33
- } catch (err) {
34
- setError(true);
35
- } finally {
36
- setLoading(false);
37
- }
38
- };
29
+ // If we have a direct URL, use it immediately
30
+ if (propImageUrl) {
31
+ setImageUrl(propImageUrl);
32
+ setLoading(false);
33
+ return;
34
+ }
35
+
36
+ // If we only have a fileId, fetch the URL using presignDownload
37
+ if (fileId) {
38
+ const fetchImageUrl = async () => {
39
+ try {
40
+ setLoading(true);
41
+ setError(false);
42
+ const response = await presignDownload(fileId, i18n.language);
43
+ setImageUrl(response.downloadUrl);
44
+ } catch (err) {
45
+ setError(true);
46
+ } finally {
47
+ setLoading(false);
48
+ }
49
+ };
39
50
 
40
- fetchImageUrl();
41
- }, [fileId, i18n.language]);
51
+ fetchImageUrl();
52
+ }
53
+ }, [fileId, propImageUrl, i18n.language]);
42
54
 
43
55
  const handleImageClick = () => {
44
56
  if (onClick) {
@@ -70,6 +70,38 @@ export const ImagePreviewDialog: React.FC<ImagePreviewDialogProps> = ({
70
70
  setImagePosition({ x: 0, y: 0 });
71
71
  }, []);
72
72
 
73
+ const handleDownload = useCallback(async () => {
74
+ if (!currentImageUrl) return;
75
+
76
+ try {
77
+ // Fetch the image as a blob
78
+ const response = await fetch(currentImageUrl);
79
+ const blob = await response.blob();
80
+
81
+ // Create a temporary URL for the blob
82
+ const blobUrl = URL.createObjectURL(blob);
83
+
84
+ // Extract filename from URL or use a default
85
+ const urlParts = currentImageUrl.split('/');
86
+ const filename = urlParts[urlParts.length - 1].split('?')[0] || 'image.png';
87
+
88
+ // Create a temporary anchor element and trigger download
89
+ const link = document.createElement('a');
90
+ link.href = blobUrl;
91
+ link.download = filename;
92
+ document.body.appendChild(link);
93
+ link.click();
94
+
95
+ // Cleanup
96
+ document.body.removeChild(link);
97
+ URL.revokeObjectURL(blobUrl);
98
+ } catch (error) {
99
+ console.error('Failed to download image:', error);
100
+ // Fallback: open in new tab if download fails
101
+ window.open(currentImageUrl, '_blank');
102
+ }
103
+ }, [currentImageUrl]);
104
+
73
105
  const handleClose = useCallback(() => {
74
106
  setZoomLevel(1);
75
107
  setImagePosition({ x: 0, y: 0 });
@@ -307,6 +339,20 @@ export const ImagePreviewDialog: React.FC<ImagePreviewDialogProps> = ({
307
339
  Reset
308
340
  </Button>
309
341
  )}
342
+ {/* Download Button */}
343
+ <div className='babylai-h-9 babylai-w-px babylai-bg-white/20 babylai-mx-1' />
344
+ <Button
345
+ variant='ghost'
346
+ size='icon'
347
+ onClick={handleDownload}
348
+ className='babylai-text-white hover:babylai-text-white/80 hover:babylai-bg-white/10 babylai-h-9 babylai-w-9'
349
+ aria-label='Download image'
350
+ type='button'
351
+ >
352
+ <svg className='babylai-w-5 babylai-h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
353
+ <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' />
354
+ </svg>
355
+ </Button>
310
356
  </div>
311
357
 
312
358
  {/* Image Counter */}
@@ -26,10 +26,19 @@ export class ClientAblyService {
26
26
  autoConnect: true,
27
27
  });
28
28
 
29
+ this.client.connection.on('failed', (stateChange) => {
30
+ console.error('[AblyService] Connection state: failed', {
31
+ reason: stateChange.reason?.message,
32
+ error: stateChange.reason,
33
+ });
34
+ });
35
+
29
36
  // Wait for connection to be established
30
37
  await new Promise<void>((resolve, reject) => {
31
38
  if (!this.client) {
32
- reject(new Error('Failed to initialize Ably client'));
39
+ const error = new Error('Failed to initialize Ably client');
40
+ console.error('[AblyService]', error);
41
+ reject(error);
33
42
  return;
34
43
  }
35
44
 
@@ -40,17 +49,26 @@ export class ClientAblyService {
40
49
  });
41
50
 
42
51
  this.client.connection.once('failed', (stateChange) => {
43
- reject(new Error(`Ably connection failed: ${stateChange.reason?.message || 'Unknown error'}`));
52
+ const error = new Error(`Ably connection failed: ${stateChange.reason?.message || 'Unknown error'}`);
53
+ console.error('[AblyService] Connection failed', {
54
+ reason: stateChange.reason?.message,
55
+ error: stateChange.reason,
56
+ });
57
+ reject(error);
44
58
  });
45
59
 
46
60
  this.client.connection.once('disconnected', (stateChange) => {
47
- reject(new Error(`Ably connection disconnected: ${stateChange.reason?.message || 'Unknown error'}`));
61
+ const error = new Error(`Ably connection disconnected: ${stateChange.reason?.message || 'Unknown error'}`);
62
+ console.error('[AblyService] Connection disconnected', { reason: stateChange.reason?.message });
63
+ reject(error);
48
64
  });
49
65
 
50
66
  // Set a timeout for connection
51
67
  setTimeout(() => {
52
68
  if (!this.isConnected) {
53
- reject(new Error('Ably connection timeout'));
69
+ const error = new Error('Ably connection timeout');
70
+ console.error('[AblyService] Connection timeout after 10 seconds');
71
+ reject(error);
54
72
  }
55
73
  }, 10000);
56
74
  });
@@ -58,6 +76,7 @@ export class ClientAblyService {
58
76
  // Subscribe to the session room
59
77
  await this.joinChannel(sessionId, onMessageReceived, tenantId);
60
78
  } catch (error) {
79
+ console.error('[AblyService] Error in startConnection', { error, sessionId });
61
80
  this.isConnected = false;
62
81
  this.sessionId = null;
63
82
  throw error;
@@ -66,7 +85,9 @@ export class ClientAblyService {
66
85
 
67
86
  private static async joinChannel(sessionId: string, onMessageReceived: Function, tenantId: string) {
68
87
  if (!this.client) {
69
- throw new Error('Chat client not initialized');
88
+ const error = new Error('Chat client not initialized');
89
+ console.error('[AblyService] joinChannel error:', error);
90
+ throw error;
70
91
  }
71
92
 
72
93
  const roomName = `session:${tenantId}:${sessionId}`;
@@ -75,21 +96,46 @@ export class ClientAblyService {
75
96
  if (this.client) {
76
97
  this.channel = this.client.channels.get(roomName);
77
98
 
99
+ this.channel.on('failed', (stateChange) => {
100
+ console.error('[AblyService] Channel failed', {
101
+ roomName,
102
+ reason: stateChange.reason?.message,
103
+ error: stateChange.reason,
104
+ });
105
+ });
106
+
78
107
  // Subscribe to assistant/system responses
79
108
  this.channel.subscribe('ReceiveMessage', (message) => {
80
109
  try {
110
+ // Ensure messageContent is always a string (default to empty string if undefined)
81
111
  const messageContent =
82
- typeof message.data === 'string' ? message.data : message.data?.content || message.data?.message;
112
+ typeof message.data === 'string' ? message.data : message.data?.content ?? message.data?.message ?? '';
83
113
  const senderType = message.data?.senderType || 3; // Assistant
84
- const needsAgent =
85
- message.data?.needsAgent ||
86
- message.data?.actionType == "needs_agent" ||
87
- false;
114
+ const needsAgent = message.data?.needsAgent || message.data?.actionType == 'needs_agent' || false;
88
115
  const attachments = message.data?.attachments || [];
89
116
 
90
- onMessageReceived(messageContent, senderType, needsAgent, attachments);
117
+ // Extract downloadUrl from attachments (Ably now returns downloadUrl directly)
118
+ // Attachments can be: strings (URLs), objects with downloadUrl, or objects with id
119
+ const attachmentUrls = attachments
120
+ .map((attachment: any) => {
121
+ if (typeof attachment === 'string') {
122
+ // If it's already a string, it's a URL
123
+ return attachment;
124
+ } else if (attachment?.downloadUrl) {
125
+ // If it's an object with downloadUrl, use that
126
+ return attachment.downloadUrl;
127
+ } else if (attachment?.url) {
128
+ // Fallback to url property
129
+ return attachment.url;
130
+ }
131
+ // If it's an object with id, we'll need to keep it for backward compatibility
132
+ return null;
133
+ })
134
+ .filter((url: string | null): url is string => url !== null);
135
+
136
+ onMessageReceived(messageContent, senderType, needsAgent, attachmentUrls);
91
137
  } catch (error) {
92
- // Handle error silently
138
+ console.error('[AblyService] Error processing message', { error, message });
93
139
  }
94
140
  });
95
141
 
@@ -121,6 +167,7 @@ export class ClientAblyService {
121
167
  this.isConnected = false;
122
168
  this.sessionId = null;
123
169
  } catch (error) {
170
+ console.error('[AblyService] Error in stopConnection', { error });
124
171
  // Reset state even if there's an error
125
172
  this.isConnected = false;
126
173
  this.sessionId = null;
package/src/core/api.ts CHANGED
@@ -238,7 +238,6 @@ export async function presignUpload(
238
238
 
239
239
  return await response.json();
240
240
  }
241
-
242
241
  export async function presignDownload(fileId: string, language: string): Promise<PresignDownloadResponse> {
243
242
  try {
244
243
  const response = await apiRequest(`NewFile/${fileId}/presign-download`, 'GET', null, {
@@ -4,21 +4,23 @@ export function useTypewriter(text: string, speed: number = 20, onType?: () => v
4
4
  const [displayedText, setDisplayedText] = useState('')
5
5
 
6
6
  useEffect(() => {
7
+ // Ensure text is always a string to prevent errors
8
+ const safeText = text ?? ''
7
9
  let index = 0
8
10
  setDisplayedText('')
9
11
 
10
12
  const interval = setInterval(() => {
11
13
  setDisplayedText(() => {
12
- const next = text.slice(0, index + 1)
14
+ const next = safeText.slice(0, index + 1)
13
15
  index++
14
16
  if (onType) onType()
15
- if (index >= text.length) clearInterval(interval)
17
+ if (index >= safeText.length) clearInterval(interval)
16
18
  return next
17
19
  })
18
20
  }, speed)
19
21
 
20
22
  return () => clearInterval(interval)
21
- }, [text])
23
+ }, [text, onType])
22
24
 
23
25
  return displayedText
24
26
  }
package/src/lib/types.ts CHANGED
@@ -27,7 +27,8 @@ export interface Message {
27
27
  messageContent: string;
28
28
  sentAt: Date;
29
29
  isSeen: boolean;
30
- attachmentIds?: string[];
30
+ attachmentIds?: string[]; // For user-sent messages (file IDs)
31
+ attachmentUrls?: string[]; // For received messages from Ably (download URLs)
31
32
  }
32
33
 
33
34
  export interface ChatSession {
@@ -22,6 +22,7 @@ interface ChatWindowFooterProps {
22
22
  handleSendMessage: (attachmentIds: string[]) => void;
23
23
  isLoading: boolean;
24
24
  onEnsureSession: () => Promise<string>;
25
+ sessionId?: string | null; // Pass existing sessionId to avoid creating new sessions
25
26
  }
26
27
 
27
28
  const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
@@ -29,20 +30,19 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
29
30
  const fileInputRef = useRef<HTMLInputElement>(null);
30
31
  const [selectedFiles, setSelectedFiles] = useState<SelectedFileDto[]>([]);
31
32
  const [previewImage, setPreviewImage] = useState<string | null>(null);
33
+ const [isSending, setIsSending] = useState(false);
32
34
 
33
35
  const handleAttachClick = useCallback(() => {
34
36
  fileInputRef.current?.click();
35
37
  }, []);
36
38
 
37
- const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
39
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
38
40
  const files = Array.from(e.target.files || []);
39
41
 
40
42
  // Validate that all files are images
41
43
  const imageFiles = files.filter((file) => file.type.startsWith('image/'));
42
44
 
43
- // Only image files are allowed
44
-
45
- // Create preview URLs and add to selected files
45
+ // Create preview URLs and add to selected files (don't upload yet)
46
46
  const newFiles: SelectedFileDto[] = imageFiles.map((file) => ({
47
47
  file,
48
48
  previewUrl: URL.createObjectURL(file),
@@ -58,118 +58,150 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
58
58
  fileInputRef.current.value = '';
59
59
  }
60
60
 
61
- // Start uploading immediately
62
- await handleUploadFiles(newFiles);
61
+ // Don't upload files immediately - wait for send button click
63
62
  }, []);
64
63
 
65
- const handleUploadFiles = useCallback(
66
- async (filesToUpload: SelectedFileDto[]) => {
67
- // Get session ID
68
- let sessionId: string;
69
- try {
70
- sessionId = await props.onEnsureSession();
71
- } catch (error) {
72
- // Mark all files as error
73
- setSelectedFiles((prev) =>
74
- prev.map((f) =>
75
- filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
76
- ? { ...f, error: 'Failed to initialize session', uploading: false }
77
- : f
78
- )
79
- );
80
- return;
64
+ // Removed handleUploadFiles - files are now uploaded in handleSendMessageWithAttachments
65
+
66
+ const handleRemoveFile = useCallback((previewUrl: string) => {
67
+ setSelectedFiles((prev) => {
68
+ const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
69
+ if (fileToRemove) {
70
+ URL.revokeObjectURL(fileToRemove.previewUrl);
81
71
  }
72
+ return prev.filter((f) => f.previewUrl !== previewUrl);
73
+ });
74
+ }, []);
82
75
 
83
- // Upload each file
84
- for (const fileDto of filesToUpload) {
85
- try {
86
- // Mark as uploading
87
- setSelectedFiles((prev) =>
88
- prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
89
- );
76
+ const handleSendMessageWithAttachments = useCallback(async () => {
77
+ // Prevent sending if already loading
78
+ if (props.isLoading || isSending) {
79
+ return;
80
+ }
90
81
 
91
- // Get presigned URL
92
- const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
93
-
94
- // Upload file to presigned URL using axios
95
- // Important: Content-Type must match the file type (e.g., 'image/png'), not 'multipart/form-data'
96
- const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
97
- headers: {
98
- 'Content-Type': fileDto.file.type,
99
- },
100
- onUploadProgress: () => {
101
- // Upload progress tracking (silent)
102
- },
103
- });
104
-
105
- if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
106
- throw new Error(`Upload failed with status ${uploadResponse.status}`);
107
- }
82
+ setIsSending(true);
108
83
 
109
- // Update with uploaded ID
110
- setSelectedFiles((prev) =>
111
- prev.map((f) =>
112
- f.previewUrl === fileDto.previewUrl
113
- ? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
114
- : f
115
- )
116
- );
84
+ try {
85
+ // Get files that need to be uploaded (those without uploadedId)
86
+ const filesToUpload = selectedFiles.filter((f) => f.uploadedId === null && !f.error);
87
+ const alreadyUploadedIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
88
+
89
+ // Declare uploadedIds outside the if block so it's accessible later
90
+ let uploadedIds: string[] = [];
91
+
92
+ // If there are files to upload, upload them first
93
+ if (filesToUpload.length > 0) {
94
+ // Get session ID - ensure session exists if needed (for image-only messages)
95
+ let sessionId: string | null = null;
96
+ try {
97
+ // Use existing sessionId if available, otherwise ensure session is created
98
+ if (props.sessionId) {
99
+ sessionId = props.sessionId;
100
+ } else {
101
+ // Ensure session exists before uploading files (allows starting chat with image only)
102
+ sessionId = await props.onEnsureSession();
103
+ }
117
104
  } catch (error) {
105
+ console.error('[ChatWindowFooter] Failed to get sessionId for file upload:', error);
106
+ // Mark all files as error
118
107
  setSelectedFiles((prev) =>
119
108
  prev.map((f) =>
120
- f.previewUrl === fileDto.previewUrl
121
- ? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
109
+ filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
110
+ ? { ...f, error: 'Failed to initialize session', uploading: false }
122
111
  : f
123
112
  )
124
113
  );
114
+ setIsSending(false);
115
+ return;
125
116
  }
126
- }
127
- },
128
- [props.onEnsureSession, i18n.language]
129
- );
130
117
 
131
- const handleRemoveFile = useCallback((previewUrl: string) => {
132
- setSelectedFiles((prev) => {
133
- const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
134
- if (fileToRemove) {
135
- URL.revokeObjectURL(fileToRemove.previewUrl);
136
- }
137
- return prev.filter((f) => f.previewUrl !== previewUrl);
138
- });
139
- }, []);
118
+ // Upload each file and collect uploaded IDs
119
+ uploadedIds = [];
120
+ let hasUploadErrors = false;
140
121
 
141
- const handleSendMessageWithAttachments = useCallback(() => {
142
- // Only allow sending if all files have finished uploading (either successfully or with error)
143
- const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
144
- if (hasUploadingFiles) {
145
- return; // Prevent sending if any files are still uploading
146
- }
122
+ for (const fileDto of filesToUpload) {
123
+ try {
124
+ // Mark as uploading
125
+ setSelectedFiles((prev) =>
126
+ prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
127
+ );
147
128
 
148
- // Get all successfully uploaded file IDs
149
- const attachmentIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
129
+ // Get presigned URL
130
+ const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
150
131
 
151
- // Call the original send message with attachment IDs
152
- props.handleSendMessage(attachmentIds);
132
+ // Upload file to presigned URL using axios
133
+ const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
134
+ headers: {
135
+ 'Content-Type': fileDto.file.type,
136
+ },
137
+ onUploadProgress: () => {
138
+ // Upload progress tracking (silent)
139
+ },
140
+ });
153
141
 
154
- // Clear selected files and revoke URLs
155
- selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
156
- setSelectedFiles([]);
157
- }, [selectedFiles, props]);
142
+ if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
143
+ throw new Error(`Upload failed with status ${uploadResponse.status}`);
144
+ }
145
+
146
+ // Collect uploaded ID
147
+ uploadedIds.push(presignResponse.id);
148
+
149
+ // Update with uploaded ID
150
+ setSelectedFiles((prev) =>
151
+ prev.map((f) =>
152
+ f.previewUrl === fileDto.previewUrl
153
+ ? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
154
+ : f
155
+ )
156
+ );
157
+ } catch (error) {
158
+ console.error('[ChatWindowFooter] File upload failed:', error);
159
+ hasUploadErrors = true;
160
+ setSelectedFiles((prev) =>
161
+ prev.map((f) =>
162
+ f.previewUrl === fileDto.previewUrl
163
+ ? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
164
+ : f
165
+ )
166
+ );
167
+ }
168
+ }
169
+
170
+ // If any uploads failed, don't send the message
171
+ if (hasUploadErrors) {
172
+ console.error('[ChatWindowFooter] Some files failed to upload, not sending message');
173
+ setIsSending(false);
174
+ return;
175
+ }
176
+ }
177
+
178
+ // Get all successfully uploaded file IDs (already uploaded + newly uploaded)
179
+ // Use uploadedIds from the upload loop instead of reading from state
180
+ const allAttachmentIds = [...alreadyUploadedIds, ...uploadedIds];
181
+
182
+ // Call the original send message with attachment IDs
183
+ props.handleSendMessage(allAttachmentIds);
184
+
185
+ // Clear selected files and revoke URLs
186
+ selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
187
+ setSelectedFiles([]);
188
+ setIsSending(false);
189
+ } catch (error) {
190
+ console.error('[ChatWindowFooter] Error sending message:', error);
191
+ setIsSending(false);
192
+ }
193
+ }, [selectedFiles, props, i18n.language, isSending]);
158
194
 
159
195
  // Check if any files are currently uploading
160
196
  const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
161
197
 
162
- // Check if there are files that haven't finished (no uploadedId, no error, not uploading)
163
- // This shouldn't happen in normal flow, but we check for safety
164
- const hasPendingFiles = selectedFiles.some((f) => !f.uploading && f.uploadedId === null && f.error === null);
165
-
166
- // Check if all files have errors (no successful uploads)
167
- const hasSuccessfulUploads = selectedFiles.some((f) => f.uploadedId !== null);
168
- const allFilesHaveErrors =
169
- selectedFiles.length > 0 && !hasSuccessfulUploads && !hasUploadingFiles && !hasPendingFiles;
198
+ // Check if there are files with errors
199
+ const hasFileErrors = selectedFiles.some((f) => f.error !== null);
170
200
 
171
- const isSendDisabled =
172
- props.isLoading || props.inputMessage.trim() === '' || hasUploadingFiles || hasPendingFiles || allFilesHaveErrors;
201
+ // Allow sending if there's text OR files selected (files will be uploaded on send)
202
+ const hasContentToSend = props.inputMessage.trim() !== '' || selectedFiles.length > 0;
203
+ const isSendDisabled = props.isLoading || isSending || !hasContentToSend || hasUploadingFiles || hasFileErrors;
204
+ const showLoading = props.isLoading || isSending || hasUploadingFiles;
173
205
 
174
206
  const handleKeyDown = useCallback(
175
207
  (e: React.KeyboardEvent) => {
@@ -252,10 +284,16 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
252
284
  size='icon'
253
285
  onClick={handleSendMessageWithAttachments}
254
286
  disabled={isSendDisabled}
255
- className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 disabled:babylai-opacity-50'
287
+ className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 !babylai-p-0 babylai-flex babylai-items-center babylai-justify-center disabled:babylai-opacity-50'
256
288
  type='button'
257
289
  >
258
- <EnvelopeIcon className={`babylai-w-4 babylai-h-4 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`} />
290
+ {showLoading ? (
291
+ <div className='babylai-inline-block babylai-animate-spin babylai-rounded-full babylai-h-4 babylai-w-4 babylai-aspect-square babylai-border-2 babylai-border-white babylai-border-t-transparent babylai-box-border'></div>
292
+ ) : (
293
+ <EnvelopeIcon
294
+ className={`babylai-w-4 babylai-h-4 babylai-flex-shrink-0 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`}
295
+ />
296
+ )}
259
297
  </Button>
260
298
  </div>
261
299