+ {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.