diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 953caa2b..edf6a2ad 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import type { DragEvent } from 'react'; +import { useRef, useState } from 'react'; import { Loader2, Mic, Send, Square, ListPlus, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat'; @@ -43,9 +44,21 @@ export const InputArea = ({ manager }: InputAreaProps) => { const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput); const [urlDialogOpen, setUrlDialogOpen] = useState(false); + const drop = useImageDrop(attachImage); return ( -
+
+ {drop.isTarget && ( +
+ Drop images to attach +
+ )} {commandFeedback && (
{commandFeedback}
)} @@ -132,6 +145,61 @@ export const InputArea = ({ manager }: InputAreaProps) => { // ── Helpers ── +/** + * Drop images anywhere on the composer, not just on the textarea — the target is the whole bar, because + * a screenshot dragged out of the macOS corner thumbnail is a small thing to aim with. + * + * Three things this has to get right, each of which silently breaks the drop if missed: + * + * - **`preventDefault` on dragover.** Without it the browser refuses the drop and never fires `onDrop`; + * it just navigates to the file instead, throwing away whatever was typed. + * - **A depth counter, not a boolean.** `dragenter`/`dragleave` fire for every child crossed, so moving + * over the textarea or a button reads as leaving the bar and the highlight strobes. + * - **Only claim drags that carry files.** Dragging selected text across the composer would otherwise + * light it up and then swallow the drop, which is how you lose a text-drag into the input. + */ +function useImageDrop(attachImage: (file: File) => void) { + const [isTarget, setIsTarget] = useState(false); + const depth = useRef(0); + + const carriesFiles = (ev: DragEvent) => Array.from(ev.dataTransfer.types).includes('Files'); + + const reset = () => { + depth.current = 0; + setIsTarget(false); + }; + + return { + isTarget, + onDragEnter: (ev: DragEvent) => { + if (!carriesFiles(ev)) return; + ev.preventDefault(); + depth.current += 1; + setIsTarget(true); + }, + onDragOver: (ev: DragEvent) => { + if (!carriesFiles(ev)) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + }, + onDragLeave: (ev: DragEvent) => { + if (!carriesFiles(ev)) return; + depth.current -= 1; + if (depth.current <= 0) reset(); + }, + onDrop: (ev: DragEvent) => { + if (!carriesFiles(ev)) return; + ev.preventDefault(); + reset(); + // Images only, and quietly: a drag can carry several files, and refusing the PDF among them with a + // toast would be noise when the three screenshots you meant went in fine. + for (const file of Array.from(ev.dataTransfer.files)) { + if (file.type.startsWith('image/')) attachImage(file); + } + }, + }; +} + /** * What is waiting to be sent. Without this a queued prompt is invisible until its turn comes — the * composer empties and nothing else changes, which reads exactly like the message having been lost.