Skip to content

Commit 87e6057

Browse files
authored
improvement(chat): partialize chat store to only persist image URL instead of full image in floating chat (#2842)
1 parent f1796d1 commit 87e6057

File tree

4 files changed

+101
-63
lines changed

4 files changed

+101
-63
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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'>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,17 @@ export function ChatMessage({ message }: ChatMessageProps) {
113113
{message.attachments && message.attachments.length > 0 && (
114114
<div className='mb-2 flex flex-wrap gap-[6px]'>
115115
{message.attachments.map((attachment) => {
116-
const isImage = attachment.type.startsWith('image/')
117116
const hasValidDataUrl =
118117
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
118+
// Only treat as displayable image if we have both image type AND valid data URL
119+
const canDisplayAsImage = attachment.type.startsWith('image/') && hasValidDataUrl
119120

120121
return (
121122
<div
122123
key={attachment.id}
123124
className={`group relative flex-shrink-0 overflow-hidden rounded-[6px] bg-[var(--surface-2)] ${
124125
hasValidDataUrl ? 'cursor-pointer' : ''
125-
} ${isImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
126+
} ${canDisplayAsImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
126127
onClick={(e) => {
127128
if (hasValidDataUrl) {
128129
e.preventDefault()
@@ -131,7 +132,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
131132
}
132133
}}
133134
>
134-
{isImage && hasValidDataUrl ? (
135+
{canDisplayAsImage ? (
135136
<img
136137
src={attachment.dataUrl}
137138
alt={attachment.name}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ export function useChatFileUpload() {
2424

2525
/**
2626
* Validate and add files
27+
* Uses functional state update to avoid stale closure issues with rapid file additions
2728
*/
28-
const addFiles = useCallback(
29-
(files: File[]) => {
30-
const remainingSlots = Math.max(0, MAX_FILES - chatFiles.length)
29+
const addFiles = useCallback((files: File[]) => {
30+
setChatFiles((currentFiles) => {
31+
const remainingSlots = Math.max(0, MAX_FILES - currentFiles.length)
3132
const candidateFiles = files.slice(0, remainingSlots)
3233
const errors: string[] = []
3334
const validNewFiles: ChatFile[] = []
@@ -39,11 +40,14 @@ export function useChatFileUpload() {
3940
continue
4041
}
4142

42-
// Check for duplicates
43-
const isDuplicate = chatFiles.some(
43+
// Check for duplicates against current files and newly added valid files
44+
const isDuplicateInCurrent = currentFiles.some(
4445
(existingFile) => existingFile.name === file.name && existingFile.size === file.size
4546
)
46-
if (isDuplicate) {
47+
const isDuplicateInNew = validNewFiles.some(
48+
(newFile) => newFile.name === file.name && newFile.size === file.size
49+
)
50+
if (isDuplicateInCurrent || isDuplicateInNew) {
4751
errors.push(`${file.name} already added`)
4852
continue
4953
}
@@ -57,20 +61,20 @@ export function useChatFileUpload() {
5761
})
5862
}
5963

64+
// Update errors outside the state setter to avoid nested state updates
6065
if (errors.length > 0) {
61-
setUploadErrors(errors)
66+
// Use setTimeout to avoid state update during render
67+
setTimeout(() => setUploadErrors(errors), 0)
68+
} else if (validNewFiles.length > 0) {
69+
setTimeout(() => setUploadErrors([]), 0)
6270
}
6371

6472
if (validNewFiles.length > 0) {
65-
setChatFiles([...chatFiles, ...validNewFiles])
66-
// Clear errors when files are successfully added
67-
if (errors.length === 0) {
68-
setUploadErrors([])
69-
}
73+
return [...currentFiles, ...validNewFiles]
7074
}
71-
},
72-
[chatFiles]
73-
)
75+
return currentFiles
76+
})
77+
}, [])
7478

7579
/**
7680
* Remove a file

0 commit comments

Comments
 (0)