feat(desktop): drop files anywhere in the chat area (#36262)

* feat(desktop): drop files anywhere in the chat area

File drops were only wired to the composer input. Add a reusable
useFileDropZone hook (enter/leave depth counting + capture-phase reset so
the affordance clears even when the composer claims the drop) and a
pointer-events-none ChatDropOverlay, wired onto the conversation viewport.
Drops funnel through the existing onAttachDroppedItems; composer drops keep
their own inline-ref behavior.

* fix(desktop): chat-area drops insert inline @file refs, not attachment cards

Match the composer-input drop behavior — funnel dropped paths through
droppedFileInlineRef + the composer insert bus so they render as inline
ref chips instead of attachment cards.

* fix(desktop): don't render bare file paths as tool images (404)

vision_analyze reports its input image as a local filesystem path, which
toolImageUrl handed straight to <img src>. In the renderer that resolves
against the dev-server origin and 404s. Restrict inline tool images to
fetchable sources (data: URLs and remote http(s)); bare paths now fall
back to the tool's codicon.
This commit is contained in:
brooklyn!
2026-06-01 00:30:39 -05:00
committed by GitHub
parent e1eba6f8cc
commit 359f2be12e
5 changed files with 180 additions and 5 deletions

View File

@ -0,0 +1,27 @@
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
/**
* Full-bleed affordance shown while files are dragged over the chat area. Always
* `pointer-events-none` so the drop lands on the real element underneath and the
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
* composer surface so the two read as one family.
*/
export function ChatDropOverlay({ active }: { active: boolean }) {
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
active ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
Drop files to attach
</div>
</div>
)
}

View File

@ -0,0 +1,82 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
interface FileDropZoneOptions {
/** When false the zone ignores drags entirely. */
enabled?: boolean
onDropFiles: (files: DroppedFile[]) => void
}
/**
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
* keeps nested children from flickering the active state; `onDropCapture` clears
* it even when a nested target (the composer) handles the drop and stops
* propagation before our bubble-phase `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
*/
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragActive, setDragActive] = useState(false)
const depth = useRef(0)
const reset = useCallback(() => {
depth.current = 0
setDragActive(false)
}, [])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
return
}
event.preventDefault()
depth.current += 1
setDragActive(true)
},
[enabled]
)
const onDragOver = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
return
}
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
},
[enabled]
)
const onDragLeave = useCallback(() => {
if (enabled && --depth.current <= 0) {
reset()
}
}, [enabled, reset])
const onDrop = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
return
}
event.preventDefault()
reset()
const files = extractDroppedFiles(event.dataTransfer)
if (files.length) {
onDropFiles(files)
}
},
[enabled, onDropFiles, reset]
)
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
}

View File

@ -7,7 +7,7 @@ import {
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { Suspense, useMemo, useRef } from 'react'
import { Suspense, useCallback, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
@ -43,9 +43,13 @@ import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@ -267,6 +271,24 @@ export function ChatView({
onReload
})
// Drop files anywhere in the conversation area, not just on the composer
// input — appending the same inline `@file:` ref chips the composer drop
// produces (vs. attachment cards) so both surfaces behave identically.
const onDropFiles = useCallback(
(candidates: DroppedFile[]) => {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
.filter((ref): ref is string => Boolean(ref))
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
},
[currentCwd]
)
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
return (
<div
className={cn(
@ -285,7 +307,10 @@ export function ChatView({
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]">
<div
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
{...dropHandlers}
>
<AssistantRuntimeProvider runtime={runtime}>
<Thread
clampToComposer={showChatBar}
@ -326,6 +351,7 @@ export function ChatView({
</Suspense>
)}
</AssistantRuntimeProvider>
<ChatDropOverlay active={dragActive} />
</div>
</div>
)

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { buildToolView, type ToolPart } from './tool-fallback-model'
const part = (overrides: Partial<ToolPart>): ToolPart => ({
args: {},
isError: false,
result: {},
toolCallId: 'call_1',
toolName: 'vision_analyze',
type: 'tool-call',
...overrides
})
describe('buildToolView image handling', () => {
// vision_analyze reports the input image as a local path; an <img> pointed at
// a bare path resolves against the renderer origin and 404s, so we render the
// tool codicon instead of a broken image.
it('drops bare filesystem paths', () => {
expect(buildToolView(part({ args: { path: '/Users/me/shot.png' } }), '').imageUrl).toBe('')
expect(buildToolView(part({ result: { image_path: '/tmp/out.jpg' } }), '').imageUrl).toBe('')
})
it('keeps fetchable data URLs', () => {
const dataUrl = 'data:image/png;base64,AAAA'
expect(buildToolView(part({ result: { image_url: dataUrl } }), '').imageUrl).toBe(dataUrl)
})
it('keeps remote http(s) image URLs', () => {
const url = 'https://example.com/pic.webp'
expect(buildToolView(part({ result: { url } }), '').imageUrl).toBe(url)
})
})

View File

@ -786,9 +786,14 @@ function toolImageUrl(args: Record<string, unknown>, result: Record<string, unkn
return ''
}
return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
? candidate
: ''
// Only inline-render images the renderer can actually fetch: data URLs or
// remote http(s). A bare filesystem path (e.g. vision_analyze's input image)
// resolves against the dev-server origin and 404s — fall back to the tool's
// codicon instead of a broken <img>.
const isDataImage = candidate.toLowerCase().startsWith('data:image/')
const isRemoteImage = /^https?:\/\//i.test(candidate) && /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
return isDataImage || isRemoteImage ? candidate : ''
}
function stripAnsi(value: string): string {