Skip to content

Commit a0259b5

Browse files
swear01HAPIclaude
authored
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>
1 parent b1910b6 commit a0259b5

7 files changed

Lines changed: 266 additions & 2 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, fireEvent } from '@testing-library/react'
3+
4+
const addAttachment = vi.fn()
5+
6+
vi.mock('@assistant-ui/react', () => ({
7+
useAssistantApi: () => ({
8+
composer: () => ({ addAttachment }),
9+
}),
10+
}))
11+
12+
vi.mock('@/lib/use-translation', () => ({
13+
useTranslation: () => ({ t: (key: string) => key }),
14+
}))
15+
16+
import { DragDropZone } from './DragDropZone'
17+
18+
function createDropEvent(types: string[], files: File[]): Event {
19+
const event = new Event('drop', { bubbles: true, cancelable: true })
20+
Object.defineProperty(event, 'dataTransfer', {
21+
value: { types, files },
22+
configurable: true,
23+
})
24+
return event
25+
}
26+
27+
describe('DragDropZone drop handling', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
})
31+
32+
it('adds dropped files as attachments and cancels the browser default', () => {
33+
const { container } = render(
34+
<DragDropZone>
35+
<div />
36+
</DragDropZone>
37+
)
38+
const zone = container.firstChild as HTMLElement
39+
const file = new File(['x'], 'a.txt', { type: 'text/plain' })
40+
const event = createDropEvent(['Files'], [file])
41+
42+
fireEvent(zone, event)
43+
44+
expect(event.defaultPrevented).toBe(true)
45+
expect(addAttachment).toHaveBeenCalledTimes(1)
46+
expect(addAttachment).toHaveBeenCalledWith(file)
47+
})
48+
49+
it('ignores non-file drops so the browser keeps its default (e.g. text into composer)', () => {
50+
const { container } = render(
51+
<DragDropZone>
52+
<div />
53+
</DragDropZone>
54+
)
55+
const zone = container.firstChild as HTMLElement
56+
const event = createDropEvent(['text/plain'], [])
57+
58+
fireEvent(zone, event)
59+
60+
expect(event.defaultPrevented).toBe(false)
61+
expect(addAttachment).not.toHaveBeenCalled()
62+
})
63+
64+
it('does not attach when disabled but still cancels the file default', () => {
65+
const { container } = render(
66+
<DragDropZone disabled>
67+
<div />
68+
</DragDropZone>
69+
)
70+
const zone = container.firstChild as HTMLElement
71+
const file = new File(['x'], 'a.txt', { type: 'text/plain' })
72+
const event = createDropEvent(['Files'], [file])
73+
74+
fireEvent(zone, event)
75+
76+
expect(event.defaultPrevented).toBe(true)
77+
expect(addAttachment).not.toHaveBeenCalled()
78+
})
79+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useCallback } from 'react'
2+
import { useAssistantApi } from '@assistant-ui/react'
3+
import { useDragOver } from '@/hooks/useDragOver'
4+
import { useTranslation } from '@/lib/use-translation'
5+
6+
export function DragDropZone({
7+
children,
8+
disabled,
9+
}: {
10+
children: React.ReactNode
11+
disabled?: boolean
12+
}) {
13+
const api = useAssistantApi()
14+
const isDragging = useDragOver()
15+
const { t } = useTranslation()
16+
17+
const onDragOver = useCallback((e: React.DragEvent) => {
18+
if (e.dataTransfer.types.includes('Files')) {
19+
e.preventDefault()
20+
e.dataTransfer.dropEffect = disabled ? 'none' : 'copy'
21+
}
22+
}, [disabled])
23+
24+
const onDrop = useCallback(
25+
async (e: React.DragEvent) => {
26+
// Let non-file drops (e.g. selected text into the composer) keep
27+
// their default browser behaviour instead of being cancelled.
28+
if (!e.dataTransfer.types.includes('Files')) return
29+
e.preventDefault()
30+
if (disabled) return
31+
const files = Array.from(e.dataTransfer.files)
32+
if (files.length === 0) return
33+
try {
34+
for (const file of files) {
35+
await api.composer().addAttachment(file)
36+
}
37+
} catch (error) {
38+
console.error('Error adding dragged file:', error)
39+
}
40+
},
41+
[api, disabled]
42+
)
43+
44+
return (
45+
<div
46+
className="relative flex min-h-0 flex-1 flex-col"
47+
onDragOver={onDragOver}
48+
onDrop={onDrop}
49+
>
50+
{children}
51+
{isDragging && !disabled && (
52+
<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">
53+
<div className="rounded-lg bg-[var(--app-bg)] px-4 py-2 text-sm font-medium text-[var(--app-link)] shadow-lg">
54+
{t('composer.dropToAttach')}
55+
</div>
56+
</div>
57+
)}
58+
</div>
59+
)
60+
}

