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": {