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 {