diff --git a/web/src/components/AssistantChat/DragDropZone.test.tsx b/web/src/components/AssistantChat/DragDropZone.test.tsx new file mode 100644 index 00000000..6f3737d7 --- /dev/null +++ b/web/src/components/AssistantChat/DragDropZone.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, fireEvent } from '@testing-library/react' + +const addAttachment = vi.fn() + +vi.mock('@assistant-ui/react', () => ({ + useAssistantApi: () => ({ + composer: () => ({ addAttachment }), + }), +})) + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { DragDropZone } from './DragDropZone' + +function createDropEvent(types: string[], files: File[]): Event { + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types, files }, + configurable: true, + }) + return event +} + +describe('DragDropZone drop handling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('adds dropped files as attachments and cancels the browser default', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const event = createDropEvent(['Files'], [file]) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(true) + expect(addAttachment).toHaveBeenCalledTimes(1) + expect(addAttachment).toHaveBeenCalledWith(file) + }) + + it('ignores non-file drops so the browser keeps its default (e.g. text into composer)', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const event = createDropEvent(['text/plain'], []) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(false) + expect(addAttachment).not.toHaveBeenCalled() + }) + + it('does not attach when disabled but still cancels the file default', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const event = createDropEvent(['Files'], [file]) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(true) + expect(addAttachment).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/AssistantChat/DragDropZone.tsx b/web/src/components/AssistantChat/DragDropZone.tsx new file mode 100644 index 00000000..102ac5d1 --- /dev/null +++ b/web/src/components/AssistantChat/DragDropZone.tsx @@ -0,0 +1,60 @@ +import { useCallback } from 'react' +import { useAssistantApi } from '@assistant-ui/react' +import { useDragOver } from '@/hooks/useDragOver' +import { useTranslation } from '@/lib/use-translation' + +export function DragDropZone({ + children, + disabled, +}: { + children: React.ReactNode + disabled?: boolean +}) { + const api = useAssistantApi() + const isDragging = useDragOver() + const { t } = useTranslation() + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault() + e.dataTransfer.dropEffect = disabled ? 'none' : 'copy' + } + }, [disabled]) + + const onDrop = useCallback( + async (e: React.DragEvent) => { + // Let non-file drops (e.g. selected text into the composer) keep + // their default browser behaviour instead of being cancelled. + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + if (disabled) return + const files = Array.from(e.dataTransfer.files) + if (files.length === 0) return + try { + for (const file of files) { + await api.composer().addAttachment(file) + } + } catch (error) { + console.error('Error adding dragged file:', error) + } + }, + [api, disabled] + ) + + return ( +
+ {children} + {isDragging && !disabled && ( +
+
+ {t('composer.dropToAttach')} +
+
+ )} +
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 8edc884d..57eedc3b 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider, useAssistantApi, useAssistantState } from '@assistant-ui/react' +import { DragDropZone } from '@/components/AssistantChat/DragDropZone' import type { ApiClient } from '@/api/client' import type { AttachmentMetadata, @@ -1137,7 +1138,8 @@ function SessionChatInner(props: SessionChatProps) { -
+ + -
+
{/* Voice session component - renders nothing but initializes voice backend */} diff --git a/web/src/hooks/useDragOver.test.ts b/web/src/hooks/useDragOver.test.ts new file mode 100644 index 00000000..6a98a47b --- /dev/null +++ b/web/src/hooks/useDragOver.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDragOver } from './useDragOver' + +function makeDragEvent(type: string, types: string[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types }, + configurable: true, + }) + return event +} + +describe('useDragOver', () => { + it('prevents the browser default when a file is dropped outside a zone', () => { + // Regression: a file dropped on the document (e.g. the sidebar) must not + // trigger the browser's file-open/navigation behaviour. + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('drop', ['Files']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + unmount() + }) + + it('does not prevent default for a non-file drop', () => { + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('drop', ['text/plain']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(false) + unmount() + }) + + it('also prevents default on dragover for files so the drop can be cancelled', () => { + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('dragover', ['Files']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + unmount() + }) + + it('tracks file-drag state and clears it on drop', () => { + const { result, unmount } = renderHook(() => useDragOver()) + expect(result.current).toBe(false) + + act(() => { + document.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + }) + expect(result.current).toBe(true) + + act(() => { + document.dispatchEvent(makeDragEvent('drop', ['Files'])) + }) + expect(result.current).toBe(false) + unmount() + }) +}) diff --git a/web/src/hooks/useDragOver.ts b/web/src/hooks/useDragOver.ts new file mode 100644 index 00000000..6a53e90e --- /dev/null +++ b/web/src/hooks/useDragOver.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' + +/** + * Returns true while the user is dragging files over the browser window. + * Also suppresses the browser's default file-open behaviour for drags that + * land outside an explicit drop zone. + */ +export function useDragOver(): boolean { + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (e.dataTransfer?.types.includes('Files')) { + setIsDraggingFiles(true) + } + } + + // Only clear when the drag leaves the browser window entirely + // (relatedTarget === null means the pointer moved outside the document) + const onDragLeave = (e: DragEvent) => { + if (e.relatedTarget === null) { + setIsDraggingFiles(false) + } + } + + const clearDrag = () => setIsDraggingFiles(false) + + // Prevent the browser from opening/navigating to a file dropped outside + // an explicit drop zone (e.g. the sidebar). This must run on BOTH + // `dragover` and `drop`: preventing only `dragover` still lets the + // browser perform its default file-open action on the `drop` event. + const preventFileDefault = (e: DragEvent) => { + if (e.dataTransfer?.types.includes('Files')) { + e.preventDefault() + } + } + + const onDrop = (e: DragEvent) => { + preventFileDefault(e) + clearDrag() + } + + document.addEventListener('dragenter', onDragEnter) + document.addEventListener('dragleave', onDragLeave) + document.addEventListener('dragend', clearDrag) + document.addEventListener('drop', onDrop) + document.addEventListener('dragover', preventFileDefault) + + return () => { + document.removeEventListener('dragenter', onDragEnter) + document.removeEventListener('dragleave', onDragLeave) + document.removeEventListener('dragend', clearDrag) + document.removeEventListener('drop', onDrop) + document.removeEventListener('dragover', preventFileDefault) + } + }, []) + + return isDraggingFiles +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 60b119bc..dcaeeb20 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -412,6 +412,7 @@ export default { 'composer.abort': 'Abort', 'composer.switchRemote': 'Switch to remote mode', 'composer.attach': 'Attach file', + 'composer.dropToAttach': 'Drop to attach', 'composer.send': 'Send', 'composer.stop': 'Stop', 'composer.voice': 'Voice assistant', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a44efebd..7508af10 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -416,6 +416,7 @@ export default { 'composer.abort': '中止', 'composer.switchRemote': '切换到远程模式', 'composer.attach': '添加文件', + 'composer.dropToAttach': '松开以添加文件', 'composer.send': '发送', 'composer.stop': '停止', 'composer.voice': '语音助手',