@aslaluroba/help-center-react 3.0.21 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,37 +1,274 @@
1
+ import React, { useRef, useState, useCallback } from 'react';
2
+ import axios from 'axios';
1
3
  import { Button } from '@/components';
4
+ import { ImagePreviewDialog } from '@/components/ui';
2
5
  import EnvelopeIcon from './../../../assets/icons/envelope.svg';
6
+ import PaperclipIcon from './../../../assets/icons/paperclip.svg';
7
+ import XIcon from './../../../assets/icons/x.svg';
3
8
  import { useLocalTranslation } from '../../../useLocalTranslation';
9
+ import { presignUpload } from '@/core/api';
10
+
11
+ interface SelectedFileDto {
12
+ file: File;
13
+ previewUrl: string;
14
+ uploading: boolean;
15
+ uploadedId: string | null;
16
+ error: string | null;
17
+ }
4
18
 
5
19
  interface ChatWindowFooterProps {
6
20
  inputMessage: string;
7
21
  setInputMessage: (e: string) => void;
8
- handleKeyDown: (e: React.KeyboardEvent) => void;
9
- handleSendMessage: () => void;
22
+ handleSendMessage: (attachmentIds: string[]) => void;
10
23
  isLoading: boolean;
24
+ onEnsureSession: () => Promise<string>;
11
25
  }
12
26
 
13
27
  const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
