improvement(chat): partialize chat store to only persist image URL instead of full image in floating chat (#2842)

This commit is contained in:
Waleed
2026-01-15 16:54:24 -08:00
committed by GitHub
parent f1796d13df
commit 87e6057033
4 changed files with 101 additions and 63 deletions

View File

@@ -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<ProcessedA
try {
dataUrl = await new Promise<string>((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<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
const [moreMenuOpen, setMoreMenuOpen] = useState(false)
// Refs
const inputRef = useRef<HTMLInputElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
const preventZoomRef = usePreventZoom()
// File upload hook
const {
chatFiles,
uploadErrors,
@@ -257,6 +285,38 @@ export function Chat() {
handleDrop,
} = useChatFileUpload()
const filePreviewUrls = useRef<Map<string, string>>(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 && (
<div className='mt-[4px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{chatFiles.map((file) => {
const isImage = file.type.startsWith('image/')
const previewUrl = isImage ? URL.createObjectURL(file.file) : null
const previewUrl = getFilePreviewUrl(file)
return (
<div
@@ -997,7 +1038,6 @@ export function Chat() {
src={previewUrl}
alt={file.name}
className='h-full w-full object-cover'
onLoad={() => URL.revokeObjectURL(previewUrl)}
/>
) : (
<div className='min-w-0 flex-1'>

View File

@@ -113,16 +113,17 @@ export function ChatMessage({ message }: ChatMessageProps) {
{message.attachments && message.attachments.length > 0 && (
<div className='mb-2 flex flex-wrap gap-[6px]'>
{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 (
<div
key={attachment.id}
className={`group relative flex-shrink-0 overflow-hidden rounded-[6px] bg-[var(--surface-2)] ${
hasValidDataUrl ? 'cursor-pointer' : ''
} ${isImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
} ${canDisplayAsImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
onClick={(e) => {
if (hasValidDataUrl) {
e.preventDefault()
@@ -131,7 +132,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
}
}}
>
{isImage && hasValidDataUrl ? (
{canDisplayAsImage ? (
<img
src={attachment.dataUrl}
alt={attachment.name}

View File

@@ -24,10 +24,11 @@ export function useChatFileUpload() {
/**
* Validate and add files
* Uses functional state update to avoid stale closure issues with rapid file additions
*/
const addFiles = useCallback(
(files: File[]) => {
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

View File

@@ -26,7 +26,6 @@ export const useChatStore = create<ChatState>()(
devtools(
persist(
(set, get) => ({
// UI State
isChatOpen: false,
chatPosition: null,
chatWidth: DEFAULT_WIDTH,
@@ -51,7 +50,6 @@ export const useChatStore = create<ChatState>()(
set({ chatPosition: null })
},
// Message State
messages: [],
selectedWorkflowOutputs: {},
conversationIds: {},
@@ -60,12 +58,10 @@ export const useChatStore = create<ChatState>()(
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<ChatState>()(
),
}
// 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<ChatState>()(
conversationIds: newConversationIds,
}
}
// When clearing all chats (workflowId is null), also clear all conversationIds
return {
...newState,
conversationIds: {},
@@ -131,15 +125,12 @@ export const useChatStore = create<ChatState>()(
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<ChatState>()(
),
]
// 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<ChatState>()(
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<ChatState>()(
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<ChatState>()(
}),
{
name: 'chat-store',
partialize: (state) => ({
...state,
messages: state.messages.map((msg) => ({
...msg,
attachments: msg.attachments?.map((att) => ({
...att,
dataUrl: '',
})),
})),
}),
}
)
)