Composer.tsx (6700B)
1 import { useState, useRef, useCallback, useEffect, type DragEvent } from "react"; 2 import { AttachmentPreview } from "./AttachmentPreview"; 3 import { useDraft } from "../../hooks/useDraft"; 4 import type { AttachmentResponse } from "../../lib/api"; 5 6 interface PendingAttachment { 7 id: string; 8 file: File; 9 thumbnailUrl?: string; 10 progress?: number; 11 error?: string; 12 } 13 14 interface ComposerProps { 15 onSend: (text: string, uploads?: AttachmentResponse[]) => void; 16 onUpload?: (files: File[]) => Promise<AttachmentResponse[]>; 17 disabled?: boolean; 18 sessionId?: string | null; 19 } 20 21 function generateId(): string { 22 return `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 23 } 24 25 function createImagePreview(file: File): string | undefined { 26 if (!file.type.startsWith("image/")) return undefined; 27 return URL.createObjectURL(file); 28 } 29 30 function addFilesToList( 31 existing: PendingAttachment[], 32 files: FileList | File[], 33 ): PendingAttachment[] { 34 const newAttachments = Array.from(files).map((file) => ({ 35 id: generateId(), 36 file, 37 thumbnailUrl: createImagePreview(file), 38 })); 39 return [...existing, ...newAttachments]; 40 } 41 42 export function Composer({ onSend, onUpload, disabled = false, sessionId }: ComposerProps) { 43 const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform); 44 const [text, setText, markSent] = useDraft(sessionId ?? null); 45 const [attachments, setAttachments] = useState<PendingAttachment[]>([]); 46 const [isDragging, setIsDragging] = useState(false); 47 const textareaRef = useRef<HTMLTextAreaElement>(null); 48 const fileInputRef = useRef<HTMLInputElement>(null); 49 50 const adjustHeight = useCallback(() => { 51 const el = textareaRef.current; 52 if (el) { 53 el.style.height = "auto"; 54 el.style.height = `${Math.min(el.scrollHeight, 200)}px`; 55 } 56 }, []); 57 58 useEffect(() => { 59 adjustHeight(); 60 }, [text, adjustHeight]); 61 62 const removeAttachment = useCallback((id: string) => { 63 setAttachments((prev) => prev.filter((a) => a.id !== id)); 64 }, []); 65 66 const handleSend = useCallback(async () => { 67 const trimmed = text.trim(); 68 const hasAttachments = attachments.length > 0; 69 if ((!trimmed && !hasAttachments) || disabled) return; 70 71 let uploads: AttachmentResponse[] | undefined; 72 if (hasAttachments && onUpload) { 73 const files = attachments.map((a) => a.file); 74 uploads = await onUpload(files); 75 } 76 77 onSend(trimmed, uploads); 78 markSent(); 79 setText(""); 80 setAttachments([]); 81 if (textareaRef.current) { 82 textareaRef.current.style.height = "auto"; 83 } 84 }, [text, attachments, disabled, onSend, onUpload, markSent]); 85 86 const handleKeyDown = useCallback( 87 (e: React.KeyboardEvent) => { 88 if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 89 e.preventDefault(); 90 handleSend(); 91 } 92 }, 93 [handleSend], 94 ); 95 96 const handleFileChange = useCallback( 97 (e: React.ChangeEvent<HTMLInputElement>) => { 98 if (e.target.files) { 99 setAttachments((prev) => addFilesToList(prev, e.target.files!)); 100 } 101 }, 102 [], 103 ); 104 105 const handleDrop = useCallback( 106 (e: DragEvent<HTMLDivElement>) => { 107 e.preventDefault(); 108 setIsDragging(false); 109 if (e.dataTransfer.files.length > 0) { 110 setAttachments((prev) => addFilesToList(prev, e.dataTransfer.files)); 111 } 112 }, 113 [], 114 ); 115 116 const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => { 117 e.preventDefault(); 118 setIsDragging(true); 119 }, []); 120 121 const handleDragLeave = useCallback(() => { 122 setIsDragging(false); 123 }, []); 124 125 const handlePaste = useCallback( 126 (e: React.ClipboardEvent) => { 127 const items = e.clipboardData?.items; 128 if (!items) return; 129 const files: File[] = []; 130 for (let i = 0; i < items.length; i++) { 131 if (items[i].kind === "file") { 132 const file = items[i].getAsFile(); 133 if (file) files.push(file); 134 } 135 } 136 if (files.length > 0) { 137 setAttachments((prev) => addFilesToList(prev, files)); 138 } 139 }, 140 [], 141 ); 142 143 return ( 144 <div className="w-full border-t border-gray-800 bg-gray-950 p-4"> 145 <div className="mx-auto max-w-3xl px-2 sm:px-0"> 146 {attachments.length > 0 && ( 147 <div className="mb-2 flex flex-wrap gap-2"> 148 {attachments.map((att) => ( 149 <AttachmentPreview 150 key={att.id} 151 file={att.file} 152 thumbnailUrl={att.thumbnailUrl} 153 progress={att.progress} 154 error={att.error} 155 onRemove={() => removeAttachment(att.id)} 156 /> 157 ))} 158 </div> 159 )} 160 161 <div 162 data-testid="composer-drop-zone" 163 onDrop={handleDrop} 164 onDragOver={handleDragOver} 165 onDragLeave={handleDragLeave} 166 className={`rounded-xl border border-gray-700 bg-gray-900 p-2 transition-colors ${ 167 isDragging ? "border-blue-500 bg-gray-800" : "" 168 } focus-within:border-gray-500`} 169 > 170 <div className="flex items-end gap-2"> 171 <input 172 ref={fileInputRef} 173 type="file" 174 multiple 175 onChange={handleFileChange} 176 className="hidden" 177 id="attachment-input" 178 aria-label="Attach file" 179 /> 180 <label 181 htmlFor="attachment-input" 182 className="cursor-pointer rounded-lg p-1.5 text-gray-500 hover:bg-gray-800 hover:text-gray-300" 183 > 184 📎 185 </label> 186 <textarea 187 ref={textareaRef} 188 value={text} 189 onChange={(e) => setText(e.target.value)} 190 onKeyDown={handleKeyDown} 191 onPaste={handlePaste} 192 disabled={disabled} 193 placeholder="Type a message..." 194 rows={1} 195 className="flex-1 resize-none bg-transparent px-2 py-1 text-sm text-gray-100 placeholder-gray-500 focus:outline-none disabled:opacity-50" 196 /> 197 <button 198 type="button" 199 onClick={handleSend} 200 disabled={disabled || (!text.trim() && attachments.length === 0)} 201 className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 202 > 203 Send 204 </button> 205 </div> 206 </div> 207 <p className="mt-1 text-xs text-gray-600"> 208 {isMac ? "⌘" : "Ctrl"}+Enter to send • Paste or drop files to attach 209 </p> 210 </div> 211 </div> 212 ); 213 }