diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index cc1bf0403a..524e3c02ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -94,6 +94,9 @@ interface ProcessedAttachment { dataUrl: string } +/** Timeout for FileReader operations in milliseconds */ +const FILE_READ_TIMEOUT_MS = 60000 + /** * Reads files and converts them to data URLs for image display * @param chatFiles - Array of chat files to process @@ -107,8 +110,37 @@ const processFileAttachments = async (chatFiles: ChatFile[]): Promise((resolve, reject) => { const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject + let settled = false + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true + reader.abort() + reject(new Error(`File read timed out after ${FILE_READ_TIMEOUT_MS}ms`)) + } + }, FILE_READ_TIMEOUT_MS) + + reader.onload = () => { + if (!settled) { + settled = true + clearTimeout(timeoutId) + resolve(reader.result as string) + } + } + reader.onerror = () => { + if (!settled) { + settled = true + clearTimeout(timeoutId) + reject(reader.error) + } + } + reader.onabort = () => { + if (!settled) { + settled = true + clearTimeout(timeoutId) + reject(new Error('File read aborted')) + } + } reader.readAsDataURL(file.file) }) } catch (error) { @@ -202,7 +234,6 @@ export function Chat() { const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate) const setSubBlockValue = useSubBlockStore((state) => state.setValue) - // Chat state (UI and messages from unified store) const { isChatOpen, chatPosition, @@ -230,19 +261,16 @@ export function Chat() { const { data: session } = useSession() const { addToQueue } = useOperationQueue() - // Local state const [chatMessage, setChatMessage] = useState('') const [promptHistory, setPromptHistory] = useState([]) const [historyIndex, setHistoryIndex] = useState(-1) const [moreMenuOpen, setMoreMenuOpen] = useState(false) - // Refs const inputRef = useRef(null) const timeoutRef = useRef(null) const streamReaderRef = useRef | null>(null) const preventZoomRef = usePreventZoom() - // File upload hook const { chatFiles, uploadErrors, @@ -257,6 +285,38 @@ export function Chat() { handleDrop, } = useChatFileUpload() + const filePreviewUrls = useRef>(new Map()) + + const getFilePreviewUrl = useCallback((file: ChatFile): string | null => { + if (!file.type.startsWith('image/')) return null + + const existing = filePreviewUrls.current.get(file.id) + if (existing) return existing + + const url = URL.createObjectURL(file.file) + filePreviewUrls.current.set(file.id, url) + return url + }, []) + + useEffect(() => { + const currentFileIds = new Set(chatFiles.map((f) => f.id)) + const urlMap = filePreviewUrls.current + + for (const [fileId, url] of urlMap.entries()) { + if (!currentFileIds.has(fileId)) { + URL.revokeObjectURL(url) + urlMap.delete(fileId) + } + } + + return () => { + for (const url of urlMap.values()) { + URL.revokeObjectURL(url) + } + urlMap.clear() + } + }, [chatFiles]) + /** * Resolves the unified start block for chat execution, if available. */ @@ -322,13 +382,11 @@ export function Chat() { const shouldShowConfigureStartInputsButton = Boolean(startBlockId) && missingStartReservedFields.length > 0 - // Get actual position (default if not set) const actualPosition = useMemo( () => getChatPosition(chatPosition, chatWidth, chatHeight), [chatPosition, chatWidth, chatHeight] ) - // Drag hook const { handleMouseDown } = useFloatDrag({ position: actualPosition, width: chatWidth, @@ -336,7 +394,6 @@ export function Chat() { onPositionChange: setChatPosition, }) - // Boundary sync hook - keeps chat within bounds when layout changes useFloatBoundarySync({ isOpen: isChatOpen, position: actualPosition, @@ -345,7 +402,6 @@ export function Chat() { onPositionChange: setChatPosition, }) - // Resize hook - enables resizing from all edges and corners const { cursor: resizeCursor, handleMouseMove: handleResizeMouseMove, @@ -359,13 +415,11 @@ export function Chat() { onDimensionsChange: setChatDimensions, }) - // Get output entries from console const outputEntries = useMemo(() => { if (!activeWorkflowId) return [] return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output) }, [entries, activeWorkflowId]) - // Get filtered messages for current workflow const workflowMessages = useMemo(() => { if (!activeWorkflowId) return [] return messages @@ -373,14 +427,11 @@ export function Chat() { .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) }, [messages, activeWorkflowId]) - // Check if any message is currently streaming const isStreaming = useMemo(() => { - // Match copilot semantics: only treat as streaming if the LAST message is streaming const lastMessage = workflowMessages[workflowMessages.length - 1] return Boolean(lastMessage?.isStreaming) }, [workflowMessages]) - // Map chat messages to copilot message format (type -> role) for scroll hook const messagesForScrollHook = useMemo(() => { return workflowMessages.map((msg) => ({ ...msg, @@ -388,8 +439,6 @@ export function Chat() { })) }, [workflowMessages]) - // Scroll management hook - reuse copilot's implementation - // Use immediate scroll behavior to keep the view pinned to the bottom during streaming const { scrollAreaRef, scrollToBottom } = useScrollManagement( messagesForScrollHook, isStreaming, @@ -398,7 +447,6 @@ export function Chat() { } ) - // Memoize user messages for performance const userMessages = useMemo(() => { return workflowMessages .filter((msg) => msg.type === 'user') @@ -406,7 +454,6 @@ export function Chat() { .filter((content): content is string => typeof content === 'string') }, [workflowMessages]) - // Update prompt history when workflow changes useEffect(() => { if (!activeWorkflowId) { setPromptHistory([]) @@ -419,7 +466,7 @@ export function Chat() { }, [activeWorkflowId, userMessages]) /** - * Auto-scroll to bottom when messages load + * Auto-scroll to bottom when messages load and chat is open */ useEffect(() => { if (workflowMessages.length > 0 && isChatOpen) { @@ -427,7 +474,6 @@ export function Chat() { } }, [workflowMessages.length, scrollToBottom, isChatOpen]) - // Get selected workflow outputs (deduplicated) const selectedOutputs = useMemo(() => { if (!activeWorkflowId) return [] const selected = selectedWorkflowOutputs[activeWorkflowId] @@ -448,7 +494,6 @@ export function Chat() { }, delay) }, []) - // Cleanup on unmount useEffect(() => { return () => { timeoutRef.current && clearTimeout(timeoutRef.current) @@ -456,7 +501,6 @@ export function Chat() { } }, []) - // React to execution cancellation from run button useEffect(() => { if (!isExecuting && isStreaming) { const lastMessage = workflowMessages[workflowMessages.length - 1] @@ -500,7 +544,6 @@ export function Chat() { const chunk = decoder.decode(value, { stream: true }) buffer += chunk - // Process only complete SSE messages; keep any partial trailing data in buffer const separatorIndex = buffer.lastIndexOf('\n\n') if (separatorIndex === -1) { continue @@ -550,7 +593,6 @@ export function Chat() { } finalizeMessageStream(responseMessageId) } finally { - // Only clear ref if it's still our reader (prevents clobbering a new stream) if (streamReaderRef.current === reader) { streamReaderRef.current = null } @@ -979,8 +1021,7 @@ export function Chat() { {chatFiles.length > 0 && (
{chatFiles.map((file) => { - const isImage = file.type.startsWith('image/') - const previewUrl = isImage ? URL.createObjectURL(file.file) : null + const previewUrl = getFilePreviewUrl(file) return (
URL.revokeObjectURL(previewUrl)} /> ) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx index 2a01d630a4..a11983b0be 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -113,16 +113,17 @@ export function ChatMessage({ message }: ChatMessageProps) { {message.attachments && message.attachments.length > 0 && (
{message.attachments.map((attachment) => { - const isImage = attachment.type.startsWith('image/') const hasValidDataUrl = attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:') + // Only treat as displayable image if we have both image type AND valid data URL + const canDisplayAsImage = attachment.type.startsWith('image/') && hasValidDataUrl return (
{ if (hasValidDataUrl) { e.preventDefault() @@ -131,7 +132,7 @@ export function ChatMessage({ message }: ChatMessageProps) { } }} > - {isImage && hasValidDataUrl ? ( + {canDisplayAsImage ? ( {attachment.name} { - const remainingSlots = Math.max(0, MAX_FILES - chatFiles.length) + const addFiles = useCallback((files: File[]) => { + setChatFiles((currentFiles) => { + const remainingSlots = Math.max(0, MAX_FILES - currentFiles.length) const candidateFiles = files.slice(0, remainingSlots) const errors: string[] = [] const validNewFiles: ChatFile[] = [] @@ -39,11 +40,14 @@ export function useChatFileUpload() { continue } - // Check for duplicates - const isDuplicate = chatFiles.some( + // Check for duplicates against current files and newly added valid files + const isDuplicateInCurrent = currentFiles.some( (existingFile) => existingFile.name === file.name && existingFile.size === file.size ) - if (isDuplicate) { + const isDuplicateInNew = validNewFiles.some( + (newFile) => newFile.name === file.name && newFile.size === file.size + ) + if (isDuplicateInCurrent || isDuplicateInNew) { errors.push(`${file.name} already added`) continue } @@ -57,20 +61,20 @@ export function useChatFileUpload() { }) } + // Update errors outside the state setter to avoid nested state updates if (errors.length > 0) { - setUploadErrors(errors) + // Use setTimeout to avoid state update during render + setTimeout(() => setUploadErrors(errors), 0) + } else if (validNewFiles.length > 0) { + setTimeout(() => setUploadErrors([]), 0) } if (validNewFiles.length > 0) { - setChatFiles([...chatFiles, ...validNewFiles]) - // Clear errors when files are successfully added - if (errors.length === 0) { - setUploadErrors([]) - } + return [...currentFiles, ...validNewFiles] } - }, - [chatFiles] - ) + return currentFiles + }) + }, []) /** * Remove a file diff --git a/apps/sim/stores/chat/store.ts b/apps/sim/stores/chat/store.ts index e9a93f1e14..3da14769db 100644 --- a/apps/sim/stores/chat/store.ts +++ b/apps/sim/stores/chat/store.ts @@ -26,7 +26,6 @@ export const useChatStore = create()( devtools( persist( (set, get) => ({ - // UI State isChatOpen: false, chatPosition: null, chatWidth: DEFAULT_WIDTH, @@ -51,7 +50,6 @@ export const useChatStore = create()( set({ chatPosition: null }) }, - // Message State messages: [], selectedWorkflowOutputs: {}, conversationIds: {}, @@ -60,12 +58,10 @@ export const useChatStore = create()( set((state) => { const newMessage: ChatMessage = { ...message, - // Preserve provided id and timestamp if they exist; otherwise generate new ones id: (message as any).id ?? crypto.randomUUID(), timestamp: (message as any).timestamp ?? new Date().toISOString(), } - // Keep only the last MAX_MESSAGES const newMessages = [newMessage, ...state.messages].slice(0, MAX_MESSAGES) return { messages: newMessages } @@ -80,7 +76,6 @@ export const useChatStore = create()( ), } - // Generate a new conversationId when clearing chat for a specific workflow if (workflowId) { const newConversationIds = { ...state.conversationIds } newConversationIds[workflowId] = uuidv4() @@ -89,7 +84,6 @@ export const useChatStore = create()( conversationIds: newConversationIds, } } - // When clearing all chats (workflowId is null), also clear all conversationIds return { ...newState, conversationIds: {}, @@ -131,15 +125,12 @@ export const useChatStore = create()( return stringValue } - // CSV Headers const headers = ['timestamp', 'type', 'content'] - // Sort messages by timestamp (oldest first) const sortedMessages = messages.sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ) - // Generate CSV rows const csvRows = [ headers.join(','), ...sortedMessages.map((message) => @@ -151,15 +142,12 @@ export const useChatStore = create()( ), ] - // Create CSV content const csvContent = csvRows.join('\n') - // Generate filename with timestamp const now = new Date() const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19) const filename = `chat-${workflowId}-${timestamp}.csv` - // Create and trigger download const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) const link = document.createElement('a') @@ -177,15 +165,11 @@ export const useChatStore = create()( setSelectedWorkflowOutput: (workflowId, outputIds) => { set((state) => { - // Create a new copy of the selections state const newSelections = { ...state.selectedWorkflowOutputs } - // If empty array, explicitly remove the key to prevent empty arrays from persisting if (outputIds.length === 0) { - // Delete the key entirely instead of setting to empty array delete newSelections[workflowId] } else { - // Ensure no duplicates in the selection by using Set newSelections[workflowId] = [...new Set(outputIds)] } @@ -200,7 +184,6 @@ export const useChatStore = create()( getConversationId: (workflowId) => { const state = get() if (!state.conversationIds[workflowId]) { - // Generate a new conversation ID if one doesn't exist return get().generateNewConversationId(workflowId) } return state.conversationIds[workflowId] @@ -270,6 +253,16 @@ export const useChatStore = create()( }), { name: 'chat-store', + partialize: (state) => ({ + ...state, + messages: state.messages.map((msg) => ({ + ...msg, + attachments: msg.attachments?.map((att) => ({ + ...att, + dataUrl: '', + })), + })), + }), } ) )