1- import { useState , useRef , useEffect } from 'react' ;
2- import { MessageCircle , Send , RotateCcw , Quote , Map , List , HelpCircle , Paperclip , X , Bookmark , Mic , MicOff } from 'lucide-react' ;
1+ import React , { useState , useRef , useEffect } from 'react' ;
2+ import { MessageCircle , Send , RotateCcw , Quote , Map , List , HelpCircle , Paperclip , X , Bookmark , Mic , MicOff , AlertCircle } from 'lucide-react' ;
33import { Button } from '@/components/ui/button' ;
44import { Input } from '@/components/ui/input' ;
55import { Card , CardContent , CardDescription , CardHeader , CardTitle } from '@/components/ui/card' ;
@@ -11,6 +11,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
1111import type { AskOutput } from '@/lib/ai/schemas/ask' ;
1212import { cn } from '@/lib/utils' ;
1313import { useSpeechToText } from '@/hooks/use-speech-to-text' ;
14+ import { useToast } from '@/hooks/use-toast' ;
15+ import { validateFileUpload , MAX_FILE_COUNT , formatFileSize } from '@/lib/validation/file' ;
1416
1517interface Message {
1618 id : string ;
@@ -35,7 +37,15 @@ const modeOptions = [
3537 { id : 'argumentmap' , label : 'Arg Map' , icon : Map } ,
3638] ;
3739
38- function MessageBubble ( { message, onSave } : { message : Message , onSave ?: ( title : string , content : string ) => void } ) {
40+ const LOADING_MESSAGES = [
41+ 'Thinking...' ,
42+ 'Researching...' ,
43+ 'Analyzing...' ,
44+ 'Generating response...' ,
45+ 'Almost there...' ,
46+ ] ;
47+
48+ const MessageBubble = React . memo ( function MessageBubble ( { message, onSave } : { message : Message , onSave ?: ( title : string , content : string ) => void } ) {
3949 const isUser = message . role === 'user' ;
4050 const output = message . output ;
4151
@@ -61,6 +71,7 @@ function MessageBubble({ message, onSave }: { message: Message, onSave?: (title:
6171 onSave ( title , message . content ) ;
6272 } }
6373 title = "Save to Library"
74+ aria-label = "Save message to library"
6475 >
6576 < Bookmark className = "h-4 w-4" />
6677 </ Button >
@@ -114,17 +125,31 @@ function MessageBubble({ message, onSave }: { message: Message, onSave?: (title:
114125 </ div >
115126 </ div >
116127 ) ;
117- }
128+ } ) ;
118129
119130export function AskChat ( { messages, isLoading, onSend, onClear, onSaveSource } : AskChatProps ) {
120131 const [ input , setInput ] = useState ( '' ) ;
121132 const [ mode , setMode ] = useState ( 'general' ) ;
122133 const [ citationMode , setCitationMode ] = useState ( false ) ;
123134 const [ files , setFiles ] = useState < File [ ] > ( [ ] ) ;
135+ const [ loadingMessageIndex , setLoadingMessageIndex ] = useState ( 0 ) ;
124136 const fileInputRef = useRef < HTMLInputElement > ( null ) ;
125-
137+ const { toast } = useToast ( ) ;
138+
126139 const { isListening, transcript, startListening, stopListening, resetTranscript, hasSupport } = useSpeechToText ( ) ;
127140
141+ // Cycle through loading messages
142+ useEffect ( ( ) => {
143+ if ( ! isLoading ) {
144+ setLoadingMessageIndex ( 0 ) ;
145+ return ;
146+ }
147+ const interval = setInterval ( ( ) => {
148+ setLoadingMessageIndex ( prev => ( prev + 1 ) % LOADING_MESSAGES . length ) ;
149+ } , 3000 ) ;
150+ return ( ) => clearInterval ( interval ) ;
151+ } , [ isLoading ] ) ;
152+
128153 useEffect ( ( ) => {
129154 if ( transcript ) {
130155 setInput ( transcript ) ;
@@ -142,7 +167,35 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
142167
143168 const handleFileChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
144169 if ( e . target . files ) {
145- setFiles ( prev => [ ...prev , ...Array . from ( e . target . files ! ) ] ) ;
170+ const newFiles = Array . from ( e . target . files ) ;
171+ const allFiles = [ ...files , ...newFiles ] ;
172+
173+ // Validate total file count
174+ if ( allFiles . length > MAX_FILE_COUNT ) {
175+ toast ( {
176+ title : 'Too many files' ,
177+ description : `You can only upload up to ${ MAX_FILE_COUNT } files at once.` ,
178+ variant : 'destructive' ,
179+ } ) ;
180+ return ;
181+ }
182+
183+ // Validate each file
184+ const result = validateFileUpload ( allFiles ) ;
185+ if ( ! result . valid ) {
186+ toast ( {
187+ title : 'File validation failed' ,
188+ description : result . error ,
189+ variant : 'destructive' ,
190+ } ) ;
191+ return ;
192+ }
193+
194+ setFiles ( allFiles ) ;
195+ }
196+ // Reset input so same file can be selected again
197+ if ( e . target ) {
198+ e . target . value = '' ;
146199 }
147200 } ;
148201
@@ -196,9 +249,14 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
196249 < MessageBubble key = { message . id } message = { message } onSave = { onSaveSource } />
197250 ) ) }
198251 { isLoading && (
199- < div className = "flex gap-3" >
252+ < div className = "flex gap-3" role = "status" aria-live = "polite" aria-atomic = "true" >
200253 < div className = "bg-card border border-border rounded-2xl rounded-bl-sm px-4 py-3" >
201- < LoadingSpinner size = "sm" />
254+ < div className = "flex items-center gap-2" >
255+ < LoadingSpinner size = "sm" />
256+ < span className = "text-sm text-muted-foreground animate-pulse" >
257+ { LOADING_MESSAGES [ loadingMessageIndex ] }
258+ </ span >
259+ </ div >
202260 </ div >
203261 </ div >
204262 ) }
@@ -236,11 +294,15 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
236294
237295 { files . length > 0 && (
238296 < div className = "flex flex-wrap gap-2" >
239- { files . map ( ( file , i ) => (
240- < div key = { i } className = "flex items-center gap-1 bg-muted px-2 py-1 rounded-md text-xs" >
297+ { files . map ( ( file ) => (
298+ < div key = { file . name } className = "flex items-center gap-1 bg-muted px-2 py-1 rounded-md text-xs" >
241299 < Paperclip className = "h-3 w-3" />
242300 < span className = "max-w-[100px] truncate" > { file . name } </ span >
243- < button onClick = { ( ) => removeFile ( i ) } className = "text-muted-foreground hover:text-foreground" >
301+ < button
302+ onClick = { ( ) => removeFile ( files . indexOf ( file ) ) }
303+ className = "text-muted-foreground hover:text-foreground"
304+ aria-label = { `Remove ${ file . name } ` }
305+ >
244306 < X className = "h-3 w-3" />
245307 </ button >
246308 </ div >
@@ -262,17 +324,19 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
262324 size = "icon"
263325 onClick = { ( ) => fileInputRef . current ?. click ( ) }
264326 title = "Attach files"
327+ aria-label = "Attach files"
265328 >
266329 < Paperclip className = "h-4 w-4" />
267330 </ Button >
268-
331+
269332 { hasSupport && (
270333 < Button
271334 type = "button"
272335 variant = { isListening ? "destructive" : "outline" }
273336 size = "icon"
274337 onClick = { toggleListening }
275338 title = { isListening ? "Stop listening" : "Start voice input" }
339+ aria-label = { isListening ? "Stop listening" : "Start voice input" }
276340 className = { isListening ? "animate-pulse" : "" }
277341 >
278342 { isListening ? < MicOff className = "h-4 w-4" /> : < Mic className = "h-4 w-4" /> }
@@ -286,7 +350,7 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
286350 disabled = { isLoading }
287351 className = "flex-1"
288352 />
289- < Button type = "submit" disabled = { ( ! input . trim ( ) && files . length === 0 ) || isLoading } >
353+ < Button type = "submit" disabled = { ( ! input . trim ( ) && files . length === 0 ) || isLoading } aria-label = "Send message" >
290354 < Send className = "h-4 w-4" />
291355 </ Button >
292356 </ form >
0 commit comments