14
- const { t, dir } = useLocalTranslation();
15
-
28
+ const { t, dir, i18n } = useLocalTranslation();
29
+ const fileInputRef = useRef<HTMLInputElement>(null);
30
+ const [selectedFiles, setSelectedFiles] = useState<SelectedFileDto[]>([]);
31
+ const [previewImage, setPreviewImage] = useState<string | null>(null);
32
+
33
+ const handleAttachClick = useCallback(() => {
34
+ fileInputRef.current?.click();
35
+ }, []);
36
+
37
+ const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
38
+ const files = Array.from(e.target.files || []);
39
+
40
+ // Validate that all files are images
41
+ const imageFiles = files.filter((file) => file.type.startsWith('image/'));
42
+
43
+ // Only image files are allowed
44
+
45
+ // Create preview URLs and add to selected files
46
+ const newFiles: SelectedFileDto[] = imageFiles.map((file) => ({
47
+ file,
48
+ previewUrl: URL.createObjectURL(file),
49
+ uploading: false,
50
+ uploadedId: null,
51
+ error: null,
52
+ }));
53
+
54
+ setSelectedFiles((prev) => [...prev, ...newFiles]);
55
+
56
+ // Clear the input
57
+ if (fileInputRef.current) {
58
+ fileInputRef.current.value = '';
59
+ }
60
+
61
+ // Start uploading immediately
62
+ await handleUploadFiles(newFiles);
63
+ }, []);
64
+
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;
81
+ }
82
+
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
+ );
90
+
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
+ }
108
+
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
+ );
117
+ } catch (error) {
118
+ setSelectedFiles((prev) =>
119
+ prev.map((f) =>
120
+ f.previewUrl === fileDto.previewUrl
121
+ ? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
122
+ : f
123
+ )
124
+ );
125
+ }
126
+ }
127
+ },
128
+ [props.onEnsureSession, i18n.language]
129
+ );
130
+
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
+ }, []);
140
+
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
+ }
147
+
148
+ // Get all successfully uploaded file IDs
149
+ const attachmentIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
150
+
151
+ // Call the original send message with attachment IDs
152
+ props.handleSendMessage(attachmentIds);
153
+
154
+ // Clear selected files and revoke URLs
155
+ selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
156
+ setSelectedFiles([]);
157
+ }, [selectedFiles, props]);
158
+
159
+ // Check if any files are currently uploading
160
+ const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
161
+
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;
170
+
171
+ const isSendDisabled =
172
+ props.isLoading || props.inputMessage.trim() === '' || hasUploadingFiles || hasPendingFiles || allFilesHaveErrors;
173
+
174
+ const handleKeyDown = useCallback(
175
+ (e: React.KeyboardEvent) => {
176
+ if (e.key === 'Enter' && !e.shiftKey) {
177
+ e.preventDefault();
178
+ if (!isSendDisabled) {
179
+ handleSendMessageWithAttachments();
180
+ }
181
+ }
182
+ },
183
+ [isSendDisabled, handleSendMessageWithAttachments]
184
+ );
185
+
16
186
  return (
17
- <footer className='babylai-flex babylai-items-center babylai-gap-2 babylai-relative babylai-rounded-full babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-mx-4 md:babylai-mx-6 md:babylai-py-3 md:babylai-px-4'>
18
- <input
19
- type='text'
20
- value={props.inputMessage}
21
- onChange={(e) => props.setInputMessage(e.target.value)}
22
- onKeyDown={props.handleKeyDown}
23
- placeholder={t('homeSdk.placeholder')}
24
- className='babylai-flex-1 babylai-py-2 babylai-px-4 babylai-bg-transparent babylai-outline-none babylai-text-sm dark:babylai-text-white babylai-border-none'
25
- />
26
- <Button
27
- variant='default'
28
- size='icon'
29
- onClick={props.handleSendMessage}
30
- disabled={props?.isLoading}
31
- className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 disabled:babylai-opacity-50'
32
- >
33
- <EnvelopeIcon className={`babylai-w-4 babylai-h-4 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`} />
34
- </Button>
187
+ <footer className='babylai-flex babylai-flex-col babylai-gap-2 babylai-mx-4 md:babylai-mx-6'>
188
+ {selectedFiles.length > 0 && (
189
+ <div className='babylai-flex babylai-gap-2 babylai-flex-wrap babylai-p-2 babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-rounded-lg'>
190
+ {selectedFiles.map((file) => (
191
+ <div key={file.previewUrl} className='babylai-relative babylai-group'>
192
+ <img
193
+ src={file.previewUrl}
194
+ alt='Preview'
195
+ className='babylai-w-16 babylai-h-16 babylai-object-cover babylai-rounded-lg babylai-border babylai-border-black-white-200 babylai-cursor-pointer hover:babylai-opacity-80 babylai-transition-opacity'
196
+ onClick={() => setPreviewImage(file.previewUrl)}
197
+ role='button'
198
+ aria-label='Click to preview image'
199
+ />
200
+ {file.uploading && (
201
+ <div className='babylai-absolute babylai-inset-0 babylai-flex babylai-items-center babylai-justify-center babylai-bg-black babylai-bg-opacity-50 babylai-rounded-lg'>
202
+ <div className='babylai-animate-spin babylai-rounded-full babylai-h-6 babylai-w-6 babylai-border-2 babylai-border-white babylai-border-t-transparent'></div>
203
+ </div>
204
+ )}
205
+ {file.error && (
206
+ <div className='babylai-absolute babylai-inset-0 babylai-flex babylai-items-center babylai-justify-center babylai-bg-red-500 babylai-bg-opacity-70 babylai-rounded-lg'>
207
+ <span className='babylai-text-white babylai-text-xs'>Error</span>
208
+ </div>
209
+ )}
210
+ <button
211
+ onClick={() => handleRemoveFile(file.previewUrl)}
212
+ className='babylai-absolute -babylai-top-2 -babylai-right-2 babylai-bg-red-500 babylai-text-white babylai-rounded-full babylai-w-5 babylai-h-5 babylai-flex babylai-items-center babylai-justify-center babylai-opacity-0 group-hover:babylai-opacity-100 babylai-transition-opacity'
213
+ type='button'
214
+ aria-label='Remove image'
215
+ >
216
+ <XIcon className='babylai-w-3 babylai-h-3' />
217
+ </button>
218
+ </div>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ <div className='babylai-flex babylai-items-center babylai-gap-2 babylai-relative babylai-rounded-full babylai-bg-white dark:!babylai-bg-storm-dust-900 babylai-py-1 babylai-px-2 md:babylai-py-3 md:babylai-px-4'>
224
+ <input
225
+ type='file'
226
+ ref={fileInputRef}
227
+ onChange={handleFileSelect}
228
+ accept='image/*'
229
+ multiple
230
+ className='babylai-hidden'
231
+ />
232
+ <Button
233
+ variant='ghost'
234
+ size='icon'
235
+ onClick={handleAttachClick}
236
+ disabled={props.isLoading}
237
+ className='babylai-rounded-full babylai-w-8 babylai-h-8 babylai-text-black-white-500 hover:babylai-text-primary-500 hover:babylai-bg-transparent'
238
+ type='button'
239
+ >
240
+ <PaperclipIcon className='babylai-w-4 babylai-h-4' />
241
+ </Button>
242
+ <input
243
+ type='text'
244
+ value={props.inputMessage}
245
+ onChange={(e) => props.setInputMessage(e.target.value)}
246
+ onKeyDown={handleKeyDown}
247
+ placeholder={t('homeSdk.placeholder')}
248
+ className='babylai-flex-1 babylai-py-2 babylai-px-2 babylai-bg-transparent babylai-outline-none babylai-text-sm dark:babylai-text-white babylai-border-none'
249
+ />
250
+ <Button
251
+ variant='default'
252
+ size='icon'
253
+ onClick={handleSendMessageWithAttachments}
254
+ 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'
256
+ type='button'
257
+ >
258
+ <EnvelopeIcon className={`babylai-w-4 babylai-h-4 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`} />
259
+ </Button>
260
+ </div>
261
+
262
+ {/* Image Preview Dialog */}
263
+ {previewImage && (
264
+ <ImagePreviewDialog
265
+ imageUrls={[previewImage]}
266
+ initialIndex={0}
267
+ isOpen={!!previewImage}
268
+ onClose={() => setPreviewImage(null)}
269
+ alt='Image preview'
270
+ />
271
+ )}
35
272
  </footer>
36
273
  );
37
274
  };
@@ -1,4 +1,5 @@
1
1
  import AgentResponse from '@/components/ui/agent-response/agent-response';
2
+ import { ImageAttachment, ImagePreviewDialog } from '@/components/ui';
2
3
  import { Message } from '@/lib/types';
3
4
  import ChatWindowFooter from '@/ui/chatbot-popup/chat-window-screen/footer';
4
5
  import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
@@ -6,9 +7,12 @@ import LoadingGif from './../../../assets/animatedLogo.gif';
6
7
  import Seperator from './../../../assets/icons/seperator.svg';
7
8
  import LogoIcon from './../../../assets/logo.svg';
8
9
  import HumanIcon from './../../../assets/icons/user.svg';
10
+ import { presignDownload } from '@/core/api';
11
+ import { useLocalTranslation } from '../../../useLocalTranslation';
9
12
 
10
13
  interface ChatWindowProps {
11
- onSendMessage: (message: string) => void;
14
+ onSendMessage: (message: string, attachmentIds: string[]) => void;
15
+ onEnsureSession: () => Promise<string>;
12
16
  messages: Message[];
13
17
  assistantStatus: string;
14
18
  needsAgent: boolean;
@@ -23,17 +27,28 @@ const MessageComponent = React.memo(
23
27
  messages,
24
28
  firstHumanAgentIndex,
25
29
  onType,
30
+ onImageClick,
26
31
  }: {
27
32
  message: Message;
28
33
  index: number;
29
34
  messages: Message[];
30
35
  firstHumanAgentIndex: number;
31
36
  onType: () => void;
37
+ onImageClick: (attachmentIds: string[], clickedIndex: number) => void;
32
38
  }) => {
33
39
  const isFirstInSequence = index === 0 || messages[index - 1].senderType !== message.senderType;
34
40
  const isFirstHumanAgentMessage = index === firstHumanAgentIndex && message.senderType === 2;
35
41
  const textDirection = message.senderType === 1 ? 'babylai-justify-end' : 'babylai-justify-start';
36
42
 
43
+ const handleImageClick = useCallback(
44
+ (clickedIndex: number) => {
45
+ if (message.attachmentIds && message.attachmentIds.length > 0) {
46
+ onImageClick(message.attachmentIds, clickedIndex);
47
+ }
48
+ },
49
+ [message.attachmentIds, onImageClick]
50
+ );
51
+
37
52
  return (
38
53
  <div key={message.id}>
39
54
  {isFirstHumanAgentMessage && (
@@ -59,12 +74,26 @@ const MessageComponent = React.memo(
59
74
  )}
60
75
  {!isFirstInSequence && <div className='babylai-flex-shrink-0 babylai-me-3 babylai-w-8'></div>}
61
76
 
62
- <AgentResponse
63
- messageContent={message.messageContent}
64
- senderType={message.senderType}
65
- messageId={message.id}
66
- onType={onType}
67
- />
77
+ <div className='babylai-flex babylai-flex-col babylai-gap-2'>
78
+ {message.attachmentIds && message.attachmentIds.length > 0 && (
79
+ <div className='babylai-flex babylai-flex-row babylai-flex-wrap babylai-gap-2 babylai-max-w-full'>
80
+ {message.attachmentIds.map((attachmentId, imgIndex) => (
81
+ <ImageAttachment
82
+ key={attachmentId}
83
+ fileId={attachmentId}
84
+ enablePreview={false}
85
+ onClick={() => handleImageClick(imgIndex)}
86
+ />
87
+ ))}
88
+ </div>
89
+ )}
90
+ <AgentResponse
91
+ messageContent={message.messageContent}
92
+ senderType={message.senderType}
93
+ messageId={message.id}
94
+ onType={onType}
95
+ />
96
+ </div>
68
97
  </div>
69
98
  </div>
70
99
  );
@@ -93,111 +122,187 @@ const TypingIndicator = React.memo(({ firstHumanAgentIndex }: { firstHumanAgentI
93
122
 
94
123
  TypingIndicator.displayName = 'TypingIndicator';
95
124
 
96
- export const ChatWindow = React.memo(({ onSendMessage, messages, assistantStatus = 'loading' }: ChatWindowProps) => {
97
- const [inputMessage, setInputMessage] = useState('');
98
- const messagesEndRef = useRef<HTMLDivElement>(null);
99
- const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
100
- const lastMessageCountRef = useRef(messages.length);
101
-
102
- // Debounced scroll to bottom function
103
- const scrollToBottom = useCallback(() => {
104
- if (scrollTimeoutRef.current) {
105
- clearTimeout(scrollTimeoutRef.current);
106
- }
107
-
108
- scrollTimeoutRef.current = setTimeout(() => {
109
- messagesEndRef.current?.scrollIntoView({
110
- behavior: 'smooth',
111
- block: 'end',
112
- });
113
- }, 100);
114
- }, []);
115
-
116
- // Only scroll when new messages are added or status changes
117
- useEffect(() => {
118
- if (messages.length !== lastMessageCountRef.current || assistantStatus === 'typing') {
119
- lastMessageCountRef.current = messages.length;
120
- scrollToBottom();
121
- }
122
- }, [messages.length, assistantStatus, scrollToBottom]);
123
-
124
- // Cleanup timeout on unmount
125
- useEffect(() => {
126
- return () => {
125
+ export const ChatWindow = React.memo(
126
+ ({ onSendMessage, onEnsureSession, messages, assistantStatus = 'loading', needsAgent }: ChatWindowProps) => {
127
+ const { i18n } = useLocalTranslation();
128
+ const [inputMessage, setInputMessage] = useState('');
129
+ const messagesEndRef = useRef<HTMLDivElement>(null);
130
+ const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
131
+ const lastMessageCountRef = useRef(messages.length);
132
+ const [galleryState, setGalleryState] = useState<{
133
+ isOpen: boolean;
134
+ imageUrls: string[];
135
+ initialIndex: number;
136
+ }>({
137
+ isOpen: false,
138
+ imageUrls: [],
139
+ initialIndex: 0,
140
+ });
141
+
142
+ // Debounced scroll to bottom function
143
+ const scrollToBottom = useCallback(() => {
127
144
  if (scrollTimeoutRef.current) {
128
145
  clearTimeout(scrollTimeoutRef.current);
129
146
  }
130
- };
131
- }, []);
132
-
133
- const handleSendMessage = useCallback(() => {
134
- if (inputMessage.trim()) {
135
- onSendMessage(inputMessage);
136
- setInputMessage('');
137
- }
138
- }, [inputMessage, onSendMessage]);
139
-
140
- const handleKeyDown = useCallback(
141
- (e: React.KeyboardEvent) => {
142
- if (e.key === 'Enter' && !e.shiftKey) {
143
- e.preventDefault();
144
- if (inputMessage.trim() && assistantStatus !== 'typing') {
145
- onSendMessage(inputMessage);
147
+
148
+ scrollTimeoutRef.current = setTimeout(() => {
149
+ messagesEndRef.current?.scrollIntoView({
150
+ behavior: 'smooth',
151
+ block: 'end',
152
+ });
153
+ }, 100);
154
+ }, []);
155
+
156
+ // Only scroll when new messages are added or status changes
157
+ useEffect(() => {
158
+ if (messages.length !== lastMessageCountRef.current || assistantStatus === 'typing') {
159
+ lastMessageCountRef.current = messages.length;
160
+ scrollToBottom();
161
+ }
162
+ }, [messages.length, assistantStatus, scrollToBottom]);
163
+
164
+ // Cleanup timeout on unmount
165
+ useEffect(() => {
166
+ return () => {
167
+ if (scrollTimeoutRef.current) {
168
+ clearTimeout(scrollTimeoutRef.current);
169
+ }
170
+ };
171
+ }, []);
172
+
173
+ const handleSendMessage = useCallback(
174
+ (attachmentIds: string[]) => {
175
+ if (inputMessage.trim()) {
176
+ onSendMessage(inputMessage, attachmentIds);
146
177
  setInputMessage('');
147
178
  }
179
+ },
180
+ [inputMessage, onSendMessage]
181
+ );
182
+
183
+ // Memoize the first human agent index calculation
184
+ const firstHumanAgentIndex = useMemo(() => {
185
+ return messages.findIndex((message) => message.senderType === 2);
186
+ }, [messages]);
187
+
188
+ // Handle image gallery opening
189
+ const handleImageClick = useCallback(
190
+ async (attachmentIds: string[], clickedIndex: number) => {
191
+ if (!attachmentIds || attachmentIds.length === 0) {
192
+ return;
193
+ }
194
+
195
+ try {
196
+ // Fetch all image URLs with comprehensive error handling
197
+ const imageUrlPromises = attachmentIds.map((fileId) => {
198
+ if (!fileId || typeof fileId !== 'string') {
199
+ return Promise.resolve(null);
200
+ }
201
+
202
+ return presignDownload(fileId, i18n.language)
203
+ .then((response) => {
204
+ if (!response || !response.downloadUrl) {
205
+ return null;
206
+ }
207
+ return response.downloadUrl;
208
+ })
209
+ .catch(() => {
210
+ // Return null for failed downloads so we can filter them out
211
+ return null;
212
+ });
213
+ });
214
+
215
+ const imageUrls = (await Promise.all(imageUrlPromises)).filter(
216
+ (url): url is string => url !== null && url.length > 0
217
+ );
218
+
219
+ if (imageUrls.length === 0) {
220
+ return;
221
+ }
222
+
223
+ // Adjust the initial index if some images failed to load
224
+ let adjustedIndex = Math.max(0, Math.min(clickedIndex, imageUrls.length - 1));
225
+
226
+ setGalleryState({
227
+ isOpen: true,
228
+ imageUrls,
229
+ initialIndex: adjustedIndex,
230
+ });
231
+ } catch (error) {
232
+ // Handle unexpected errors silently
233
+ }
234
+ },
235
+ [i18n.language]
236
+ );
237
+
238
+ const handleCloseGallery = useCallback(() => {
239
+ setGalleryState({
240
+ isOpen: false,
241
+ imageUrls: [],
242
+ initialIndex: 0,
243
+ });
244
+ }, []);
245
+
246
+ // Memoize loading state check
247
+ // When a human agent has joined, don't disable based on assistantStatus
248
+ const hasHumanAgent = useMemo(() => {
249
+ return needsAgent || messages.some((msg) => msg.senderType === 2);
250
+ }, [needsAgent, messages]);
251
+
252
+ const isLoading = useMemo(() => {
253
+ // If human agent has joined, don't disable file uploads based on assistantStatus
254
+ if (hasHumanAgent) {
255
+ return false;
148
256
  }
149
- },
150
- [inputMessage, onSendMessage, assistantStatus]
151
- );
257
+ return assistantStatus === 'typing' || assistantStatus === 'loading' || assistantStatus === 'error';
258
+ }, [assistantStatus, hasHumanAgent]);
259
+
260
+ // Memoize the message list to prevent unnecessary re-renders
261
+ const messagesListWithGallery = useMemo(() => {
262
+ return messages.map((message, index) => (
263
+ <MessageComponent
264
+ key={`${message.id}-${index}`}
265
+ message={message}
266
+ index={index}
267
+ messages={messages}
268
+ firstHumanAgentIndex={firstHumanAgentIndex}
269
+ onType={scrollToBottom}
270
+ onImageClick={handleImageClick}
271
+ />
272
+ ));
273
+ }, [messages, firstHumanAgentIndex, scrollToBottom, handleImageClick]);
152
274
 
153
- // Memoize the first human agent index calculation
154
- const firstHumanAgentIndex = useMemo(() => {
155
- return messages.findIndex((message) => message.senderType === 2);
156
- }, [messages]);
157
-
158
- // Memoize the message list to prevent unnecessary re-renders
159
- const messagesList = useMemo(() => {
160
- return messages.map((message, index) => (
161
- <MessageComponent
162
- key={`${message.id}-${index}`}
163
- message={message}
164
- index={index}
165
- messages={messages}
166
- firstHumanAgentIndex={firstHumanAgentIndex}
167
- onType={scrollToBottom}
168
- />
169
- ));
170
- }, [messages, firstHumanAgentIndex, scrollToBottom]);
171
-
172
- // Memoize loading state check
173
- const isLoading = useMemo(() => {
174
275
  return (
175
- assistantStatus === 'typing' ||
176
- assistantStatus === 'loading' ||
177
- assistantStatus === 'error' ||
178
- inputMessage.trim() === ''
179
- );
180
- }, [assistantStatus, inputMessage]);
276
+ <>
277
+ <div className='babylai-flex-1 babylai-overflow-y-auto babylai-p-4 babylai-h-full'>
278
+ {messagesListWithGallery}
181
279
 
182
- return (
183
- <>
184
- <div className='babylai-flex-1 babylai-overflow-y-auto babylai-p-4 babylai-h-full'>
185
- {messagesList}
280
+ {assistantStatus === 'typing' && <TypingIndicator firstHumanAgentIndex={firstHumanAgentIndex} />}
186
281
 
187
- {assistantStatus === 'typing' && <TypingIndicator firstHumanAgentIndex={firstHumanAgentIndex} />}
282
+ <div ref={messagesEndRef} />
283
+ </div>
188
284
 
189
- <div ref={messagesEndRef} />
190
- </div>
285
+ <ChatWindowFooter
286
+ inputMessage={inputMessage}
287
+ handleSendMessage={handleSendMessage}
288
+ setInputMessage={setInputMessage}
289
+ isLoading={isLoading}
290
+ onEnsureSession={onEnsureSession}
291
+ />
191
292
 
192
- <ChatWindowFooter
193
- inputMessage={inputMessage}
194
- handleKeyDown={handleKeyDown}
195
- handleSendMessage={handleSendMessage}
196
- setInputMessage={setInputMessage}
197
- isLoading={isLoading}
198
- />
199
- </>
200
- );
201
- });
293
+ {/* Gallery Preview Dialog */}
294
+ {galleryState.isOpen && galleryState.imageUrls.length > 0 && (
295
+ <ImagePreviewDialog
296
+ imageUrls={galleryState.imageUrls}
297
+ initialIndex={galleryState.initialIndex}
298
+ isOpen={galleryState.isOpen}
299
+ onClose={handleCloseGallery}
300
+ alt='Image gallery preview'
301
+ />
302
+ )}
303
+ </>
304
+ );
305
+ }
306
+ );
202
307
 
203
308
  ChatWindow.displayName = 'ChatWindow';