diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..a74279eefc1 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,22 @@ 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 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 + // 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; + window.addEventListener("dragend", resetViewDragState); + return () => window.removeEventListener("dragend", resetViewDragState); + }, [isDraggingFilesOverView, resetViewDragState]); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -5084,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 ; @@ -5187,8 +5214,57 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const onViewDragEnter = (event: React.DragEvent) => { + if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + viewDragDepthRef.current += 1; + setIsDraggingFilesOverView(true); + }; + + const onViewDragOver = (event: React.DragEvent) => { + if (!canDropImagesOnView || !event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + 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(); + 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(); + resetViewDragState(); + if (!canDropImagesOnView) return; + 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,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : null, ); }, + addImages: (files: File[]) => { + if (environmentUnavailable !== null || projectSelectionRequired) return; + addComposerImages(files); + if (isComposerCollapsedMobile) { + expandMobileComposer(); + } else { + focusComposer(); + } + }, addTerminalContext: (selection: TerminalContextSelection) => { if (!activeThread) return; const snapshot = composerEditorRef.current?.readSnapshot() ?? { @@ -2104,6 +2095,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentUnavailable, activePendingProgress, applyPromptReplacement, + isComposerCollapsedMobile, + expandMobileComposer, isComposerModelPickerOpen, readComposerSnapshot, selectedModel, @@ -2129,10 +2122,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 +2547,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
+ + + + } + > + + + Attach images +