diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 0c60ed13..570fd481 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -169,8 +169,8 @@ describe('Content-only pages render without errors', () => { }) expect(await screen.findAllByText('/goal')).toHaveLength(2) - expect(screen.getByText('[ | clear]')).toBeInTheDocument() - expect(screen.getByText('Set a completion goal')).toBeInTheDocument() + expect(screen.getByText('[status|pause|resume|complete|clear|--tokens |]')).toBeInTheDocument() + expect(screen.getByText('Create or manage an autonomous completion goal')).toBeInTheDocument() expect(screen.queryByText('/goal status')).not.toBeInTheDocument() expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument() }) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index e51ca5e2..be175876 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({ browse: vi.fn(), wsSend: vi.fn(), dialogOpen: vi.fn(), + webviewDragHandlers: [] as Array<(event: { payload: unknown }) => void>, + webviewUnlisten: vi.fn(), })) vi.mock('../../api/sessions', () => ({ @@ -56,6 +58,15 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.dialogOpen, })) +vi.mock('@tauri-apps/api/webview', () => ({ + getCurrentWebview: () => ({ + onDragDropEvent: vi.fn(async (handler: (event: { payload: unknown }) => void) => { + mocks.webviewDragHandlers.push(handler) + return mocks.webviewUnlisten + }), + }), +})) + vi.mock('../../hooks/useMobileViewport', () => ({ useMobileViewport: () => viewportMocks.isMobile, })) @@ -118,6 +129,7 @@ describe('ChatInput file mentions', () => { beforeEach(() => { vi.clearAllMocks() + mocks.webviewDragHandlers.length = 0 delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ viewportMocks.isMobile = false useSettingsStore.setState({ locale: 'en' }) @@ -665,6 +677,77 @@ describe('ChatInput file mentions', () => { }) }) + it('accepts native desktop file drops on the active session composer as path-only attachments', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: {}, + }) + + render() + + const panel = screen.getByTestId('chat-input-panel') + Object.defineProperty(panel, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left: 0, + top: 0, + right: 640, + bottom: 180, + width: 640, + height: 180, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }) + + await waitFor(() => { + expect(mocks.webviewDragHandlers).toHaveLength(1) + }) + + act(() => { + mocks.webviewDragHandlers[0]?.({ + payload: { type: 'over', position: { x: 24, y: 24 } }, + }) + }) + expect(screen.getByTestId('chat-input-drop-overlay')).toBeInTheDocument() + + act(() => { + mocks.webviewDragHandlers[0]?.({ + payload: { + type: 'drop', + position: { x: 24, y: 24 }, + paths: ['/Users/nanmi/drop/large-a.log'], + }, + }) + }) + + expect(await screen.findByText('large-a.log')).toBeInTheDocument() + expect(screen.queryByTestId('chat-input-drop-overlay')).not.toBeInTheDocument() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { + target: { + value: 'analyze dropped file', + selectionStart: 'analyze dropped file'.length, + }, + }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: 'analyze dropped file', + attachments: [ + expect.objectContaining({ + type: 'file', + name: 'large-a.log', + path: '/Users/nanmi/drop/large-a.log', + data: undefined, + }), + ], + }) + }) + it('uses larger icon-only mobile action buttons for browser H5 access', async () => { viewportMocks.isMobile = true mocks.search.mockResolvedValueOnce({ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 54d90a28..de5d1bfe 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -17,6 +17,7 @@ import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' import type { AttachmentRef } from '../../types/chat' import { AttachmentGallery } from './AttachmentGallery' +import { ComposerDropOverlay } from './ComposerDropOverlay' import { ProjectContextChip } from '../shared/ProjectContextChip' import { RepositoryLaunchControls } from '../shared/RepositoryLaunchControls' import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' @@ -37,6 +38,7 @@ import { selectNativeFileAttachments, type ComposerAttachment, } from '../../lib/composerAttachments' +import { useComposerFileDrop } from './useComposerFileDrop' type GitInfo = SessionGitInfo @@ -88,6 +90,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const [launchTransitioning, setLaunchTransitioning] = useState(false) const composingRef = useRef(false) const textareaRef = useRef(null) + const panelRef = useRef(null) const fileInputRef = useRef(null) const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) @@ -707,6 +710,20 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro }) }, [setComposerAttachments]) + const appendAttachments = useCallback((nextAttachments: Attachment[]) => { + if (nextAttachments.length === 0) return + setComposerAttachments((prev) => [...prev, ...nextAttachments]) + }, [setComposerAttachments]) + + const { isDragActive, dragHandlers } = useComposerFileDrop({ + disabled: isMemberSession || isWorkspaceMissing, + panelRef, + onAttachments: appendAttachments, + onError: (error) => { + console.warn('[attachments] Failed to read dropped files', error) + }, + }) + const openAttachmentPicker = useCallback(() => { if (!isTauriRuntime()) { fileInputRef.current?.click() @@ -736,15 +753,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro event.target.value = '' } - const handleDrop = (event: React.DragEvent) => { - event.preventDefault() - if (isMemberSession) return - const files = event.dataTransfer.files - if (files.length > 0) { - appendFiles(files) - } - } - const removeAttachment = (id: string) => { setComposerAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) if (activeTabId) removeWorkspaceReference(activeTabId, id) @@ -798,15 +806,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro } >
event.preventDefault()} - onDrop={handleDrop} + ? `glass-panel relative overflow-hidden p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'} ${isDragActive ? 'composer-drop-target-active' : ''}` + : `glass-panel relative overflow-hidden transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'} ${isDragActive ? 'composer-drop-target-active' : ''}`} + {...dragHandlers} > + {isDragActive && ( + + )} + {!isMemberSession && fileSearchOpen && (
+ ) +} diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index e74f4448..acc14e5b 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -75,8 +75,8 @@ describe('composerUtils', () => { expect.arrayContaining([ { name: 'goal', - description: 'Set a completion goal', - argumentHint: '[ | clear]', + description: 'Create or manage an autonomous completion goal', + argumentHint: '[status|pause|resume|complete|clear|--tokens |]', }, ]), ) @@ -89,8 +89,8 @@ describe('composerUtils', () => { commands.map((command) => command.name), ).toEqual(['goal']) expect(commands[0]).toMatchObject({ - description: 'Set a completion goal', - argumentHint: '[ | clear]', + description: 'Create or manage an autonomous completion goal', + argumentHint: '[status|pause|resume|complete|clear|--tokens |]', }) expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens') diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 884903d9..b35c36a4 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -26,8 +26,8 @@ export const FALLBACK_SLASH_COMMANDS = [ { name: 'clear', description: 'Clear conversation history' }, { name: 'goal', - description: 'Set a completion goal', - argumentHint: '[ | clear]', + description: 'Create or manage an autonomous completion goal', + argumentHint: '[status|pause|resume|complete|clear|--tokens |]', }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, diff --git a/desktop/src/components/chat/useComposerFileDrop.ts b/desktop/src/components/chat/useComposerFileDrop.ts new file mode 100644 index 00000000..a4060fd6 --- /dev/null +++ b/desktop/src/components/chat/useComposerFileDrop.ts @@ -0,0 +1,163 @@ +import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react' +import { isTauriRuntime } from '../../lib/desktopRuntime' +import { + dataTransferHasFiles, + dataTransferToComposerAttachments, + pathsToComposerAttachments, + type ComposerAttachment, +} from '../../lib/composerAttachments' + +type TauriDropPosition = { + x: number + y: number +} + +type TauriDragDropPayload = + | { type: 'enter'; paths: string[]; position: TauriDropPosition } + | { type: 'over'; position: TauriDropPosition } + | { type: 'drop'; paths: string[]; position: TauriDropPosition } + | { type: 'leave' } + | { type: 'cancel' } + +type TauriDragDropEvent = { + payload: TauriDragDropPayload +} + +type UseComposerFileDropOptions = { + disabled?: boolean + panelRef: RefObject + onAttachments: (attachments: ComposerAttachment[]) => void + onError?: (error: unknown) => void +} + +function isPointInsideElement(element: HTMLElement | null, position: TauriDropPosition): boolean { + if (!element) return false + const rect = element.getBoundingClientRect() + return ( + position.x >= rect.left && + position.x <= rect.right && + position.y >= rect.top && + position.y <= rect.bottom + ) +} + +export function useComposerFileDrop({ + disabled = false, + panelRef, + onAttachments, + onError, +}: UseComposerFileDropOptions) { + const [isDragActive, setIsDragActive] = useState(false) + const dragDepthRef = useRef(0) + const disabledRef = useRef(disabled) + const onAttachmentsRef = useRef(onAttachments) + const onErrorRef = useRef(onError) + + useEffect(() => { + disabledRef.current = disabled + }, [disabled]) + + useEffect(() => { + onAttachmentsRef.current = onAttachments + }, [onAttachments]) + + useEffect(() => { + onErrorRef.current = onError + }, [onError]) + + useEffect(() => { + if (!isTauriRuntime()) return + + let disposed = false + let unlisten: (() => void) | undefined + + void import('@tauri-apps/api/webview') + .then(({ getCurrentWebview }) => + getCurrentWebview().onDragDropEvent((event) => { + if (disposed) return + + const payload = event.payload as TauriDragDropEvent['payload'] + if (payload.type === 'cancel' || payload.type === 'leave') { + dragDepthRef.current = 0 + setIsDragActive(false) + return + } + + const isInside = isPointInsideElement(panelRef.current, payload.position) + if (payload.type === 'enter' || payload.type === 'over') { + setIsDragActive(!disabledRef.current && isInside) + return + } + + dragDepthRef.current = 0 + setIsDragActive(false) + if (disabledRef.current || !isInside) return + + const attachments = pathsToComposerAttachments(payload.paths) + if (attachments.length > 0) onAttachmentsRef.current(attachments) + }), + ) + .then((nextUnlisten) => { + if (disposed) { + nextUnlisten() + return + } + unlisten = nextUnlisten + }) + .catch((error) => { + onErrorRef.current?.(error) + }) + + return () => { + disposed = true + unlisten?.() + } + }, [panelRef]) + + const onDragEnter = useCallback((event: DragEvent) => { + if (disabled || !dataTransferHasFiles(event.dataTransfer)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + dragDepthRef.current += 1 + setIsDragActive(true) + }, [disabled]) + + const onDragOver = useCallback((event: DragEvent) => { + if (disabled || !dataTransferHasFiles(event.dataTransfer)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + setIsDragActive(true) + }, [disabled]) + + const onDragLeave = useCallback((event: DragEvent) => { + if (disabled || !dataTransferHasFiles(event.dataTransfer)) return + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setIsDragActive(false) + }, [disabled]) + + const onDrop = useCallback((event: DragEvent) => { + if (disabled || !dataTransferHasFiles(event.dataTransfer)) return + event.preventDefault() + dragDepthRef.current = 0 + setIsDragActive(false) + + void dataTransferToComposerAttachments(event.dataTransfer) + .then((attachments) => { + if (attachments.length > 0) onAttachments(attachments) + }) + .catch((error) => { + onError?.(error) + }) + }, [disabled, onAttachments, onError]) + + return { + isDragActive, + dragHandlers: { + onDragEnter, + onDragOver, + onDragLeave, + onDrop, + }, + } +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 65c5d97a..7dffc94a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -882,6 +882,8 @@ export const en = { 'chat.placeholder': 'Ask Claude to edit, debug or explain...', 'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.', 'chat.addFiles': 'Add files or photos', + 'chat.dropFilesTitle': 'Drop files here', + 'chat.dropFilesHint': 'Attached as file paths.', 'chat.workspaceReferencesOnly': 'Added {count} workspace references', 'chat.contextReferencesOnly': 'Added {count} references', 'chat.addSelectionToChat': 'Add to chat', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 85f673d8..1baca488 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -884,6 +884,8 @@ export const zh: Record = { 'chat.placeholder': '让 Claude 编辑、调试或解释代码...', 'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。', 'chat.addFiles': '添加文件或图片', + 'chat.dropFilesTitle': '松手添加文件', + 'chat.dropFilesHint': '会以文件路径附加到当前输入。', 'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用', 'chat.contextReferencesOnly': '已添加 {count} 个引用', 'chat.addSelectionToChat': '添加至对话', diff --git a/desktop/src/lib/composerAttachments.ts b/desktop/src/lib/composerAttachments.ts index 15be5f71..f6722b45 100644 --- a/desktop/src/lib/composerAttachments.ts +++ b/desktop/src/lib/composerAttachments.ts @@ -33,6 +33,21 @@ export function pathToComposerAttachment(filePath: string): ComposerAttachment { } } +export function pathsToComposerAttachments(filePaths: string[]): ComposerAttachment[] { + return filePaths + .filter((filePath) => typeof filePath === 'string' && filePath.length > 0) + .map(pathToComposerAttachment) +} + +export function dataTransferHasFiles(dataTransfer: DataTransfer): boolean { + const types = Array.from(dataTransfer.types ?? []) + return types.includes('Files') || dataTransfer.files.length > 0 +} + +export async function dataTransferToComposerAttachments(dataTransfer: DataTransfer): Promise { + return filesToComposerAttachments(dataTransfer.files) +} + export async function selectNativeFileAttachments(): Promise { if (!isTauriRuntime()) return null @@ -43,7 +58,7 @@ export async function selectNativeFileAttachments(): Promise ({ wsSend: vi.fn(), wsDisconnect: vi.fn(), dialogOpen: vi.fn(), + webviewDragHandlers: [] as Array<(event: { payload: unknown }) => void>, + webviewUnlisten: vi.fn(), isMobile: false, isTauriRuntime: false, })) @@ -66,6 +68,15 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.dialogOpen, })) +vi.mock('@tauri-apps/api/webview', () => ({ + getCurrentWebview: () => ({ + onDragDropEvent: vi.fn(async (handler: (event: { payload: unknown }) => void) => { + mocks.webviewDragHandlers.push(handler) + return mocks.webviewUnlisten + }), + }), +})) + vi.mock('../components/shared/DirectoryPicker', () => ({ DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (