diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 869c2990..e12c8d12 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ search: vi.fn(), browse: vi.fn(), wsSend: vi.fn(), + dialogOpen: vi.fn(), })) vi.mock('../../api/sessions', () => ({ @@ -51,6 +52,10 @@ vi.mock('../../api/websocket', () => ({ }, })) +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: mocks.dialogOpen, +})) + vi.mock('../../hooks/useMobileViewport', () => ({ useMobileViewport: () => viewportMocks.isMobile, })) @@ -113,6 +118,7 @@ describe('ChatInput file mentions', () => { beforeEach(() => { vi.clearAllMocks() + delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ viewportMocks.isMobile = false useSettingsStore.setState({ locale: 'en' }) useChatStore.setState(initialChatState, true) @@ -612,6 +618,53 @@ describe('ChatInput file mentions', () => { }) }) + it('uses native desktop file paths instead of inlining selected files', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: {}, + }) + mocks.dialogOpen.mockResolvedValueOnce([ + '/Users/nanmi/tmp/large-a.log', + 'C:\\Users\\Nanmi\\Desktop\\large-b.zip', + ]) + + render() + + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click(screen.getByText('Add files or photos')) + + expect(await screen.findByText('large-a.log')).toBeInTheDocument() + expect(await screen.findByText('large-b.zip')).toBeInTheDocument() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { + target: { + value: 'analyze these', + selectionStart: 'analyze these'.length, + }, + }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: 'analyze these', + attachments: [ + expect.objectContaining({ + type: 'file', + name: 'large-a.log', + path: '/Users/nanmi/tmp/large-a.log', + data: undefined, + }), + expect.objectContaining({ + type: 'file', + name: 'large-b.zip', + path: 'C:\\Users\\Nanmi\\Desktop\\large-b.zip', + 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 eecfccf9..7b0b89dd 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -31,23 +31,15 @@ import { } from './composerUtils' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isTauriRuntime } from '../../lib/desktopRuntime' +import { + filesToComposerAttachments, + selectNativeFileAttachments, + type ComposerAttachment, +} from '../../lib/composerAttachments' type GitInfo = SessionGitInfo -type Attachment = { - id: string - name: string - type: 'image' | 'file' - path?: string - mimeType?: string - previewUrl?: string - data?: string - isDirectory?: boolean - lineStart?: number - lineEnd?: number - note?: string - quote?: string -} +type Attachment = ComposerAttachment type ComposerDraft = { input: string @@ -710,31 +702,43 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro if (!hasImage) return } + const appendFiles = useCallback((files: FileList | File[]) => { + void filesToComposerAttachments(files) + .then((nextAttachments) => { + if (nextAttachments.length === 0) return + setComposerAttachments((prev) => [...prev, ...nextAttachments]) + }) + .catch((error) => { + console.warn('[attachments] Failed to read selected files', error) + }) + }, [setComposerAttachments]) + + const openAttachmentPicker = useCallback(() => { + if (!isTauriRuntime()) { + fileInputRef.current?.click() + setPlusMenuOpen(false) + return + } + + void selectNativeFileAttachments() + .then((nativeAttachments) => { + if (nativeAttachments) { + if (nativeAttachments.length > 0) { + setComposerAttachments((prev) => [...prev, ...nativeAttachments]) + } + return + } + fileInputRef.current?.click() + }) + .finally(() => setPlusMenuOpen(false)) + }, [setComposerAttachments]) + const handleFileSelect = (event: React.ChangeEvent) => { if (isMemberSession) return const files = event.target.files if (!files) return - Array.from(files).forEach((file) => { - const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}` - const isImage = file.type.startsWith('image/') - const reader = new FileReader() - reader.onload = () => { - setComposerAttachments((prev) => [ - ...prev, - { - id, - name: file.name, - type: isImage ? 'image' : 'file', - mimeType: file.type || undefined, - previewUrl: isImage ? (reader.result as string) : undefined, - data: reader.result as string, - }, - ]) - } - reader.readAsDataURL(file) - }) - + appendFiles(files) event.target.value = '' } @@ -743,8 +747,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro if (isMemberSession) return const files = event.dataTransfer.files if (files.length > 0) { - const fakeEvent = { target: { files } } as React.ChangeEvent - handleFileSelect(fakeEvent) + appendFiles(files) } } @@ -983,10 +986,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro {plusMenuOpen && (