mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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:
co-authored by
HAPI
Claude Opus 4.8
parent
b1910b6b2e
commit
a0259b531e
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user