From ea23ccbbee68946b2f25548c1bbdb023f11f6eec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:36:01 -0700 Subject: [PATCH] feat(native-chat): show a drop target on the whole chat pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping a file into a chat only worked if you hit the input box, and nothing on screen said so. A drag aimed at the transcript fell through to the terminal behind the chat, which pastes the paths into the hidden TUI. The chat pane shell is now the drop surface. While a drag carrying files is over it, the pane dims behind a card naming what the drop will do; the composer's existing attach logic — workspace, execution-host and SSH-owner checks included — runs unchanged from the wider element. Two routes reach the composer and they are widened differently: - an in-app drag is claimed in the renderer, so the pane calls the composer's handlers through a claim the composer publishes to the surface around it. - an OS drag is delivered by the preload drop route, which consumes the event at `document` before React sees it. The pane widens that route by publishing the composer's scope key as the nearest drop marker, and learns the drag ended from a document-level listener rather than a React drop. The surface lives in the chat portal because that is the one place wrapping both the bridge and structured panes, so neither pane root grows a second copy of this wiring. No composer mounted (a question card owns the input region) means no marker and no overlay, so that drag stays the terminal's. A guarded composer still answers for the drag — that refusal is what keeps it out of the terminal — but the pane does not invite a drop it will refuse. --- .../native-chat/NativeChatComposer.tsx | 4 +- .../native-chat/NativeChatComposerField.tsx | 13 +- .../NativeChatPaneFileDropSurface.tsx | 122 ++++++++++++ ...chat-composer-workspace-file-drop.test.tsx | 90 ++++----- .../native-chat-pane-file-drop.test.tsx | 178 ++++++++++++++++++ .../native-chat/native-chat-pane-file-drop.ts | 97 ++++++++++ .../use-native-chat-workspace-file-drop.ts | 14 ++ .../TerminalPaneNativeChatPortal.tsx | 5 +- src/renderer/src/i18n/locales/en.json | 4 + 9 files changed, 468 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatPaneFileDropSurface.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2f0688dfede..fb0d533a36e 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -174,10 +174,11 @@ const NativeChatComposerPane = forwardRef removeImageAttachment(id)} onAttach={pickAttachment} - workspaceFileDropHandlers={workspaceFileDropHandlers} onDictationToggle={toggleDictation} onDictationHoldStart={startHoldDictation} onDictationHoldEnd={stopHoldDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 5ea20e3d0cb..f51bf4c5400 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,11 +1,6 @@ import { NativeChatPromptEditor } from './NativeChatPromptEditor' import type { NativeChatComposerInput } from './native-chat-composer-input' -import type { - ClipboardEventHandler, - DragEventHandler, - KeyboardEventHandler, - RefObject -} from 'react' +import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' import { useLayoutEffect, useRef } from 'react' import { ImageOff } from 'lucide-react' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' @@ -53,10 +48,6 @@ export type NativeChatComposerFieldProps = { onAcceptMention: () => void onRemoveImageAttachment: (id: string) => void onAttach: () => void - workspaceFileDropHandlers?: { - onDragOverCapture: DragEventHandler - onDropCapture: DragEventHandler - } onDictationToggle: () => void onDictationHoldStart: () => void onDictationHoldEnd: () => void @@ -129,7 +120,6 @@ export function NativeChatComposerField({ onAcceptMention, onRemoveImageAttachment, onAttach, - workspaceFileDropHandlers, onDictationToggle, onDictationHoldStart, onDictationHoldEnd, @@ -195,7 +185,6 @@ export function NativeChatComposerField({ ) : null}
NativeChatPaneDropClaim + scopeKey: string +} + +type RegisterPaneDropClaim = (registration: NativeChatPaneDropRegistration) => () => void + +const NativeChatPaneFileDropContext = createContext(null) + +/** + * Publishes the composer's drop claim to the pane around it, so the whole chat + * pane — not just the input box — is the target a file can be dropped on. + */ +export function useNativeChatPaneFileDropClaim(claim: NativeChatPaneDropClaim): void { + const register = useContext(NativeChatPaneFileDropContext) + const claimRef = useRef(claim) + useLayoutEffect(() => { + claimRef.current = claim + }) + const { scopeKey } = claim + const registration = useMemo( + () => ({ getClaim: () => claimRef.current, scopeKey }), + [scopeKey] + ) + useLayoutEffect(() => register?.(registration), [register, registration]) +} + +export function NativeChatPaneFileDropSurface({ + className, + children +}: { + className: string + children: React.ReactNode +}): React.JSX.Element { + const [registration, setRegistration] = useState(null) + const [isDragActive, setIsDragActive] = useState(false) + const register = useMemo( + () => (next) => { + setRegistration(next) + return () => { + setRegistration((current) => (current === next ? null : current)) + setIsDragActive(false) + } + }, + [] + ) + const registrationRef = useRef(registration) + registrationRef.current = registration + const handlers = useMemo( + () => + makeNativeChatPaneFileDropHandlers({ + getClaim: () => registrationRef.current?.getClaim() ?? null, + setDragActive: setIsDragActive + }), + [] + ) + // An OS drag never reaches React: the preload drop route consumes that event + // before it leaves `document`. Its end is the only signal the overlay gets. + useLayoutEffect(() => { + if (!isDragActive) { + return + } + const clear = (): void => setIsDragActive(false) + document.addEventListener('drop', clear, true) + document.addEventListener('dragend', clear, true) + return () => { + document.removeEventListener('drop', clear, true) + document.removeEventListener('dragend', clear, true) + } + }, [isDragActive]) + + return ( + +
+ {children} + {isDragActive ? : null} +
+
+ ) +} + +function NativeChatPaneFileDropOverlay(): React.JSX.Element { + return ( +
+
+ + + + + {translate('components.native-chat.drop.title', 'Drop to attach to this chat')} + + + {translate( + 'components.native-chat.drop.subtitle', + 'Files are added to your message as paths the agent can read.' + )} + +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx index 015c9e9ab75..898119801ab 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx @@ -151,6 +151,7 @@ function ComposerProbe({ terminalTabId: 'terminal-tab-1', structuredWorktreeId: structured ? (structuredWorkspaceId ?? workspaceId) : undefined, disabled, + paneKey: `pane:${workspaceId}`, attachResolvedPaths: attachments.attachResolvedPaths, setNotice }) @@ -160,49 +161,52 @@ function ComposerProbe({ return (
- { - setDraft(value) - setCaret(input.selectionStart ?? value.length) - }} - onTextareaSelect={(input) => setCaret(input.selectionStart ?? input.value.length)} - onKeyDown={() => {}} - onImeSettled={(input) => { - setDraft(input.value) - attachments.flushPendingAttachments() - }} - onPaste={() => {}} - pickerListboxId="picker" - onChoosePickerItem={() => {}} - onRetrySkills={() => {}} - onAcceptMention={() => {}} - onRemoveImageAttachment={attachments.removeImageAttachment} - onAttach={() => {}} - workspaceFileDropHandlers={workspaceFileDropHandlers} - onDictationToggle={() => {}} - onDictationHoldStart={() => {}} - onDictationHoldEnd={() => {}} - onSend={() => {}} - sessionOptionsSurface={null} - sessionOptionsSnapshot={[]} - /> + {/* The pane around the composer mounts these in production; here they sit + on a bare wrapper so the drop logic is exercised on its own. */} +
+ { + setDraft(value) + setCaret(input.selectionStart ?? value.length) + }} + onTextareaSelect={(input) => setCaret(input.selectionStart ?? input.value.length)} + onKeyDown={() => {}} + onImeSettled={(input) => { + setDraft(input.value) + attachments.flushPendingAttachments() + }} + onPaste={() => {}} + pickerListboxId="picker" + onChoosePickerItem={() => {}} + onRetrySkills={() => {}} + onAcceptMention={() => {}} + onRemoveImageAttachment={attachments.removeImageAttachment} + onAttach={() => {}} + onDictationToggle={() => {}} + onDictationHoldStart={() => {}} + onDictationHoldEnd={() => {}} + onSend={() => {}} + sessionOptionsSurface={null} + sessionOptionsSnapshot={[]} + /> +
{draft}
) diff --git a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx new file mode 100644 index 00000000000..02e7d39f05a --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import { + NativeChatPaneFileDropSurface, + useNativeChatPaneFileDropClaim +} from './NativeChatPaneFileDropSurface' +import { nativeChatPaneDragKind } from './native-chat-pane-file-drop' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +const OVERLAY = '[data-native-chat-drop-overlay="true"]' +const IGNORE_DRAG = (): void => {} + +class DropDataTransfer { + dropEffect = 'none' + effectAllowed = 'all' + private readonly data = new Map() + get types(): string[] { + return [...this.data.keys()] + } + setData(type: string, value: string): void { + this.data.set(type, value) + } + getData(type: string): string { + return this.data.get(type) ?? '' + } +} + +function workspaceDrag(): DropDataTransfer { + const transfer = new DropDataTransfer() + transfer.setData(WORKSPACE_FILE_PATH_MIME, '/repo/a.ts') + return transfer +} + +function osDrag(): DropDataTransfer { + const transfer = new DropDataTransfer() + transfer.setData('Files', '') + return transfer +} + +/** Mounts the composer's claim the way the real composer hook does. */ +function ClaimingComposer({ + disabled = false, + onDragOverCapture = IGNORE_DRAG, + onDropCapture = IGNORE_DRAG +}: { + disabled?: boolean + onDragOverCapture?: (event: React.DragEvent) => void + onDropCapture?: (event: React.DragEvent) => void +}) { + useNativeChatPaneFileDropClaim({ + scopeKey: 'pane:1', + disabled, + onDragOverCapture, + onDropCapture + }) + return
+} + +function renderPane(composer: React.ReactNode) { + const result = render( +
+ +
transcript
+ {composer} +
+
+ ) + return { ...result, transcript: screen.getByTestId('transcript') } +} + +function fireDrag( + target: HTMLElement, + type: 'dragenter' | 'dragover' | 'dragleave' | 'drop', + dataTransfer: DropDataTransfer, + relatedTarget: EventTarget | null = null +): void { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }) + Object.defineProperty(event, 'relatedTarget', { value: relatedTarget }) + act(() => { + target.dispatchEvent(event) + }) +} + +afterEach(cleanup) + +describe('nativeChatPaneDragKind', () => { + it('separates an in-app file drag from an OS file drag', () => { + expect(nativeChatPaneDragKind(workspaceDrag())).toBe('workspace') + expect(nativeChatPaneDragKind(osDrag())).toBe('os') + expect(nativeChatPaneDragKind(new DropDataTransfer())).toBeNull() + }) + + it('reads an OS-looking drag that carries in-app paths as the in-app drag', () => { + const transfer = osDrag() + transfer.setData(WORKSPACE_FILE_PATH_MIME, '/repo/a.ts') + expect(nativeChatPaneDragKind(transfer)).toBe('workspace') + }) +}) + +describe('NativeChatPaneFileDropSurface', () => { + it('routes a drop on the transcript to the composer that claimed the pane', () => { + const onDropCapture = vi.fn() + const { transcript, container } = renderPane() + + fireDrag(transcript, 'dragover', workspaceDrag()) + expect(container.querySelector(OVERLAY)).not.toBeNull() + + fireDrag(transcript, 'drop', workspaceDrag()) + expect(onDropCapture).toHaveBeenCalledTimes(1) + expect(container.querySelector(OVERLAY)).toBeNull() + }) + + it('publishes the claiming composer as the OS drop route target', () => { + const { container } = renderPane() + const surface = container.querySelector('.pane') + expect(surface?.getAttribute('data-native-file-drop-target')).toBe('composer') + expect(surface?.getAttribute('data-composer-scope-key')).toBe('pane:1') + }) + + it('leaves the pane to the terminal behind it while no composer is mounted', () => { + const { transcript, container } = renderPane(null) + fireDrag(transcript, 'dragover', workspaceDrag()) + expect(container.querySelector(OVERLAY)).toBeNull() + expect(container.querySelector('.pane')?.hasAttribute('data-native-file-drop-target')).toBe( + false + ) + }) + + it('does not invite a drop the guarded composer will refuse', () => { + const onDragOverCapture = vi.fn() + const { transcript, container } = renderPane( + + ) + fireDrag(transcript, 'dragover', workspaceDrag()) + expect(container.querySelector(OVERLAY)).toBeNull() + // The composer still answers for the drag: that refusal is what keeps it + // out of the terminal behind the chat. + expect(onDragOverCapture).toHaveBeenCalledTimes(1) + }) + + it('shows the overlay for an OS drag without claiming its drop', () => { + const onDropCapture = vi.fn() + const { transcript, container } = renderPane() + fireDrag(transcript, 'dragover', osDrag()) + expect(container.querySelector(OVERLAY)).not.toBeNull() + fireDrag(transcript, 'drop', osDrag()) + expect(onDropCapture).not.toHaveBeenCalled() + }) + + it('keeps the overlay up while the cursor crosses children, and drops it on exit', () => { + const { transcript, container } = renderPane() + fireDrag(transcript, 'dragover', workspaceDrag()) + + fireDrag(transcript, 'dragleave', workspaceDrag(), screen.getByTestId('composer')) + expect(container.querySelector(OVERLAY)).not.toBeNull() + + fireDrag(transcript, 'dragleave', workspaceDrag(), screen.getByTestId('terminal-behind')) + expect(container.querySelector(OVERLAY)).toBeNull() + }) + + it('clears an OS drag overlay from the document drop the preload route consumes', () => { + const { transcript, container } = renderPane() + fireDrag(transcript, 'dragover', osDrag()) + expect(container.querySelector(OVERLAY)).not.toBeNull() + + // The preload listener stops this event at `document`, so the surface never + // sees it as a React drop. + act(() => { + document.dispatchEvent(new Event('drop', { bubbles: false })) + }) + expect(container.querySelector(OVERLAY)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts new file mode 100644 index 00000000000..e0ebfe4243f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pane-file-drop.ts @@ -0,0 +1,97 @@ +import { hasWorkspaceFileDragType } from '@/lib/workspace-file-drag' +import { hasNativeFileDragTypes } from '../../../../shared/native-file-drop' + +/** What a drag hovering the chat pane is carrying. + * `workspace`: an in-app file drag, attached by the composer's own handlers. + * `os`: a Finder/Explorer drag, delivered by the preload drop route instead. */ +export type NativeChatPaneDragKind = 'os' | 'workspace' + +export type NativeChatPaneDragEvent = { + currentTarget: { contains: (node: Node | null) => boolean } + dataTransfer: Pick + relatedTarget: EventTarget | null +} + +/** The composer's drop claim, published while a composer is mounted in the pane. */ +export type NativeChatPaneDropClaim = { + /** Composer identity the preload drop route addresses (`data-composer-scope-key`). */ + scopeKey: string + /** A guarded composer refuses the drop, so the pane must not invite one. */ + disabled: boolean + onDragOverCapture: (event: React.DragEvent) => void + onDropCapture: (event: React.DragEvent) => void +} + +export function nativeChatPaneDragKind( + dataTransfer: Pick | null +): NativeChatPaneDragKind | null { + if (!dataTransfer) { + return null + } + if (hasWorkspaceFileDragType(dataTransfer)) { + return 'workspace' + } + return hasNativeFileDragTypes(dataTransfer.types) ? 'os' : null +} + +/** True while the cursor only crossed between children of the drop surface — + * the leave that follows every child boundary must not end the drag state. */ +export function movedWithinDropSurface(event: NativeChatPaneDragEvent): boolean { + const enteredNode = event.relatedTarget + return enteredNode instanceof Node && event.currentTarget.contains(enteredNode) +} + +/** + * Decides what the pane does with a drag, given what the mounted composer will + * accept. Only a workspace drag is claimed here: an OS drag reaches the composer + * through the preload route, which already consumed the drop event by the time + * React would see it, so the pane's part of that route is the overlay alone. + */ +export function makeNativeChatPaneFileDropHandlers(host: { + getClaim: () => NativeChatPaneDropClaim | null + setDragActive: (active: boolean) => void +}): { + onDragEnterCapture: (event: React.DragEvent) => void + onDragLeaveCapture: (event: React.DragEvent) => void + onDragOverCapture: (event: React.DragEvent) => void + onDropCapture: (event: React.DragEvent) => void +} { + const showsDropTarget = (event: NativeChatPaneDragEvent): boolean => { + const kind = nativeChatPaneDragKind(event.dataTransfer) + if (kind === null) { + return false + } + const claim = host.getClaim() + // No composer (a question card owns the input region) means no attachment + // target, so the drag stays the terminal's the way it is today. + return claim !== null && !claim.disabled + } + + return { + onDragEnterCapture(event) { + if (showsDropTarget(event)) { + host.setDragActive(true) + } + }, + onDragOverCapture(event) { + if (showsDropTarget(event)) { + host.setDragActive(true) + } + if (nativeChatPaneDragKind(event.dataTransfer) === 'workspace') { + host.getClaim()?.onDragOverCapture(event) + } + }, + onDragLeaveCapture(event) { + if (nativeChatPaneDragKind(event.dataTransfer) === null || movedWithinDropSurface(event)) { + return + } + host.setDragActive(false) + }, + onDropCapture(event) { + host.setDragActive(false) + if (nativeChatPaneDragKind(event.dataTransfer) === 'workspace') { + host.getClaim()?.onDropCapture(event) + } + } + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts b/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts index ee03aa6849c..cc37d7783a2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts @@ -18,6 +18,7 @@ import { nativeChatWorkspaceAttachmentMismatchNotice, type NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' +import { useNativeChatPaneFileDropClaim } from './NativeChatPaneFileDropSurface' type WorkspaceFileDropHandlers = { onDragOverCapture: DragEventHandler @@ -31,6 +32,9 @@ type Args = { options?: NativeChatResolvedPathOptions ) => void disabled: boolean + /** Composer identity the preload drop route addresses; published to the pane + * so an OS drop anywhere in it resolves to this composer. */ + paneKey: string setNotice: (notice: string | null) => void structuredWorktreeId?: string terminalTabId: string @@ -63,6 +67,7 @@ function setDropEffect(dataTransfer: DataTransfer, effect: 'copy' | 'none'): voi export function useNativeChatWorkspaceFileDrop({ attachResolvedPaths, disabled, + paneKey, setNotice, structuredWorktreeId, terminalTabId @@ -163,5 +168,14 @@ export function useNativeChatWorkspaceFileDrop({ [attachResolvedPaths, disabled, setNotice, structuredWorktreeId, terminalTabId] ) + // The pane around the composer is the drop surface; these handlers run from + // there so the whole chat, not just the input box, accepts a file. + useNativeChatPaneFileDropClaim({ + scopeKey: paneKey, + disabled, + onDragOverCapture, + onDropCapture + }) + return { onDragOverCapture, onDropCapture } } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index fd21e73ff68..1cebc980c31 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -1,5 +1,6 @@ import { createPortal } from 'react-dom' import NativeChatView from '../native-chat/NativeChatView' +import { NativeChatPaneFileDropSurface } from '../native-chat/NativeChatPaneFileDropSurface' import { makePaneKey } from '../../../../shared/stable-pane-id' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import type { TerminalPaneController } from './use-terminal-pane-controller' @@ -68,7 +69,7 @@ export function TerminalPaneNativeChatPortal({ } return createPortal( -
+ {structuredSessionId && structuredChatAgent ? ( )} -
, + , chatPane.container, `native-chat-${tabId}-${chatPane.leafId}` ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f2c94ba41da..bc48b2ced5e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17304,6 +17304,10 @@ "conversationCommand": { "pendingWork": "Wait for pending work and messages to finish before using this command.", "unconfirmed": "Conversation operation was not confirmed." + }, + "drop": { + "title": "Drop to attach to this chat", + "subtitle": "Files are added to your message as paths the agent can read." } }, "tab": {