import { useState, useRef, useCallback, useEffect } from 'react' import { Send, Paperclip, Terminal, Loader2, X, RotateCcw, ImagePlus } from 'lucide-react' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' import { toast } from '@/lib/toast' import type { StepResponseRequest } from '@/types/ai-session' import type { PendingUpload } from '@/types/upload' const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx' interface FlowPilotMessageBarProps { onRespond: (response: StepResponseRequest) => void disabled?: boolean isProcessing?: boolean } export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing = false }: FlowPilotMessageBarProps) { const [message, setMessage] = useState('') const [showLogs, setShowLogs] = useState(false) const [logContent, setLogContent] = useState('') const [pendingUploads, setPendingUploads] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) const dragCounterRef = useRef(0) const isDisabled = disabled || isProcessing // Auto-grow textarea useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 150)}px` }, [message]) const handleSubmit = useCallback(() => { const trimmed = message.trim() if (!trimmed || isDisabled) return let fullMessage = trimmed if (logContent.trim()) { fullMessage += `\n\n--- Logs ---\n${logContent.trim()}` } onRespond({ free_text_input: fullMessage }) setMessage('') setLogContent('') setShowLogs(false) if (textareaRef.current) { textareaRef.current.style.height = 'auto' } }, [message, logContent, isDisabled, onRespond]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSubmit() } }, [handleSubmit]) // ── File handling ────────────────────────────── const processFiles = useCallback((files: File[]) => { if (files.length === 0) return const newUploads: PendingUpload[] = files.map((file) => ({ id: crypto.randomUUID(), file, preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '', status: 'uploading' as const, })) setPendingUploads((prev) => [...prev, ...newUploads]) newUploads.forEach((upload) => { uploadsApi .upload(upload.file) .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u) ) }) .catch((err) => { const is503 = err?.response?.status === 503 if (is503) { toast.warning('Image attachments are not available yet — describe the issue in text instead') } else { toast.error(`Upload failed: ${err?.message || 'Unknown error'}`) } setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) }) }) }, []) const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items if (!items) return const imageFiles: File[] = [] for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile() if (file) imageFiles.push(file) } } if (imageFiles.length > 0) { e.preventDefault() processFiles(imageFiles) } }, [processFiles]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() e.dataTransfer.dropEffect = 'copy' }, []) const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault() dragCounterRef.current++ if (dragCounterRef.current === 1) setIsDragOver(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() dragCounterRef.current-- if (dragCounterRef.current === 0) setIsDragOver(false) }, []) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() dragCounterRef.current = 0 setIsDragOver(false) processFiles(Array.from(e.dataTransfer.files)) }, [processFiles]) const handleFileSelect = useCallback((e: React.ChangeEvent) => { if (e.target.files) { processFiles(Array.from(e.target.files)) e.target.value = '' } }, [processFiles]) const handleRemoveUpload = useCallback((uploadId: string) => { setPendingUploads((prev) => { const toRemove = prev.find((u) => u.id === uploadId) if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview) return prev.filter((u) => u.id !== uploadId) }) }, []) const retryUpload = useCallback((uploadId: string) => { const upload = pendingUploads.find((u) => u.id === uploadId) if (!upload) return setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u) ) uploadsApi .upload(upload.file) .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u) ) }) .catch((err) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u) ) }) }, [pendingUploads]) // Cleanup blob URLs on unmount useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const hasContent = message.trim().length > 0 return (
{/* Drag overlay */} {isDragOver && (
Drop files to attach
)} {/* Textarea */}