expand chat file drop zone (#1085)

## What changed

- Added a chat-shell-level file drag/drop target so the whole chat area, including the conversation area, shows the drop overlay and accepts files.
- Disabled the nested composer drop handler for the sidebar chat path to avoid duplicate drops or stuck overlay state.
- Kept the existing local input drop behavior available for other ChatInput usages like the home composer.
This commit is contained in:
ishaanxgupta 2026-06-10 18:55:57 +00:00
parent 6efb4ec3c2
commit a2a1b065a2
2 changed files with 120 additions and 14 deletions

View file

@ -201,6 +201,7 @@ export function ChatSidebar({
const [attachmentDrafts, setAttachmentDrafts] = useState<
ChatAttachmentDraft[]
>([])
const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false)
const [selectedModel, setSelectedModel] = useState<ModelId>(
initialSelectedModel ?? "grok-4.3",
)
@ -246,6 +247,7 @@ export function ChatSidebar({
null,
)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const chatDragDepthRef = useRef(0)
const isScrolledToBottomRef = useRef(true)
const userJustSentRef = useRef(false)
const sentQueuedMessageRef = useRef<string | null>(null)
@ -725,6 +727,77 @@ export function ChatSidebar({
[attachmentDrafts, currentChatId, uploadAttachmentDraft],
)
const hasDraggedFiles = useCallback(
(event: React.DragEvent) =>
Array.from(event.dataTransfer.types).includes("Files"),
[],
)
const resetChatFileDrag = useCallback(() => {
chatDragDepthRef.current = 0
setIsChatDraggingFiles(false)
}, [])
const handleChatDragEnter = useCallback(
(event: React.DragEvent) => {
if (!hasDraggedFiles(event)) return
event.preventDefault()
event.stopPropagation()
chatDragDepthRef.current += 1
if (status !== "submitted" && status !== "streaming") {
setIsChatDraggingFiles(true)
}
},
[hasDraggedFiles, status],
)
const handleChatDragOver = useCallback(
(event: React.DragEvent) => {
if (!hasDraggedFiles(event)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect =
status === "submitted" || status === "streaming" ? "none" : "copy"
},
[hasDraggedFiles, status],
)
const handleChatDragLeave = useCallback(
(event: React.DragEvent) => {
if (!hasDraggedFiles(event)) return
event.preventDefault()
event.stopPropagation()
chatDragDepthRef.current = Math.max(0, chatDragDepthRef.current - 1)
if (chatDragDepthRef.current === 0) {
setIsChatDraggingFiles(false)
}
},
[hasDraggedFiles],
)
const handleChatDrop = useCallback(
(event: React.DragEvent) => {
if (!hasDraggedFiles(event)) return
event.preventDefault()
event.stopPropagation()
resetChatFileDrag()
const files = event.dataTransfer.files
if (status !== "submitted" && status !== "streaming" && files.length) {
handleAddAttachmentFiles(files)
}
},
[handleAddAttachmentFiles, hasDraggedFiles, resetChatFileDrag, status],
)
useEffect(() => {
if (status === "submitted" || status === "streaming") {
resetChatFileDrag()
}
}, [resetChatFileDrag, status])
useEffect(() => {
if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
setMessages(pendingThreadLoad.messages)
@ -1765,6 +1838,27 @@ export function ChatSidebar({
</div>
) : null
const chatDropOverlay = isChatDraggingFiles ? (
<div
className={cn(
"pointer-events-none absolute inset-0 z-[80] grid place-items-center border border-dashed border-[#4B5563] bg-black/72 text-sm font-medium text-fg-primary backdrop-blur-sm",
isMobile || isPageDesktop ? "rounded-none" : "rounded-2xl",
)}
aria-hidden="true"
>
<div className="rounded-lg border border-white/10 bg-black/50 px-4 py-2 shadow-[0_12px_32px_rgba(0,0,0,0.35)]">
Drop files to attach
</div>
</div>
) : null
const chatDropTargetProps = {
onDragEnter: handleChatDragEnter,
onDragOver: handleChatDragOver,
onDragLeave: handleChatDragLeave,
onDrop: handleChatDrop,
onDragEnd: resetChatFileDrag,
}
const shell = (
<>
{showHeaderRow ? (
@ -1983,6 +2077,7 @@ export function ChatSidebar({
onRetryAttachment={handleRetryAttachment}
canSend={canSendMessage}
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
disableFileDropZone
sendDisabled={isResponding && isQueueFull}
sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`}
activeStatus={
@ -2063,7 +2158,9 @@ export function ChatSidebar({
layout === "page" ? { opacity: 0, y: 12 } : { x: "100px", opacity: 0 }
}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
{...(!isPageDesktop ? chatDropTargetProps : {})}
>
{!isPageDesktop && chatDropOverlay}
{chatHistorySheet}
{isPageDesktop ? (
<div className="flex h-full min-h-0 w-full flex-1 flex-row">
@ -2073,7 +2170,11 @@ export function ChatSidebar({
chatProject === AUTO_CHAT_SPACE_ID ? null : [chatProject]
}
/>
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[min(720px,100%)] shrink-0 basis-[min(720px,50vw)] flex-col">
<div
{...chatDropTargetProps}
className="relative flex h-full min-h-0 w-full min-w-0 max-w-[min(720px,100%)] shrink-0 basis-[min(720px,50vw)] flex-col"
>
{chatDropOverlay}
{pageDesktopToolbarRow}
<div className="relative mx-auto flex h-full min-h-0 w-full min-w-0 max-w-[min(720px,100%)] flex-1 flex-col px-3 sm:px-4 md:px-0">
{shell}

View file

@ -59,6 +59,7 @@ interface ChatInputProps {
onRetryAttachment?: (id: string) => void
canSend?: boolean
attachmentAccept?: string
disableFileDropZone?: boolean
}
export default function ChatInput({
@ -84,6 +85,7 @@ export default function ChatInput({
onRetryAttachment,
canSend,
attachmentAccept = CHAT_ATTACHMENT_ACCEPT,
disableFileDropZone = false,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
@ -200,11 +202,20 @@ export default function ChatInput({
</>
) : null
const dropOverlay = isDraggingFiles ? (
const dropOverlay =
!disableFileDropZone && isDraggingFiles ? (
<div className="pointer-events-none absolute inset-1 z-10 grid place-items-center rounded-lg border border-dashed border-[#4B5563] bg-black/70 text-sm font-medium text-fg-primary backdrop-blur-sm">
Drop files to attach
</div>
) : null
const dropZoneProps = disableFileDropZone
? {}
: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
}
return (
<motion.div
@ -328,10 +339,7 @@ export default function ChatInput({
{stackedToolbar ? (
<fieldset
aria-label="Chat input with file drop zone"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
{...dropZoneProps}
className="relative z-30 m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10"
>
{dropOverlay}
@ -368,10 +376,7 @@ export default function ChatInput({
) : (
<fieldset
aria-label="Chat input with file drop zone"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
{...dropZoneProps}
className={cn(
"relative m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
isMultiline && "flex-col",