From 8dd3c7df996658856436e68b4d294b9957c0f22b Mon Sep 17 00:00:00 2001 From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:23:13 +0530 Subject: [PATCH] fix: reject folder drops in TrueForge chat attachments Folders were accepted as pending attachments then failed on send with NotFoundError and no UI feedback. Reject them at add time and toast on drop. Co-authored-by: Cursor --- .changeset/reject-folder-chat-attachments.md | 6 +++ .../src/attachmentAdapter.ts | 24 ++++++++-- .../test/attachmentAdapter.test.ts | 29 +++++++++++- .../src/containers/ComposerContainer.tsx | 21 +++++++-- .../src/utils/partitionDroppedFiles.ts | 31 ++++++++++++ .../containers/ComposerContainer.test.tsx | 44 +++++++++++++++++ .../test/utils/partitionDroppedFiles.test.ts | 47 +++++++++++++++++++ 7 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 .changeset/reject-folder-chat-attachments.md create mode 100644 packages/trueforge-ui/src/utils/partitionDroppedFiles.ts create mode 100644 packages/trueforge-ui/test/utils/partitionDroppedFiles.test.ts diff --git a/.changeset/reject-folder-chat-attachments.md b/.changeset/reject-folder-chat-attachments.md new file mode 100644 index 0000000000..aa87988f74 --- /dev/null +++ b/.changeset/reject-folder-chat-attachments.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-assistant-ui-runtime': patch +'@truefoundry/trueforge-ui': patch +--- + +Reject folder drops in chat attachments so they cannot block message send. diff --git a/packages/assistant-ui-runtime/src/attachmentAdapter.ts b/packages/assistant-ui-runtime/src/attachmentAdapter.ts index 0e3f382288..be91a4efa2 100644 --- a/packages/assistant-ui-runtime/src/attachmentAdapter.ts +++ b/packages/assistant-ui-runtime/src/attachmentAdapter.ts @@ -1,7 +1,22 @@ import type { AttachmentAdapter, PendingAttachment } from '@assistant-ui/core'; +const FOLDER_ATTACHMENT_ERROR = 'Folders cannot be attached. Attach individual files instead.'; + const bytesToBase64 = (bytes: Uint8Array): string => globalThis.Buffer.from(bytes).toString('base64'); +/** + * Drag-dropped folders surface as `File` objects that fail on read (NotFoundError). + * Real files (including empty ones) are readable via slice; directories are not. + */ +export async function isDirectoryFile(file: File): Promise { + try { + await file.slice(0, 1).arrayBuffer(); + return false; + } catch { + return true; + } +} + const getFileDataURL = async (file: File): Promise => { if (typeof FileReader === 'undefined') { const buffer = await file.arrayBuffer(); @@ -29,8 +44,11 @@ const getFileDataURL = async (file: File): Promise => { */ export const trueForgeAttachmentAdapter: AttachmentAdapter = { accept: '*', - add({ file }) { - return Promise.resolve({ + async add({ file }) { + if (await isDirectoryFile(file)) { + throw new Error(FOLDER_ATTACHMENT_ERROR); + } + return { id: globalThis.crypto.randomUUID(), type: 'file', name: file.name, @@ -41,7 +59,7 @@ export const trueForgeAttachmentAdapter: AttachmentAdapter = { type: 'requires-action', reason: 'composer-send', }, - }); + }; }, async send(attachment: PendingAttachment) { return { diff --git a/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts b/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts index 0ec3bfc63f..7d31eb5bc5 100644 --- a/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts +++ b/packages/assistant-ui-runtime/test/attachmentAdapter.test.ts @@ -1,7 +1,7 @@ import type { PendingAttachment } from '@assistant-ui/core'; import { describe, expect, it } from 'vitest'; -import { trueForgeAttachmentAdapter } from '../src/attachmentAdapter.js'; +import { isDirectoryFile, trueForgeAttachmentAdapter } from '../src/attachmentAdapter.js'; async function addFile(file: File): Promise { const result = await trueForgeAttachmentAdapter.add({ file }); @@ -11,6 +11,16 @@ async function addFile(file: File): Promise { return result; } +function unreadableZeroByteFile(name: string): File { + const file = new File([], name); + Object.defineProperty(file, 'slice', { + value: () => ({ + arrayBuffer: () => Promise.reject(new DOMException('not found', 'NotFoundError')), + }), + }); + return file; +} + describe('trueForgeAttachmentAdapter', () => { it('accepts all file types', () => { expect(trueForgeAttachmentAdapter.accept).toBe('*'); @@ -31,6 +41,23 @@ describe('trueForgeAttachmentAdapter', () => { expect(pending.id).toBeTruthy(); }); + it('add accepts readable empty files', async () => { + const file = new File([], 'empty.txt', { type: 'text/plain' }); + await expect(addFile(file)).resolves.toMatchObject({ name: 'empty.txt' }); + }); + + it('add rejects directory-like files that cannot be read', async () => { + await expect(trueForgeAttachmentAdapter.add({ file: unreadableZeroByteFile('docs') })).rejects.toThrow( + /Folders cannot be attached/, + ); + }); + + it('isDirectoryFile is true only when the file blob cannot be read', async () => { + await expect(isDirectoryFile(new File(['x'], 'a.txt'))).resolves.toBe(false); + await expect(isDirectoryFile(new File([], 'empty.txt'))).resolves.toBe(false); + await expect(isDirectoryFile(unreadableZeroByteFile('docs'))).resolves.toBe(true); + }); + it('send reads the file into a data URI file part', async () => { const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); const pending = await addFile(file); diff --git a/packages/trueforge-ui/src/containers/ComposerContainer.tsx b/packages/trueforge-ui/src/containers/ComposerContainer.tsx index de83853c3d..bf8edd0c2d 100644 --- a/packages/trueforge-ui/src/containers/ComposerContainer.tsx +++ b/packages/trueforge-ui/src/containers/ComposerContainer.tsx @@ -13,11 +13,13 @@ import { useComposerPauseView } from '../hooks/useComposerPauseView.js'; import { useActiveSessionCanManage } from '../hooks/useResourcePermissions.js'; import { useOptionalShellMode } from '../server/ShellModeContext.js'; import { SlotsProvider, useSlot, useSlotIsDefault } from '../theme/SlotsProvider.js'; +import { partitionDroppedFiles } from '../utils/partitionDroppedFiles.js'; import { ApprovalNavContainer } from './ApprovalNavContainer.js'; import { AskUserContainer } from './AskUserContainer.js'; import { ComposerAttachmentsContainer } from './AttachmentsContainer.js'; import { CustomActionContainer } from './CustomActionContainer.js'; import { McpAuthContainer } from './McpAuthContainer.js'; +import { useToasterOptional } from './ToasterContainer.js'; export type ComposerContainerProps = { placeholder?: string; @@ -63,6 +65,7 @@ function ComposerBody({ const { isBusy, send, resetBusy } = useComposerBusyState(); const canManageSession = useActiveSessionCanManage(); const cancel = useTrueForgeCancel(); + const toaster = useToasterOptional(); const fileInputRef = useRef(null); // Running/pause no longer lock the input — only session permissions do. const disabled = !canManageSession; @@ -112,7 +115,12 @@ function ComposerBody({ withSessionProps({ file_count: files.length }, { sessionId, ...shellAgent }), ); for (const file of files) { - void aui.composer().addAttachment(file); + void aui + .composer() + .addAttachment(file) + .catch((error: unknown) => { + toaster?.showError(error); + }); } } event.target.value = ''; @@ -124,8 +132,15 @@ function ComposerBody({ className="w-full rounded-[0.75rem] transition-[box-shadow] data-[dragging=true]:ring-focus-ring/20 data-[dragging=true]:ring-3" onDropCapture={event => { if (disabled) return; - const files = event.dataTransfer?.files; - if (files == null || files.length === 0) return; + const dataTransfer = event.dataTransfer; + if (dataTransfer == null) return; + const { files, rejectedDirectoryCount } = partitionDroppedFiles(dataTransfer); + if (rejectedDirectoryCount > 0) { + toaster?.showError(new Error('Folders cannot be attached. Attach individual files instead.')); + } + // Do not preventDefault: the primitive clears drag state and adds files; + // trueForgeAttachmentAdapter rejects directory File objects. + if (files.length === 0) return; track( AnalyticsEvents.Attachment.PICKED, withSessionProps({ file_count: files.length }, { sessionId, ...shellAgent }), diff --git a/packages/trueforge-ui/src/utils/partitionDroppedFiles.ts b/packages/trueforge-ui/src/utils/partitionDroppedFiles.ts new file mode 100644 index 0000000000..2b6febc324 --- /dev/null +++ b/packages/trueforge-ui/src/utils/partitionDroppedFiles.ts @@ -0,0 +1,31 @@ +type DataTransferItemWithEntry = DataTransferItem & { + webkitGetAsEntry?: () => FileSystemEntry | null; +}; + +/** + * Split a drop into attachable files vs directories. + * Prefer `webkitGetAsEntry` so folders never become composer attachments. + */ +export function partitionDroppedFiles(dataTransfer: DataTransfer): { + files: File[]; + rejectedDirectoryCount: number; +} { + const items = dataTransfer.items; + if (items.length > 0) { + const files: File[] = []; + let rejectedDirectoryCount = 0; + for (const item of Array.from(items)) { + if (item.kind !== 'file') continue; + const entry = (item as DataTransferItemWithEntry).webkitGetAsEntry?.() ?? null; + if (entry?.isDirectory === true) { + rejectedDirectoryCount += 1; + continue; + } + const file = item.getAsFile(); + if (file != null) files.push(file); + } + return { files, rejectedDirectoryCount }; + } + + return { files: Array.from(dataTransfer.files), rejectedDirectoryCount: 0 }; +} diff --git a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx index 54300185c5..759b2a5cf9 100644 --- a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx +++ b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx @@ -237,6 +237,10 @@ describe('ComposerContainer', () => { fireEvent.drop(dropzone, { dataTransfer: { files: [new File(['hello'], 'note.txt', { type: 'text/plain' })], + items: { + length: 0, + [Symbol.iterator]: function* () {}, + }, }, }); @@ -246,6 +250,46 @@ describe('ComposerContainer', () => { ); }); + it('does not track attachment_picked when only a folder is dropped', () => { + const track = vi.fn(); + render( + + + + + + + + + , + ); + const dropzone = document.querySelector('[data-slot="aui_composer-attachment-dropzone"]'); + expect(dropzone).not.toBeNull(); + if (dropzone === null) { + throw new Error('Expected attachment dropzone'); + } + + const folderItem = { + kind: 'file', + getAsFile: () => new File([], 'docs'), + webkitGetAsEntry: () => ({ isDirectory: true, isFile: false, name: 'docs' }), + }; + fireEvent.drop(dropzone, { + dataTransfer: { + files: [new File([], 'docs')], + items: { + length: 1, + 0: folderItem, + [Symbol.iterator]: function* () { + yield folderItem; + }, + }, + }, + }); + + expect(track).not.toHaveBeenCalledWith(AnalyticsEvents.Attachment.PICKED, expect.anything()); + }); + it('preserves consumer section overrides in draft mode', () => { render( { + it('keeps files and counts directory entries', () => { + const note = new File(['hi'], 'note.txt', { type: 'text/plain' }); + const fileItem = { + kind: 'file' as const, + getAsFile: () => note, + webkitGetAsEntry: () => ({ isDirectory: false, isFile: true, name: 'note.txt' }) as FileSystemEntry, + }; + const folderItem = { + kind: 'file' as const, + getAsFile: () => new File([], 'docs'), + webkitGetAsEntry: () => ({ isDirectory: true, isFile: false, name: 'docs' }) as FileSystemEntry, + }; + const dataTransfer = { + items: { + length: 2, + 0: fileItem, + 1: folderItem, + [Symbol.iterator]: function* () { + yield fileItem; + yield folderItem; + }, + }, + files: [note, new File([], 'docs')], + } as unknown as DataTransfer; + + expect(partitionDroppedFiles(dataTransfer)).toEqual({ + files: [note], + rejectedDirectoryCount: 1, + }); + }); + + it('falls back to dataTransfer.files when items is empty', () => { + const note = new File(['hi'], 'note.txt', { type: 'text/plain' }); + const dataTransfer = { + items: { length: 0, [Symbol.iterator]: function* () {} }, + files: [note], + } as unknown as DataTransfer; + + expect(partitionDroppedFiles(dataTransfer)).toEqual({ files: [note], rejectedDirectoryCount: 0 }); + }); +});