@@ -94,6 +94,9 @@ interface ProcessedAttachment {
9494 dataUrl : string
9595}
9696
97+ /** Timeout for FileReader operations in milliseconds */
98+ const FILE_READ_TIMEOUT_MS = 60000
99+
97100/**
98101 * Reads files and converts them to data URLs for image display
99102 * @param chatFiles - Array of chat files to process
@@ -107,8 +110,37 @@ const processFileAttachments = async (chatFiles: ChatFile[]): Promise<ProcessedA
107110 try {
108111 dataUrl = await new Promise < string > ( ( resolve , reject ) => {
109112 const reader = new FileReader ( )
110- reader . onload = ( ) => resolve ( reader . result as string )
111- reader . onerror = reject
113+ let settled = false
114+
115+ const timeoutId = setTimeout ( ( ) => {
116+ if ( ! settled ) {
117+ settled = true
118+ reader . abort ( )
119+ reject ( new Error ( `File read timed out after ${ FILE_READ_TIMEOUT_MS } ms` ) )
120+ }
121+ } , FILE_READ_TIMEOUT_MS )
122+
123+ reader . onload = ( ) => {
124+ if ( ! settled ) {
125+ settled = true
126+ clearTimeout ( timeoutId )
127+ resolve ( reader . result as string )
128+ }
129+ }
130+ reader . onerror = ( ) => {
131+ if ( ! settled ) {
132+ settled = true
133+ clearTimeout ( timeoutId )
134+ reject ( reader . error )
135+ }
136+ }
137+ reader . onabort = ( ) => {
138+ if ( ! settled ) {
139+ settled = true
140+ clearTimeout ( timeoutId )
141+ reject ( new Error ( 'File read aborted' ) )
142+ }
143+ }
112144 reader . readAsDataURL ( file . file )
113145 } )
114146 } catch ( error ) {
@@ -202,7 +234,6 @@ export function Chat() {
202234 const triggerWorkflowUpdate = useWorkflowStore ( ( state ) => state . triggerUpdate )
203235 const setSubBlockValue = useSubBlockStore ( ( state ) => state . setValue )
204236
205- // Chat state (UI and messages from unified store)
206237 const {
207238 isChatOpen,
208239 chatPosition,
@@ -230,19 +261,16 @@ export function Chat() {
230261 const { data : session } = useSession ( )
231262 const { addToQueue } = useOperationQueue ( )
232263
233- // Local state
234264 const [ chatMessage , setChatMessage ] = useState ( '' )
235265 const [ promptHistory , setPromptHistory ] = useState < string [ ] > ( [ ] )
236266 const [ historyIndex , setHistoryIndex ] = useState ( - 1 )
237267 const [ moreMenuOpen , setMoreMenuOpen ] = useState ( false )
238268
239- // Refs
240269 const inputRef = useRef < HTMLInputElement > ( null )
241270 const timeoutRef = useRef < NodeJS . Timeout | null > ( null )
242271 const streamReaderRef = useRef < ReadableStreamDefaultReader < Uint8Array > | null > ( null )
243272 const preventZoomRef = usePreventZoom ( )
244273
245- // File upload hook
246274 const {
247275 chatFiles,
248276 uploadErrors,
@@ -257,6 +285,38 @@ export function Chat() {
257285 handleDrop,
258286 } = useChatFileUpload ( )
259287
288+ const filePreviewUrls = useRef < Map < string , string > > ( new Map ( ) )
289+
290+ const getFilePreviewUrl = useCallback ( ( file : ChatFile ) : string | null => {
291+ if ( ! file . type . startsWith ( 'image/' ) ) return null
292+
293+ const existing = filePreviewUrls . current . get ( file . id )
294+ if ( existing ) return existing
295+
296+ const url = URL . createObjectURL ( file . file )
297+ filePreviewUrls . current . set ( file . id , url )
298+ return url
299+ } , [ ] )
300+
301+ useEffect ( ( ) => {
302+ const currentFileIds = new Set ( chatFiles . map ( ( f ) => f . id ) )
303+ const urlMap = filePreviewUrls . current
304+
305+ for ( const [ fileId , url ] of urlMap . entries ( ) ) {
306+ if ( ! currentFileIds . has ( fileId ) ) {
307+ URL . revokeObjectURL ( url )
308+ urlMap . delete ( fileId )
309+ }
310+ }
311+
312+ return ( ) => {
313+ for ( const url of urlMap . values ( ) ) {
314+ URL . revokeObjectURL ( url )
315+ }
316+ urlMap . clear ( )
317+ }
318+ } , [ chatFiles ] )
319+
260320 /**
261321 * Resolves the unified start block for chat execution, if available.
262322 */
@@ -322,21 +382,18 @@ export function Chat() {
322382 const shouldShowConfigureStartInputsButton =
323383 Boolean ( startBlockId ) && missingStartReservedFields . length > 0
324384
325- // Get actual position (default if not set)
326385 const actualPosition = useMemo (
327386 ( ) => getChatPosition ( chatPosition , chatWidth , chatHeight ) ,
328387 [ chatPosition , chatWidth , chatHeight ]
329388 )
330389
331- // Drag hook
332390 const { handleMouseDown } = useFloatDrag ( {
333391 position : actualPosition ,
334392 width : chatWidth ,
335393 height : chatHeight ,
336394 onPositionChange : setChatPosition ,
337395 } )
338396
339- // Boundary sync hook - keeps chat within bounds when layout changes
340397 useFloatBoundarySync ( {
341398 isOpen : isChatOpen ,
342399 position : actualPosition ,
@@ -345,7 +402,6 @@ export function Chat() {
345402 onPositionChange : setChatPosition ,
346403 } )
347404
348- // Resize hook - enables resizing from all edges and corners
349405 const {
350406 cursor : resizeCursor ,
351407 handleMouseMove : handleResizeMouseMove ,
@@ -359,37 +415,30 @@ export function Chat() {
359415 onDimensionsChange : setChatDimensions ,
360416 } )
361417
362- // Get output entries from console
363418 const outputEntries = useMemo ( ( ) => {
364419 if ( ! activeWorkflowId ) return [ ]
365420 return entries . filter ( ( entry ) => entry . workflowId === activeWorkflowId && entry . output )
366421 } , [ entries , activeWorkflowId ] )
367422
368- // Get filtered messages for current workflow
369423 const workflowMessages = useMemo ( ( ) => {
370424 if ( ! activeWorkflowId ) return [ ]
371425 return messages
372426 . filter ( ( msg ) => msg . workflowId === activeWorkflowId )
373427 . sort ( ( a , b ) => new Date ( a . timestamp ) . getTime ( ) - new Date ( b . timestamp ) . getTime ( ) )
374428 } , [ messages , activeWorkflowId ] )
375429
376- // Check if any message is currently streaming
377430 const isStreaming = useMemo ( ( ) => {
378- // Match copilot semantics: only treat as streaming if the LAST message is streaming
379431 const lastMessage = workflowMessages [ workflowMessages . length - 1 ]
380432 return Boolean ( lastMessage ?. isStreaming )
381433 } , [ workflowMessages ] )
382434
383- // Map chat messages to copilot message format (type -> role) for scroll hook
384435 const messagesForScrollHook = useMemo ( ( ) => {
385436 return workflowMessages . map ( ( msg ) => ( {
386437 ...msg ,
387438 role : msg . type ,
388439 } ) )
389440 } , [ workflowMessages ] )
390441
391- // Scroll management hook - reuse copilot's implementation
392- // Use immediate scroll behavior to keep the view pinned to the bottom during streaming
393442 const { scrollAreaRef, scrollToBottom } = useScrollManagement (
394443 messagesForScrollHook ,
395444 isStreaming ,
@@ -398,15 +447,13 @@ export function Chat() {
398447 }
399448 )
400449
401- // Memoize user messages for performance
402450 const userMessages = useMemo ( ( ) => {
403451 return workflowMessages
404452 . filter ( ( msg ) => msg . type === 'user' )
405453 . map ( ( msg ) => msg . content )
406454 . filter ( ( content ) : content is string => typeof content === 'string' )
407455 } , [ workflowMessages ] )
408456
409- // Update prompt history when workflow changes
410457 useEffect ( ( ) => {
411458 if ( ! activeWorkflowId ) {
412459 setPromptHistory ( [ ] )
@@ -419,15 +466,14 @@ export function Chat() {
419466 } , [ activeWorkflowId , userMessages ] )
420467
421468 /**
422- * Auto-scroll to bottom when messages load
469+ * Auto-scroll to bottom when messages load and chat is open
423470 */
424471 useEffect ( ( ) => {
425472 if ( workflowMessages . length > 0 && isChatOpen ) {
426473 scrollToBottom ( )
427474 }
428475 } , [ workflowMessages . length , scrollToBottom , isChatOpen ] )
429476
430- // Get selected workflow outputs (deduplicated)
431477 const selectedOutputs = useMemo ( ( ) => {
432478 if ( ! activeWorkflowId ) return [ ]
433479 const selected = selectedWorkflowOutputs [ activeWorkflowId ]
@@ -448,15 +494,13 @@ export function Chat() {
448494 } , delay )
449495 } , [ ] )
450496
451- // Cleanup on unmount
452497 useEffect ( ( ) => {
453498 return ( ) => {
454499 timeoutRef . current && clearTimeout ( timeoutRef . current )
455500 streamReaderRef . current ?. cancel ( )
456501 }
457502 } , [ ] )
458503
459- // React to execution cancellation from run button
460504 useEffect ( ( ) => {
461505 if ( ! isExecuting && isStreaming ) {
462506 const lastMessage = workflowMessages [ workflowMessages . length - 1 ]
@@ -500,7 +544,6 @@ export function Chat() {
500544 const chunk = decoder . decode ( value , { stream : true } )
501545 buffer += chunk
502546
503- // Process only complete SSE messages; keep any partial trailing data in buffer
504547 const separatorIndex = buffer . lastIndexOf ( '\n\n' )
505548 if ( separatorIndex === - 1 ) {
506549 continue
@@ -550,7 +593,6 @@ export function Chat() {
550593 }
551594 finalizeMessageStream ( responseMessageId )
552595 } finally {
553- // Only clear ref if it's still our reader (prevents clobbering a new stream)
554596 if ( streamReaderRef . current === reader ) {
555597 streamReaderRef . current = null
556598 }
@@ -979,8 +1021,7 @@ export function Chat() {
9791021 { chatFiles . length > 0 && (
9801022 < div className = 'mt-[4px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' >
9811023 { chatFiles . map ( ( file ) => {
982- const isImage = file . type . startsWith ( 'image/' )
983- const previewUrl = isImage ? URL . createObjectURL ( file . file ) : null
1024+ const previewUrl = getFilePreviewUrl ( file )
9841025
9851026 return (
9861027 < div
@@ -997,7 +1038,6 @@ export function Chat() {
9971038 src = { previewUrl }
9981039 alt = { file . name }
9991040 className = 'h-full w-full object-cover'
1000- onLoad = { ( ) => URL . revokeObjectURL ( previewUrl ) }
10011041 />
10021042 ) : (
10031043 < div className = 'min-w-0 flex-1' >
0 commit comments