web/src/components/SessionChat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { useNavigate } from '@tanstack/react-router'
33
import { AssistantRuntimeProvider, useAssistantApi, useAssistantState } from '@assistant-ui/react'
4+
import { DragDropZone } from '@/components/AssistantChat/DragDropZone'
45
import type { ApiClient } from '@/api/client'
56
import type {
67
AttachmentMetadata,
@@ -1137,7 +1138,8 @@ function SessionChatInner(props: SessionChatProps) {
11371138

11381139
<AssistantRuntimeProvider runtime={runtime}>
11391140
<ShareSeedConsumer sessionId={props.session.id} sessionActive={props.session.active} />
1140-
<div className="relative flex min-h-0 flex-1 flex-col">
1141+
<DragDropZone disabled={sessionInactive || props.isSending || pendingSchedule != null}>
1142+
11411143
<HappyThread
11421144
// Key with prefix: different components under the same session
11431145
// (thread, scratchlist, composer) must have distinct keys to avoid
@@ -1337,7 +1339,7 @@ function SessionChatInner(props: SessionChatProps) {
13371339
sendError={props.sendError ?? null}
13381340
onClearSendError={props.onClearSendError}
13391341
/>
1340-
</div>
1342+
</DragDropZone>
13411343
</AssistantRuntimeProvider>
13421344

13431345
{/* Voice session component - renders nothing but initializes voice backend */}

web/src/hooks/useDragOver.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useDragOver } from './useDragOver'
4+
5+
function makeDragEvent(type: string, types: string[]): Event {
6+
const event = new Event(type, { bubbles: true, cancelable: true })
7+
Object.defineProperty(event, 'dataTransfer', {
8+
value: { types },
9+
configurable: true,
10+
})
11+
return event
12+
}
13+
14+
describe('useDragOver', () => {
15+
it('prevents the browser default when a file is dropped outside a zone', () => {
16+
// Regression: a file dropped on the document (e.g. the sidebar) must not
17+
// trigger the browser's file-open/navigation behaviour.
18+
const { unmount } = renderHook(() => useDragOver())
19+
const event = makeDragEvent('drop', ['Files'])
20+
act(() => {
21+
document.dispatchEvent(event)
22+
})
23+
expect(event.defaultPrevented).toBe(true)
24+
unmount()
25+
})
26+
27+
it('does not prevent default for a non-file drop', () => {
28+
const { unmount } = renderHook(() => useDragOver())
29+
const event = makeDragEvent('drop', ['text/plain'])
30+
act(() => {
31+
document.dispatchEvent(event)
32+
})
33+
expect(event.defaultPrevented).toBe(false)
34+
unmount()
35+
})
36+
37+
it('also prevents default on dragover for files so the drop can be cancelled', () => {
38+
const { unmount } = renderHook(() => useDragOver())
39+
const event = makeDragEvent('dragover', ['Files'])
40+
act(() => {
41+
document.dispatchEvent(event)
42+
})
43+
expect(event.defaultPrevented).toBe(true)
44+
unmount()
45+
})
46+
47+
it('tracks file-drag state and clears it on drop', () => {
48+
const { result, unmount } = renderHook(() => useDragOver())
49+
expect(result.current).toBe(false)
50+
51+
act(() => {
52+
document.dispatchEvent(makeDragEvent('dragenter', ['Files']))
53+
})
54+
expect(result.current).toBe(true)
55+
56+
act(() => {
57+
document.dispatchEvent(makeDragEvent('drop', ['Files']))
58+
})
59+
expect(result.current).toBe(false)
60+
unmount()
61+
})
62+
})

web/src/hooks/useDragOver.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useEffect, useState } from 'react'
2+
3+
/**
4+
* Returns true while the user is dragging files over the browser window.
5+
* Also suppresses the browser's default file-open behaviour for drags that
6+
* land outside an explicit drop zone.
7+
*/
8+
export function useDragOver(): boolean {
9+
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
10+
11+
useEffect(() => {
12+
const onDragEnter = (e: DragEvent) => {
13+
if (e.dataTransfer?.types.includes('Files')) {
14+
setIsDraggingFiles(true)
15+
}
16+
}
17+
18+
// Only clear when the drag leaves the browser window entirely
19+
// (relatedTarget === null means the pointer moved outside the document)
20+
const onDragLeave = (e: DragEvent) => {
21+
if (e.relatedTarget === null) {
22+
setIsDraggingFiles(false)
23+
}
24+
}
25+
26+
const clearDrag = () => setIsDraggingFiles(false)
27+
28+
// Prevent the browser from opening/navigating to a file dropped outside
29+
// an explicit drop zone (e.g. the sidebar). This must run on BOTH
30+
// `dragover` and `drop`: preventing only `dragover` still lets the
31+
// browser perform its default file-open action on the `drop` event.
32+
const preventFileDefault = (e: DragEvent) => {
33+
if (e.dataTransfer?.types.includes('Files')) {
34+
e.preventDefault()
35+
}
36+
}
37+
38+
const onDrop = (e: DragEvent) => {
39+
preventFileDefault(e)
40+
clearDrag()
41+
}
42+
43+
document.addEventListener('dragenter', onDragEnter)
44+
document.addEventListener('dragleave', onDragLeave)
45+
document.addEventListener('dragend', clearDrag)
46+
document.addEventListener('drop', onDrop)
47+
document.addEventListener('dragover', preventFileDefault)
48+
49+
return () => {
50+
document.removeEventListener('dragenter', onDragEnter)
51+
document.removeEventListener('dragleave', onDragLeave)
52+
document.removeEventListener('dragend', clearDrag)
53+
document.removeEventListener('drop', onDrop)
54+
document.removeEventListener('dragover', preventFileDefault)
55+
}
56+
}, [])
57+
58+
return isDraggingFiles
59+
}

web/src/lib/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ export default {
412412
'composer.abort': 'Abort',
413413
'composer.switchRemote': 'Switch to remote mode',
414414
'composer.attach': 'Attach file',
415+
'composer.dropToAttach': 'Drop to attach',
415416
'composer.send': 'Send',
416417
'composer.stop': 'Stop',
417418
'composer.voice': 'Voice assistant',

web/src/lib/locales/zh-CN.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ export default {
416416
'composer.abort': '中止',
417417
'composer.switchRemote': '切换到远程模式',
418418
'composer.attach': '添加文件',
419+
'composer.dropToAttach': '松开以添加文件',
419420
'composer.send': '发送',
420421
'composer.stop': '停止',
421422
'composer.voice': '语音助手',

0 commit comments

Comments
 (0)