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': '语音助手',