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 && ( { - fileInputRef.current?.click() - setPlusMenuOpen(false) - }} + onClick={openAttachmentPicker} className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]" > attach_file diff --git a/desktop/src/lib/composerAttachments.test.ts b/desktop/src/lib/composerAttachments.test.ts new file mode 100644 index 00000000..92a8328b --- /dev/null +++ b/desktop/src/lib/composerAttachments.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { pathToComposerAttachment } from './composerAttachments' + +describe('composer attachment payloads', () => { + it('keeps many selected desktop project files as paths instead of request-body data', () => { + const projectRoot = '/tmp/cc-haha-issue-444-regression' + const files = Array.from({ length: 12 }, (_, index) => ( + `${projectRoot}/assets/large-${index + 1}.bin` + )) + + const oldInlineAttachments = files.map((filePath) => ({ + type: 'file', + name: filePath.split('/').pop(), + data: `data:application/octet-stream;base64,${'A'.repeat(256 * 1024)}`, + mimeType: 'application/octet-stream', + })) + const oldInlinePayload = JSON.stringify({ + type: 'user_message', + content: 'analyze these files', + attachments: oldInlineAttachments, + }) + + const pathOnlyAttachments = files.map(pathToComposerAttachment) + const pathOnlyPayload = JSON.stringify({ + type: 'user_message', + content: 'analyze these files', + attachments: pathOnlyAttachments, + }) + + expect(oldInlinePayload.length).toBeGreaterThan(3 * 1024 * 1024) + expect(pathOnlyPayload.length).toBeLessThan(3 * 1024) + expect(pathOnlyAttachments.every((attachment) => attachment.path && !attachment.data)).toBe(true) + }) +}) diff --git a/desktop/src/lib/composerAttachments.ts b/desktop/src/lib/composerAttachments.ts new file mode 100644 index 00000000..15be5f71 --- /dev/null +++ b/desktop/src/lib/composerAttachments.ts @@ -0,0 +1,95 @@ +import { isTauriRuntime } from './desktopRuntime' + +export type ComposerAttachment = { + 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 +} + +function nextAttachmentId() { + return `att-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function getFileNameFromPath(filePath: string): string { + const normalized = filePath.replace(/[\\/]+$/g, '') + return normalized.split(/[\\/]/).filter(Boolean).pop() || filePath +} + +export function pathToComposerAttachment(filePath: string): ComposerAttachment { + return { + id: nextAttachmentId(), + name: getFileNameFromPath(filePath), + type: 'file', + path: filePath, + } +} + +export async function selectNativeFileAttachments(): Promise { + if (!isTauriRuntime()) return null + + try { + const { open } = await import('@tauri-apps/plugin-dialog') + const selected = await open({ + multiple: true, + directory: false, + }) + const paths = normalizeDialogSelection(selected) + return paths.map(pathToComposerAttachment) + } catch (error) { + console.warn('[attachments] Native file picker failed; falling back to browser file input', error) + return null + } +} + +export async function filesToComposerAttachments(files: FileList | File[]): Promise { + const entries = Array.from(files) + const attachments = await Promise.all(entries.map(fileToComposerAttachment)) + return attachments.filter((attachment): attachment is ComposerAttachment => !!attachment) +} + +function normalizeDialogSelection(selected: string | string[] | null): string[] { + if (!selected) return [] + const paths = Array.isArray(selected) ? selected : [selected] + return paths.filter((filePath) => typeof filePath === 'string' && filePath.length > 0) +} + +function getNativeFilePath(file: File): string | undefined { + const path = (file as File & { path?: unknown }).path + return typeof path === 'string' && path.length > 0 ? path : undefined +} + +async function fileToComposerAttachment(file: File): Promise { + const nativePath = isTauriRuntime() ? getNativeFilePath(file) : undefined + if (nativePath) { + return pathToComposerAttachment(nativePath) + } + + const isImage = file.type.startsWith('image/') + const data = await readFileAsDataUrl(file) + return { + id: nextAttachmentId(), + name: file.name, + type: isImage ? 'image' : 'file', + mimeType: file.type || undefined, + previewUrl: isImage ? data : undefined, + data, + } +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error ?? new Error(`Failed to read ${file.name}`)) + reader.readAsDataURL(file) + }) +} diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 949f12db..7c24562b 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ wsOnMessage: vi.fn(), wsSend: vi.fn(), wsDisconnect: vi.fn(), + dialogOpen: vi.fn(), isMobile: false, isTauriRuntime: false, })) @@ -61,6 +62,10 @@ vi.mock('../lib/desktopRuntime', () => ({ isTauriRuntime: () => mocks.isTauriRuntime, })) +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: mocks.dialogOpen, +})) + vi.mock('../components/shared/DirectoryPicker', () => ({ DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => ( onChange('/workspace/project')}> @@ -301,6 +306,49 @@ describe('EmptySession', () => { ]) }) + it('uses native desktop file paths for draft attachments', async () => { + mocks.isTauriRuntime = true + mocks.dialogOpen.mockResolvedValueOnce([ + 'C:\\Users\\Nanmi\\Desktop\\huge-a.log', + '/Users/nanmi/tmp/huge-b.zip', + ]) + + render() + + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click(screen.getByText('Add files or photos')) + + expect(await screen.findByText('huge-a.log')).toBeInTheDocument() + expect(await screen.findByText('huge-b.zip')).toBeInTheDocument() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'check these files', selectionStart: 'check these files'.length }, + }) + fireEvent.click(screen.getByRole('button', { name: /Run/i })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({}) + }) + expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', { + type: 'user_message', + content: 'check these files', + attachments: [ + expect.objectContaining({ + type: 'file', + name: 'huge-a.log', + path: 'C:\\Users\\Nanmi\\Desktop\\huge-a.log', + data: undefined, + }), + expect.objectContaining({ + type: 'file', + name: 'huge-b.zip', + path: '/Users/nanmi/tmp/huge-b.zip', + data: undefined, + }), + ], + }) + }) + it('starts in a selected non-Git project without showing a repository warning', async () => { mocks.getRepositoryContext.mockResolvedValueOnce(notGitRepositoryContext()) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 4d9e2a39..b1618838 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ApiError } from '../api/client' import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' @@ -17,6 +17,11 @@ import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/Fi import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { useMobileViewport } from '../hooks/useMobileViewport' import { isTauriRuntime } from '../lib/desktopRuntime' +import { + filesToComposerAttachments, + selectNativeFileAttachments, + type ComposerAttachment, +} from '../lib/composerAttachments' import { FALLBACK_SLASH_COMMANDS, findSlashToken, @@ -28,15 +33,7 @@ import { import type { AttachmentRef } from '../types/chat' import type { SlashCommandOption } from '../components/chat/composerUtils' -type Attachment = { - id: string - name: string - type: 'image' | 'file' - path?: string - mimeType?: string - previewUrl?: string - data?: string -} +type Attachment = ComposerAttachment type Translate = ReturnType @@ -441,30 +438,42 @@ export function EmptySession() { if (!hasImage) return } + const appendFiles = useCallback((files: FileList | File[]) => { + void filesToComposerAttachments(files) + .then((nextAttachments) => { + if (nextAttachments.length === 0) return + setAttachments((prev) => [...prev, ...nextAttachments]) + }) + .catch((error) => { + console.warn('[attachments] Failed to read selected files', error) + }) + }, []) + + const openAttachmentPicker = useCallback(() => { + if (!isTauriRuntime()) { + fileInputRef.current?.click() + setPlusMenuOpen(false) + return + } + + void selectNativeFileAttachments() + .then((nativeAttachments) => { + if (nativeAttachments) { + if (nativeAttachments.length > 0) { + setAttachments((prev) => [...prev, ...nativeAttachments]) + } + return + } + fileInputRef.current?.click() + }) + .finally(() => setPlusMenuOpen(false)) + }, []) + const handleFileSelect = (event: React.ChangeEvent) => { 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 = () => { - setAttachments((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 = '' } @@ -472,8 +481,7 @@ export function EmptySession() { event.preventDefault() const files = event.dataTransfer.files if (files.length > 0) { - const fakeEvent = { target: { files } } as React.ChangeEvent - handleFileSelect(fakeEvent) + appendFiles(files) } } @@ -683,10 +691,7 @@ export function EmptySession() { isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]' }`}> { - fileInputRef.current?.click() - setPlusMenuOpen(false) - }} + onClick={openAttachmentPicker} className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]" > attach_file