feat(web): drag-and-drop files onto chat panel to add as attachments (#936)

* feat(web): drag-and-drop files onto chat panel to add as attachments

Closes #935

Adds a `useDragOver` hook that detects when a file is being dragged over
the browser window and suppresses the browser's default file-open
behaviour for drops outside the accept zone.

A new `DragDropZone` component wraps the inner `AssistantRuntimeProvider`
content in `SessionChat`. It shows a semi-transparent overlay (dashed
border + "Drop to attach" label) on the right-side chat panel as soon as
any file drag is detected — regardless of where the pointer is on the
page. Dropping on the right panel adds the files as composer attachments
via the existing `api.composer().addAttachment()` path. Drops on the left
sidebar are suppressed (no navigation, no attachment).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): disable drag-drop zone when pendingSchedule is active

The backend rejects requests with both scheduledAt and attachments.
DragDropZone now respects pendingSchedule the same way paste and the
attach button do — disabled=true suppresses the overlay, sets
dropEffect='none', and skips addAttachment on drop.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): harden drag-drop default-action handling

Address HAPI Bot review on #936:

- useDragOver: cancel the browser's default file-open/navigation on the
  document-level `drop` event for file payloads, not only on `dragover`.
  Preventing default on `dragover` alone still lets the browser open a
  file dropped outside any zone (e.g. the sidebar), which could unload
  the app.
- DragDropZone: only preventDefault when the drop payload actually
  contains files, so non-file drops (e.g. dragging selected text into
  the composer) keep their default browser behaviour.

Add regression tests for both.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(web): use Simplified Chinese for composer.dropToAttach in zh-CN

Address HAPI Bot review on #936: the new zh-CN string used Traditional
Chinese forms (放開以附加檔案) in the Simplified Chinese locale, which is
inconsistent with neighbouring keys (e.g. composer.attach = 添加文件).
Use 松开以添加文件 to match.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-19 17:37:04 +08:00
committed by GitHub
co-authored by HAPI Claude Opus 4.8
parent b1910b6b2e
commit a0259b531e
7 changed files with 266 additions and 2 deletions
@@ -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(
<DragDropZone>
<div />
</DragDropZone>
)
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(
<DragDropZone>
<div />
</DragDropZone>
)
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(
<DragDropZone disabled>
<div />
</DragDropZone>
)
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()
})
})
@@ -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 (
<div
className="relative flex min-h-0 flex-1 flex-col"
onDragOver={onDragOver}
onDrop={onDrop}
>
{children}
{isDragging && !disabled && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-[var(--app-link)] bg-[var(--app-link)]/10">
<div className="rounded-lg bg-[var(--app-bg)] px-4 py-2 text-sm font-medium text-[var(--app-link)] shadow-lg">
{t('composer.dropToAttach')}
</div>
</div>
)}
</div>
)
}
+4 -2
View File
@@ -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) {
<AssistantRuntimeProvider runtime={runtime}>
<ShareSeedConsumer sessionId={props.session.id} sessionActive={props.session.active} />
<div className="relative flex min-h-0 flex-1 flex-col">
<DragDropZone disabled={sessionInactive || props.isSending || pendingSchedule != null}>
<HappyThread
// Key with prefix: different components under the same session
// (thread, scratchlist, composer) must have distinct keys to avoid
@@ -1337,7 +1339,7 @@ function SessionChatInner(props: SessionChatProps) {
sendError={props.sendError ?? null}
onClearSendError={props.onClearSendError}
/>
</div>
</DragDropZone>
</AssistantRuntimeProvider>
{/* Voice session component - renders nothing but initializes voice backend */}
+62
View File
@@ -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()
})
})
+59
View File
@@ -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
}
+1
View File
@@ -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',
+1
View File
@@ -416,6 +416,7 @@ export default {
'composer.abort': '中止',
'composer.switchRemote': '切换到远程模式',
'composer.attach': '添加文件',
'composer.dropToAttach': '松开以添加文件',
'composer.send': '发送',
'composer.stop': '停止',
'composer.voice': '语音助手',