From bd53230739da3bacbf58211f79a56cebcbfb4e06 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:28:28 -0500 Subject: [PATCH] refactor(desktop): extract composer drag-and-drop into useComposerDrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the attachment drop engine (dragActive + the 7 drag/drop handlers + the in-app-ref vs OS-upload split) out of ChatBar into composer/hooks/use-composer-drop.ts. Self-contained, off the keystroke path — consumes insertInlineRefs + onAttachDroppedItems + requestMainFocus. Verbatim move, behaviour-preserving. --- .../chat/composer/hooks/use-composer-drop.ts | 164 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 139 ++------------- 2 files changed, 174 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts new file mode 100644 index 00000000000..2c56061c80d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts @@ -0,0 +1,164 @@ +import { type DragEvent as ReactDragEvent, useRef, useState } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../../hooks/use-composer-actions' +import { dragHasAttachments, droppedFileInlineRefs, type InlineRefInput } from '../inline-refs' +import type { ChatBarProps } from '../types' + +interface UseComposerDropArgs { + cwd: ChatBarProps['cwd'] + insertInlineRefs: (refs: InlineRefInput[]) => boolean + onAttachDroppedItems: ChatBarProps['onAttachDroppedItems'] + requestMainFocus: () => void +} + +/** + * Drag-and-drop attachment engine. Splits drops by origin: in-app drags + * (project tree / gutter) stay inline `@file:`/`@line:` refs the gateway + * resolves directly; OS/Finder drops (absolute local paths a remote gateway + * can't read, image bytes vision needs) route through the upload pipeline. + * Off the keystroke path; consumes `insertInlineRefs` + the attach handler. + */ +export function useComposerDrop({ + cwd, + insertInlineRefs, + onAttachDroppedItems, + requestMainFocus +}: UseComposerDropArgs) { + const [dragActive, setDragActive] = useState(false) + const dragDepthRef = useRef(0) + + const resetDragState = () => { + dragDepthRef.current = 0 + setDragActive(false) + } + + const handleDragEnter = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + resetDragState() + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (candidates.length === 0) { + return + } + + // In-app drags (project tree / gutter) are workspace-relative paths the + // gateway resolves directly, so they stay inline @file:/@line: refs. OS + // drops are absolute local paths a remote gateway can't read (and images + // need byte upload for vision), so route them through the upload pipeline. + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(inAppRefs, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (osDrops.length) { + void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + const handleInputDragOver = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + } + + const handleInputDrop = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (!candidates.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + // Dropping straight onto the text box used to inline-ref *every* file — + // including OS/Finder drops, whose absolute local path a remote gateway + // can't read and whose image bytes never reached vision. Split by origin: + // in-app drags stay inline refs; OS drops go through the upload pipeline. + // (When no upload handler is wired, fall back to inline refs for all.) + const attach = onAttachDroppedItems + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (attach && osDrops.length) { + void Promise.resolve(attach(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + return { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 478aabd7e55..71c32292800 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -5,7 +5,6 @@ import { type ClipboardEvent, type FormEvent, type KeyboardEvent, - type DragEvent as ReactDragEvent, useCallback, useEffect, useMemo, @@ -69,8 +68,6 @@ import { $autoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' -import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' - import { AttachmentList } from './attachments' import { cloneAttachments, @@ -97,13 +94,12 @@ import { } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerDrop } from './hooks/use-composer-drop' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { - dragHasAttachments, - droppedFileInlineRefs, type InlineRefInput, insertInlineRefsIntoEditor } from './inline-refs' @@ -281,12 +277,10 @@ export function ChatBar({ const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) const [focusRequestId, setFocusRequestId] = useState(0) const queueEditRef = useRef(queueEdit) queueEditRef.current = queueEdit - const dragDepthRef = useRef(0) const composingRef = useRef(false) // true during IME composition (CJK input) const { availableThemes, themeName } = useTheme() @@ -1114,128 +1108,15 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } - const resetDragState = () => { - dragDepthRef.current = 0 - setDragActive(false) - } - - const handleDragEnter = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - dragDepthRef.current += 1 - - if (!dragActive) { - setDragActive(true) - } - } - - const handleDragOver = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - - const handleDragLeave = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - - if (dragDepthRef.current === 0) { - setDragActive(false) - } - } - - const handleDrop = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - resetDragState() - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (candidates.length === 0) { - return - } - - // In-app drags (project tree / gutter) are workspace-relative paths the - // gateway resolves directly, so they stay inline @file:/@line: refs. OS - // drops are absolute local paths a remote gateway can't read (and images - // need byte upload for vision), so route them through the upload pipeline. - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(inAppRefs, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (osDrops.length) { - void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } - - const handleInputDragOver = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'copy' - } - - const handleInputDrop = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (!candidates.length) { - return - } - - event.preventDefault() - event.stopPropagation() - resetDragState() - - // Dropping straight onto the text box used to inline-ref *every* file — - // including OS/Finder drops, whose absolute local path a remote gateway - // can't read and whose image bytes never reached vision. Split by origin: - // in-app drags stay inline refs; OS drops go through the upload pipeline. - // (When no upload handler is wired, fall back to inline refs for all.) - const attach = onAttachDroppedItems - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (attach && osDrops.length) { - void Promise.resolve(attach(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } + const { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) const clearDraft = useCallback(() => { setComposerText('')