refactor(desktop): extract composer drag-and-drop into useComposerDrop

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.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 03:28:28 -05:00
parent cf05b38683
commit bd53230739
2 changed files with 174 additions and 129 deletions

View file

@ -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<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
dragDepthRef.current += 1
if (!dragActive) {
setDragActive(true)
}
}
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems) {
return
}
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragActive(false)
}
}
const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
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<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
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
}
}

View file

@ -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<QueueEditState | null>(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<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
dragDepthRef.current += 1
if (!dragActive) {
setDragActive(true)
}
}
const handleDragOver = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
const handleDragLeave = (event: ReactDragEvent<HTMLFormElement>) => {
if (!onAttachDroppedItems) {
return
}
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragActive(false)
}
}
const handleDrop = (event: ReactDragEvent<HTMLFormElement>) => {
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<HTMLDivElement>) => {
if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return
}
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
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('')