Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
*/
Expand Down Expand Up @@ -322,21 +382,18 @@ 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,
height: chatHeight,
onPositionChange: setChatPosition,
})

// Boundary sync hook - keeps chat within bounds when layout changes
useFloatBoundarySync({
isOpen: isChatOpen,
position: actualPosition,
Expand All @@ -345,7 +402,6 @@ export function Chat() {
onPositionChange: setChatPosition,
})

// Resize hook - enables resizing from all edges and corners
const {
cursor: resizeCursor,
handleMouseMove: handleResizeMouseMove,
Expand All @@ -359,37 +415,30 @@ 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
.filter((msg) => msg.workflowId === activeWorkflowId)
.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,
role: msg.type,
}))
}, [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,
Expand All @@ -398,15 +447,13 @@ export function Chat() {
}
)

// Memoize user messages for performance
const userMessages = useMemo(() => {
return workflowMessages
.filter((msg) => msg.type === 'user')
.map((msg) => msg.content)
.filter((content): content is string => typeof content === 'string')
}, [workflowMessages])

// Update prompt history when workflow changes
useEffect(() => {
if (!activeWorkflowId) {
setPromptHistory([])
Expand All @@ -419,15 +466,14 @@ 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) {
scrollToBottom()
}
}, [workflowMessages.length, scrollToBottom, isChatOpen])

// Get selected workflow outputs (deduplicated)
const selectedOutputs = useMemo(() => {
if (!activeWorkflowId) return []
const selected = selectedWorkflowOutputs[activeWorkflowId]
Expand All @@ -448,15 +494,13 @@ export function Chat() {
}, delay)
}, [])

// Cleanup on unmount
useEffect(() => {
return () => {
timeoutRef.current && clearTimeout(timeoutRef.current)
streamReaderRef.current?.cancel()
}
}, [])

// React to execution cancellation from run button
useEffect(() => {
if (!isExecuting && isStreaming) {
const lastMessage = workflowMessages[workflowMessages.length - 1]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -131,7 +132,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
}
}}
>
{isImage && hasValidDataUrl ? (
{canDisplayAsImage ? (
<img
src={attachment.dataUrl}
alt={attachment.name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Loading