feat(composer): support drag-and-drop files and folders onto the composer (#787)

Files dropped onto the new-workspace composer (modal or full page) are
appended as attachment chips; folders are inserted inline at the textarea
caret with shell-style quoting so users can reference working directories
from the OS file browser without leaving the prompt.
This commit is contained in:
Neil
2026-04-17 19:14:51 -07:00
committed by GitHub
parent 5b71303bbd
commit 8d3494fe07
5 changed files with 213 additions and 2 deletions
@@ -166,6 +166,7 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void {
args:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'composer' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => {
if (mainWindow.isDestroyed()) {
+1
View File
@@ -582,6 +582,7 @@ export type PreloadApi = {
data:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'composer' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => void
) => () => void
+4 -1
View File
@@ -24,6 +24,7 @@ import {
type NativeDropResolution =
| { target: 'editor' }
| { target: 'terminal' }
| { target: 'composer' }
| { target: 'file-explorer'; destinationDir: string }
// Why: returned when the explorer marker was found but no destinationDir
// could be resolved. The caller must suppress the drop entirely instead of
@@ -51,7 +52,7 @@ function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null {
}
const target = entry.dataset.nativeFileDropTarget
if (target === 'editor' || target === 'terminal') {
if (target === 'editor' || target === 'terminal' || target === 'composer') {
return { target }
}
if (target === 'file-explorer') {
@@ -1089,6 +1090,7 @@ const api = {
data:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'composer' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => void
): (() => void) => {
@@ -1097,6 +1099,7 @@ const api = {
data:
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'composer' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
) => callback(data)
ipcRenderer.on('terminal:file-drop', listener)
@@ -265,6 +265,77 @@ function SetupCommandPreview({
)
}
function useComposerFileDragOver(): {
isFileDragOver: boolean
dragHandlers: {
onDragEnter: (event: React.DragEvent<HTMLDivElement>) => void
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void
}
} {
const [isFileDragOver, setIsFileDragOver] = React.useState(false)
const dragCounterRef = React.useRef(0)
const reset = React.useCallback(() => {
dragCounterRef.current = 0
setIsFileDragOver(false)
}, [])
const onDragEnter = React.useCallback((event: React.DragEvent<HTMLDivElement>): void => {
// Why: "Files" is the DataTransfer type the OS adds for native file drags;
// internal in-app drags (text/x-orca-file-path) must not trigger the
// attachment-drop highlight so they still route to their own handlers.
if (!event.dataTransfer.types.includes('Files')) {
return
}
if (event.dataTransfer.types.includes('text/x-orca-file-path')) {
return
}
dragCounterRef.current += 1
setIsFileDragOver(true)
}, [])
const onDragLeave = React.useCallback(
(event: React.DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) {
return
}
// Why: mirror the onDragEnter guard so internal in-app drags (which may
// carry both 'Files' and 'text/x-orca-file-path' types) don't decrement
// the counter when enter skipped incrementing it — otherwise the counter
// goes negative and the native-drag highlight state desyncs.
if (event.dataTransfer.types.includes('text/x-orca-file-path')) {
return
}
dragCounterRef.current -= 1
if (dragCounterRef.current <= 0) {
reset()
}
},
[reset]
)
// Why: the preload bridge calls stopPropagation on native `drop` events so
// React's onDrop never fires on the composer card. Listen at the document
// level (also capture-phase) to reset the drag highlight whenever any drop
// or dragend occurs anywhere in the window.
React.useEffect(() => {
const handler = (): void => {
reset()
}
document.addEventListener('drop', handler, true)
document.addEventListener('dragend', handler, true)
return () => {
document.removeEventListener('drop', handler, true)
document.removeEventListener('dragend', handler, true)
}
}, [reset])
return {
isFileDragOver,
dragHandlers: { onDragEnter, onDragLeave }
}
}
export default function NewWorkspaceComposerCard({
containerClassName,
composerRef,
@@ -314,12 +385,22 @@ export default function NewWorkspaceComposerCard({
resolvedSetupDecision,
createError
}: NewWorkspaceComposerCardProps): React.JSX.Element {
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
return (
<div className="grid gap-3">
<div
ref={composerRef}
// Why: preload classifies native OS file drops by the nearest
// `data-native-file-drop-target` marker in the composedPath. Tagging
// the composer root makes drops anywhere on the card (modal or full
// page) route to the composer attachment handler instead of falling
// back to the default editor-open behavior.
data-native-file-drop-target="composer"
onDragEnter={dragHandlers.onDragEnter}
onDragLeave={dragHandlers.onDragLeave}
className={cn(
'rounded-[20px] border border-border/50 bg-background/40 p-3 shadow-lg backdrop-blur-xl supports-[backdrop-filter]:bg-background/40',
'rounded-[20px] border border-border/50 bg-background/40 p-3 shadow-lg backdrop-blur-xl supports-[backdrop-filter]:bg-background/40 transition',
isFileDragOver && 'border-ring ring-2 ring-ring/30',
containerClassName
)}
>
+125
View File
@@ -112,6 +112,15 @@ export type UseComposerStateResult = {
createDisabled: boolean
}
// Why: both the full-page NewWorkspacePage composer and the Cmd+J modal can
// be mounted simultaneously. Without instance scoping, a single native file
// drop fires every subscriber and duplicates attachments/prompt edits across
// the background draft and the visible modal. Route drops to the
// most-recently-mounted composer only — the modal stacks on top, so the
// modal wins when both are present, and the page takes over once the modal
// closes.
const composerDropStack: symbol[] = []
// Why: agent detection runs `which` for every agent binary on PATH — an IPC
// round-trip that takes 50200ms. The set of installed agents doesn't change
// within a session, so cache the promise at module scope to collapse all
@@ -268,6 +277,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const composerRef = useRef<HTMLDivElement | null>(null)
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null)
const nameInputRef = useRef<HTMLInputElement | null>(null)
// Why: the native-file-drop effect below subscribes once on mount and must
// read the latest agentPrompt when computing the caret-scoped insertion.
// Mirror the value into a ref so the listener sees fresh state without
// re-subscribing (which would reorder the composerDropStack and break
// multi-instance routing).
const agentPromptRef = useRef(agentPrompt)
agentPromptRef.current = agentPrompt
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const parsedLinkedIssueNumber = useMemo(
@@ -687,6 +703,115 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [])
// Why: native OS file drops onto the composer are captured by the preload
// bridge (see `data-native-file-drop-target="composer"` markers) and relayed
// as a gesture-scoped IPC event. Files become attachments (matching the
// manual picker behavior); folders are pasted inline at the textarea caret
// so the user can reference them as working directories in their prompt
// without attaching a path we can't embed as file content.
const instanceIdRef = useRef<symbol>(Symbol('composer'))
useEffect(() => {
const instanceId = instanceIdRef.current
composerDropStack.push(instanceId)
const unsubscribe = window.api.ui.onFileDrop((data) => {
if (data.target !== 'composer') {
return
}
// Why: only the top-of-stack composer (most recently mounted) owns the
// drop. Earlier subscribers stay bound to keep their own cleanup tidy
// but short-circuit so the event doesn't double-apply when page+modal
// are both alive.
if (composerDropStack.at(-1) !== instanceId) {
return
}
void (async () => {
const fileAttachments: string[] = []
const folderPaths: string[] = []
for (const filePath of data.paths) {
try {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
const stat = await window.api.fs.stat({ filePath })
if (stat.isDirectory) {
folderPaths.push(filePath)
} else {
fileAttachments.push(filePath)
}
} catch {
// Skip paths we cannot authorize or stat.
}
}
if (fileAttachments.length > 0) {
setAttachmentPaths((current) => {
const next = [...current]
for (const p of fileAttachments) {
if (!next.includes(p)) {
next.push(p)
}
}
return next
})
}
if (folderPaths.length > 0) {
// Why: de-dup within a single drop — the OS occasionally delivers
// the same folder twice when a user drags from a selection that
// includes both the item and its parent, and we don't want to
// insert it multiple times.
const uniqueFolderPaths = Array.from(new Set(folderPaths))
// Why: wrap paths containing shell metacharacters in double quotes
// (and escape embedded quotes) so the inserted text reads as a
// single token if the user pastes it into a terminal. Simple paths
// stay unadorned to match how Finder/Explorer drops appear.
const formatPath = (p: string): string => {
if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) {
return `"${p.replace(/(["\\$`])/g, '\\$1')}"`
}
return p
}
const insertion = uniqueFolderPaths.map(formatPath).join(' ')
const textarea = promptTextareaRef.current
// Why: compute selection, insertion, and caret target OUTSIDE the
// setAgentPrompt updater so the updater stays pure. React Strict
// Mode double-invokes updaters in dev, and batching can delay
// execution — reading `textarea.selectionStart` inside the updater
// risks seeing a shifted caret. Read `agentPromptRef.current` for
// the latest prompt because this effect subscribes once and the
// outer closure's `agentPrompt` would be stale.
const current = agentPromptRef.current
const selStart = textarea?.selectionStart ?? current.length
const selEnd = textarea?.selectionEnd ?? current.length
const before = current.slice(0, selStart)
const after = current.slice(selEnd)
// Why: pad with single spaces when the caret sits directly against
// other text so the folder path doesn't merge into an adjacent word.
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}`
const caret = before.length + padded.length
if (textarea) {
// Restore the caret to the end of the inserted text after React flushes.
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(caret, caret)
})
}
// Why: pass a plain value (not an updater) since `before`/`after`
// were already resolved from `agentPromptRef.current`; this keeps
// the state write side-effect-free under Strict-Mode double-render.
setAgentPrompt(before + padded + after)
}
})()
})
return () => {
unsubscribe()
const idx = composerDropStack.lastIndexOf(instanceId)
if (idx !== -1) {
composerDropStack.splice(idx, 1)
}
}
}, [])
const handlePromptKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>): void => {
const mod = IS_MAC ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey