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 }); + }); +});