From b118e158d420770188a98d303f1ed13291a74d2a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 23 Mar 2026 12:25:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20rich=20message=20bar=20in=20FlowPilot?= =?UTF-8?q?=20=E2=80=94=20paste,=20drag-drop,=20attach,=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgraded FlowPilotMessageBar from basic textarea to ChatGPT-style input: - Paste images (Ctrl+V) with thumbnail preview - Drag-and-drop files with drop zone overlay - Attach button (images, logs, docs, PDFs) - Expandable "Paste Logs" section for raw error output - Auto-growing textarea with focus ring - Adjusted bottom offset for slimmer action bar Co-Authored-By: Claude Opus 4.6 (1M context) --- .../flowpilot/FlowPilotMessageBar.tsx | 312 ++++++++++++++++-- 1 file changed, 284 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx b/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx index 7c5b59dd..e6a31687 100644 --- a/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx +++ b/frontend/src/components/flowpilot/FlowPilotMessageBar.tsx @@ -1,7 +1,11 @@ -import { useState, useRef, useCallback } from 'react' -import { Send } from 'lucide-react' +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 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 @@ -11,19 +15,41 @@ interface FlowPilotMessageBarProps { 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 - onRespond({ free_text_input: trimmed }) + + 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, isDisabled, onRespond]) + }, [message, logContent, isDisabled, onRespond]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -32,50 +58,280 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing } }, [handleSubmit]) - const handleInput = useCallback((e: React.ChangeEvent) => { - setMessage(e.target.value) - const textarea = e.target - textarea.style.height = 'auto' - textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` + // ── 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 errorMsg = err?.response?.status === 503 + ? 'File uploads not available' + : err?.message || 'Upload failed' + setPendingUploads((prev) => + prev.map((u) => u.id === upload.id ? { ...u, status: 'error' as const, error: errorMsg } : u) + ) + }) + }) }, []) + 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 */}