From e3912de4d027598248182ac21ed668e409fd4050 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 20 Jul 2026 18:14:56 +0000 Subject: [PATCH 1/4] feat(web): composer attach button and view-wide image drag-and-drop Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 53 ++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 88 ++++++++++--------- 2 files changed, 99 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..e39307f70e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -138,7 +138,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, ImageUpIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -1196,6 +1196,8 @@ function ChatViewContent(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [isDraggingFilesOverView, setIsDraggingFilesOverView] = useState(false); + const viewDragDepthRef = useRef(0); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -5187,8 +5189,55 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const onViewDragEnter = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + viewDragDepthRef.current += 1; + setIsDraggingFilesOverView(true); + }; + + const onViewDragOver = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsDraggingFilesOverView(true); + }; + + const onViewDragLeave = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + viewDragDepthRef.current = Math.max(0, viewDragDepthRef.current - 1); + if (viewDragDepthRef.current === 0) { + setIsDraggingFilesOverView(false); + } + }; + + const onViewDrop = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + viewDragDepthRef.current = 0; + setIsDraggingFilesOverView(false); + composerRef.current?.addImages(Array.from(event.dataTransfer.files)); + }; + return ( -
+
+ {isDraggingFilesOverView ? ( +
+
+ + Drop images to attach +
+
+ ) : null} {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
void; /** Insert a terminal context from the terminal drawer. */ addTerminalContext: (selection: TerminalContextSelection) => void; + /** Attach image files (e.g. from a view-level drop zone). */ + addImages: (files: File[]) => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; @@ -919,6 +922,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const attachFileInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -1836,8 +1840,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) removeComposerImageFromDraft(imageId); }; + const openAttachFilePicker = () => { + attachFileInputRef.current?.click(); + }; + + const onAttachFileInputChange = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length === 0) return; + addComposerImages(files); + focusComposer(); + }; + // ------------------------------------------------------------------ - // Callbacks: paste / drag + // Callbacks: paste // ------------------------------------------------------------------ const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); @@ -1848,41 +1864,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - addComposerImages(files); - focusComposer(); - }; - const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -1943,6 +1924,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.addEventListener("dragend", onWindowDragEnd); return () => window.removeEventListener("dragend", onWindowDragEnd); }, [isDragOverComposer]); + const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); @@ -2035,6 +2017,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : null, ); }, + addImages: (files: File[]) => { + addComposerImages(files); + focusComposer(); + }, addTerminalContext: (selection: TerminalContextSelection) => { if (!activeThread) return; const snapshot = composerEditorRef.current?.readSnapshot() ?? { @@ -2129,10 +2115,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "group rounded-[22px] p-px transition-colors duration-200", composerProviderState.composerFrameClassName, )} - onDragEnter={onComposerDragEnter} - onDragOver={onComposerDragOver} - onDragLeave={onComposerDragLeave} - onDrop={onComposerDrop} onDragEnterCapture={composerMentionDragHandlers.onDragEnter} onDragOverCapture={composerMentionDragHandlers.onDragOver} onDragLeaveCapture={onComposerMentionDragLeaveCapture} @@ -2558,6 +2540,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
+ + + + } + > + + + Attach images + Date: Mon, 20 Jul 2026 20:36:20 +0000 Subject: [PATCH 2/4] fix(web): harden view-wide image drop against review findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 29 +++++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 9 +++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e39307f70e9..8f0630e61fe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1197,7 +1197,19 @@ function ChatViewContent(props: ChatViewProps) { const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDraggingFilesOverView, setIsDraggingFilesOverView] = useState(false); - const viewDragDepthRef = useRef(0); + + // A cancelled drag (Escape) can end without a dragleave on the hovered + // target, which would leave the drop overlay stuck. dragend always fires + // on the in-page drag source and bubbles to window, so it is the reset of + // last resort while the overlay is up. + useEffect(() => { + if (!isDraggingFilesOverView) return; + const onWindowDragEnd = () => { + setIsDraggingFilesOverView(false); + }; + window.addEventListener("dragend", onWindowDragEnd); + return () => window.removeEventListener("dragend", onWindowDragEnd); + }, [isDraggingFilesOverView]); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -5189,15 +5201,17 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const canDropImagesOnView = + activeEnvironmentUnavailableState === null && !(isLocalDraftThread && activeProject === null); + const onViewDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; + if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return; event.preventDefault(); - viewDragDepthRef.current += 1; setIsDraggingFilesOverView(true); }; const onViewDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; + if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDraggingFilesOverView(true); @@ -5208,17 +5222,14 @@ function ChatViewContent(props: ChatViewProps) { event.preventDefault(); const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - viewDragDepthRef.current = Math.max(0, viewDragDepthRef.current - 1); - if (viewDragDepthRef.current === 0) { - setIsDraggingFilesOverView(false); - } + setIsDraggingFilesOverView(false); }; const onViewDrop = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); - viewDragDepthRef.current = 0; setIsDraggingFilesOverView(false); + if (!canDropImagesOnView) return; composerRef.current?.addImages(Array.from(event.dataTransfer.files)); }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 85d4c1931f9..8fcc1b8f51f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2018,8 +2018,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }, addImages: (files: File[]) => { + if (environmentUnavailable !== null || projectSelectionRequired) return; addComposerImages(files); - focusComposer(); + if (isComposerCollapsedMobile) { + expandMobileComposer(); + } else { + focusComposer(); + } }, addTerminalContext: (selection: TerminalContextSelection) => { if (!activeThread) return; @@ -2090,6 +2095,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentUnavailable, activePendingProgress, applyPromptReplacement, + isComposerCollapsedMobile, + expandMobileComposer, isComposerModelPickerOpen, readComposerSnapshot, selectedModel, From ed760bb551dbf2a5eb14e36d903d1e48f8bf6c31 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 20 Jul 2026 22:02:59 +0000 Subject: [PATCH 3/4] fix(web): keep drop overlay stable during nested drags and eligibility loss Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 36 ++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8f0630e61fe..894570b1f95 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1197,6 +1197,11 @@ function ChatViewContent(props: ChatViewProps) { const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDraggingFilesOverView, setIsDraggingFilesOverView] = useState(false); + const viewDragDepthRef = useRef(0); + const resetViewDragState = useCallback(() => { + viewDragDepthRef.current = 0; + setIsDraggingFilesOverView(false); + }, []); // A cancelled drag (Escape) can end without a dragleave on the hovered // target, which would leave the drop overlay stuck. dragend always fires @@ -1204,12 +1209,9 @@ function ChatViewContent(props: ChatViewProps) { // last resort while the overlay is up. useEffect(() => { if (!isDraggingFilesOverView) return; - const onWindowDragEnd = () => { - setIsDraggingFilesOverView(false); - }; - window.addEventListener("dragend", onWindowDragEnd); - return () => window.removeEventListener("dragend", onWindowDragEnd); - }, [isDraggingFilesOverView]); + window.addEventListener("dragend", resetViewDragState); + return () => window.removeEventListener("dragend", resetViewDragState); + }, [isDraggingFilesOverView, resetViewDragState]); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -5204,9 +5206,18 @@ function ChatViewContent(props: ChatViewProps) { const canDropImagesOnView = activeEnvironmentUnavailableState === null && !(isLocalDraftThread && activeProject === null); + // If attaching becomes unavailable mid-drag, retract the overlay so it + // does not keep inviting a drop that would be discarded. + useEffect(() => { + if (!canDropImagesOnView) { + resetViewDragState(); + } + }, [canDropImagesOnView, resetViewDragState]); + const onViewDragEnter = (event: React.DragEvent) => { if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return; event.preventDefault(); + viewDragDepthRef.current += 1; setIsDraggingFilesOverView(true); }; @@ -5217,18 +5228,23 @@ function ChatViewContent(props: ChatViewProps) { setIsDraggingFilesOverView(true); }; + // dragenter/dragleave bubble in balanced pairs from descendants, so a + // plain depth counter tracks whether the pointer is still inside the + // view without inspecting relatedTarget (which nested targets and some + // browsers report as null). const onViewDragLeave = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - setIsDraggingFilesOverView(false); + viewDragDepthRef.current = Math.max(0, viewDragDepthRef.current - 1); + if (viewDragDepthRef.current === 0) { + setIsDraggingFilesOverView(false); + } }; const onViewDrop = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); - setIsDraggingFilesOverView(false); + resetViewDragState(); if (!canDropImagesOnView) return; composerRef.current?.addImages(Array.from(event.dataTransfer.files)); }; From 0504ff4c5d0807ad61f3ab81292961018d81c33a Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 20 Jul 2026 22:10:17 +0000 Subject: [PATCH 4/4] fix(web): move drop-eligibility effect above early return Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 894570b1f95..a74279eefc1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5100,6 +5100,17 @@ function ChatViewContent(props: ChatViewProps) { void onRevertToTurnCountRef.current(targetTurnCount); }, []); + const canDropImagesOnView = + activeEnvironmentUnavailableState === null && !(isLocalDraftThread && activeProject === null); + + // If attaching becomes unavailable mid-drag, retract the overlay so it + // does not keep inviting a drop that would be discarded. + useEffect(() => { + if (!canDropImagesOnView) { + resetViewDragState(); + } + }, [canDropImagesOnView, resetViewDragState]); + // Empty state: no active thread if (!activeThread) { return ; @@ -5203,17 +5214,6 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; - const canDropImagesOnView = - activeEnvironmentUnavailableState === null && !(isLocalDraftThread && activeProject === null); - - // If attaching becomes unavailable mid-drag, retract the overlay so it - // does not keep inviting a drop that would be discarded. - useEffect(() => { - if (!canDropImagesOnView) { - resetViewDragState(); - } - }, [canDropImagesOnView, resetViewDragState]); - const onViewDragEnter = (event: React.DragEvent) => { if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return; event.preventDefault();