From 6d166f43d8b555c01b827fb3db94e8aed961fec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 15 May 2026 10:55:35 +0800 Subject: [PATCH] fix: keep desktop attachments path-only Desktop file attachment selection was inlining every selected file as a data URL before sending the websocket message. Large multi-file sends could inflate the renderer request body by tens of megabytes before the server had a chance to materialize uploads. Route Tauri file selection through the native dialog so desktop sends absolute paths, while preserving browser fallback data URLs for H5. Cover both active-session and draft-session composers plus a payload-size regression case. Constraint: Browser/H5 cannot rely on local absolute file paths, so the existing FileReader fallback remains for non-Tauri runtimes. Rejected: Raise websocket/body limits | would keep renderer memory pressure and still send file bytes unnecessarily. Confidence: high Scope-risk: narrow Directive: Do not reintroduce FileReader data URLs for Tauri desktop file-picker attachments without measuring websocket payload size. Tested: cd desktop && bun run test src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx Tested: /tmp complex-project reproduction showed 12x3MB old inline payload at 50,333,174 bytes versus path-only payload around 1-2KB Not-tested: Full desktop lint/build due unrelated existing Sidebar.tsx type errors in the dirty worktree Related: https://github.com/NanmiCoder/cc-haha/issues/444 --- .../src/components/chat/ChatInput.test.tsx | 53 +++++++++++ desktop/src/components/chat/ChatInput.tsx | 80 ++++++++-------- desktop/src/lib/composerAttachments.test.ts | 34 +++++++ desktop/src/lib/composerAttachments.ts | 95 +++++++++++++++++++ desktop/src/pages/EmptySession.test.tsx | 48 ++++++++++ desktop/src/pages/EmptySession.tsx | 77 ++++++++------- 6 files changed, 311 insertions(+), 76 deletions(-) create mode 100644 desktop/src/lib/composerAttachments.test.ts create mode 100644 desktop/src/lib/composerAttachments.ts 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 && (