From 1d5454c31b412844d73ffdc370a05f97cfd06857 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 20 Mar 2026 03:31:03 +0000 Subject: [PATCH] feat(evidence): add RichTextInput with clipboard paste and wire into FlowPilot Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/common/RichTextInput.tsx | 285 ++++++++++++++++++ .../components/flowpilot/EscalateModal.tsx | 14 +- .../flowpilot/FlowPilotActionBar.tsx | 3 + .../components/flowpilot/FlowPilotIntake.tsx | 18 +- .../components/flowpilot/FlowPilotSession.tsx | 1 + .../flowpilot/FlowPilotStepCard.tsx | 11 +- 6 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/common/RichTextInput.tsx diff --git a/frontend/src/components/common/RichTextInput.tsx b/frontend/src/components/common/RichTextInput.tsx new file mode 100644 index 00000000..9d7a23dc --- /dev/null +++ b/frontend/src/components/common/RichTextInput.tsx @@ -0,0 +1,285 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { Loader2, X, RotateCcw, ImagePlus } from 'lucide-react' +import { cn } from '@/lib/utils' +import { uploadsApi } from '@/api/uploads' +import type { FileUploadResponse, PendingUpload } from '@/types/upload' + +interface RichTextInputProps { + value: string + onChange: (value: string) => void + onFilesChange?: (uploads: FileUploadResponse[]) => void + sessionId?: string + placeholder?: string + rows?: number + className?: string + disabled?: boolean +} + +export function RichTextInput({ + value, + onChange, + onFilesChange, + sessionId, + placeholder, + rows = 3, + className, + disabled, +}: RichTextInputProps) { + const [pendingUploads, setPendingUploads] = useState([]) + const [isDragOver, setIsDragOver] = useState(false) + const [isFocused, setIsFocused] = useState(false) + const textareaRef = useRef(null) + const dragCounterRef = useRef(0) + + // Cleanup blob URLs on unmount + useEffect(() => { + return () => { + pendingUploads.forEach((upload) => { + URL.revokeObjectURL(upload.preview) + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const processFiles = useCallback( + (files: File[]) => { + const imageFiles = files.filter((f) => f.type.startsWith('image/')) + if (imageFiles.length === 0) return + + const newUploads: PendingUpload[] = imageFiles.map((file) => ({ + id: crypto.randomUUID(), + file, + preview: URL.createObjectURL(file), + status: 'uploading' as const, + })) + + setPendingUploads((prev) => [...prev, ...newUploads]) + + // Upload each file + newUploads.forEach((upload) => { + uploadsApi + .upload(upload.file, sessionId) + .then((result) => { + setPendingUploads((prev) => { + const updated = prev.map((u) => + u.id === upload.id ? { ...u, status: 'done' as const, result } : u + ) + // Notify parent of all completed uploads + const completed = updated + .filter((u) => u.status === 'done' && u.result) + .map((u) => u.result!) + onFilesChange?.(completed) + return updated + }) + }) + .catch((err) => { + setPendingUploads((prev) => + prev.map((u) => + u.id === upload.id + ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } + : u + ) + ) + }) + }) + }, + [sessionId, onFilesChange] + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + if (disabled) return + const items = e.clipboardData?.items + if (!items) return + + const imageFiles: File[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.type.startsWith('image/')) { + const file = item.getAsFile() + if (file) imageFiles.push(file) + } + } + + if (imageFiles.length > 0) { + e.preventDefault() + processFiles(imageFiles) + } + }, + [disabled, processFiles] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (disabled) return + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }, + [disabled] + ) + + const handleDragEnter = useCallback( + (e: React.DragEvent) => { + if (disabled) return + e.preventDefault() + dragCounterRef.current++ + if (dragCounterRef.current === 1) { + setIsDragOver(true) + } + }, + [disabled] + ) + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + dragCounterRef.current-- + if (dragCounterRef.current === 0) { + setIsDragOver(false) + } + }, + [] + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + if (disabled) return + e.preventDefault() + dragCounterRef.current = 0 + setIsDragOver(false) + + const files = Array.from(e.dataTransfer.files) + processFiles(files) + }, + [disabled, processFiles] + ) + + const handleRemove = useCallback( + (uploadId: string) => { + setPendingUploads((prev) => { + const toRemove = prev.find((u) => u.id === uploadId) + if (toRemove) { + URL.revokeObjectURL(toRemove.preview) + } + const updated = prev.filter((u) => u.id !== uploadId) + const completed = updated + .filter((u) => u.status === 'done' && u.result) + .map((u) => u.result!) + onFilesChange?.(completed) + return updated + }) + }, + [onFilesChange] + ) + + const retryUpload = useCallback( + (uploadId: string) => { + setPendingUploads((prev) => + prev.map((u) => (u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u)) + ) + + const upload = pendingUploads.find((u) => u.id === uploadId) + if (!upload) return + + uploadsApi + .upload(upload.file, sessionId) + .then((result) => { + setPendingUploads((prev) => { + const updated = prev.map((u) => + u.id === uploadId ? { ...u, status: 'done' as const, result } : u + ) + const completed = updated + .filter((u) => u.status === 'done' && u.result) + .map((u) => u.result!) + onFilesChange?.(completed) + return updated + }) + }) + .catch((err) => { + setPendingUploads((prev) => + prev.map((u) => + u.id === uploadId + ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } + : u + ) + ) + }) + }, + [pendingUploads, sessionId, onFilesChange] + ) + + return ( +
+