diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx
new file mode 100644
index 000000000..428511da2
--- /dev/null
+++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx
@@ -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 (
+
+
+
+
+ Drop files to attach
+
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts
new file mode 100644
index 000000000..3254cbf04
--- /dev/null
+++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts
@@ -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 } }
+}
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 98cb2f636..971964847 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -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 (
-
)
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts
new file mode 100644
index 000000000..78d2c923a
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildToolView, type ToolPart } from './tool-fallback-model'
+
+const part = (overrides: Partial): 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
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)
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
index 9ca808d20..b31b1e8c8 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
@@ -786,9 +786,14 @@ function toolImageUrl(args: Record, result: Record.
+ 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 {