theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 }