Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/reject-folder-chat-attachments.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 21 additions & 3 deletions packages/assistant-ui-runtime/src/attachmentAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
try {
await file.slice(0, 1).arrayBuffer();
return false;
} catch {
return true;
}
}

const getFileDataURL = async (file: File): Promise<string> => {
if (typeof FileReader === 'undefined') {
const buffer = await file.arrayBuffer();
Expand Down Expand Up @@ -29,8 +44,11 @@ const getFileDataURL = async (file: File): Promise<string> => {
*/
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,
Expand All @@ -41,7 +59,7 @@ export const trueForgeAttachmentAdapter: AttachmentAdapter = {
type: 'requires-action',
reason: 'composer-send',
},
});
};
},
async send(attachment: PendingAttachment) {
return {
Expand Down
29 changes: 28 additions & 1 deletion packages/assistant-ui-runtime/test/attachmentAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<PendingAttachment> {
const result = await trueForgeAttachmentAdapter.add({ file });
Expand All @@ -11,6 +11,16 @@ async function addFile(file: File): Promise<PendingAttachment> {
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('*');
Expand All @@ -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);
Expand Down
21 changes: 18 additions & 3 deletions packages/trueforge-ui/src/containers/ComposerContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,6 +65,7 @@ function ComposerBody({
const { isBusy, send, resetBusy } = useComposerBusyState();
const canManageSession = useActiveSessionCanManage();
const cancel = useTrueForgeCancel();
const toaster = useToasterOptional();
const fileInputRef = useRef<HTMLInputElement>(null);
// Running/pause no longer lock the input — only session permissions do.
const disabled = !canManageSession;
Expand Down Expand Up @@ -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 = '';
Expand All @@ -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 }),
Expand Down
31 changes: 31 additions & 0 deletions packages/trueforge-ui/src/utils/partitionDroppedFiles.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {},
},
},
});

Expand All @@ -246,6 +250,46 @@ describe('ComposerContainer', () => {
);
});

it('does not track attachment_picked when only a folder is dropped', () => {
const track = vi.fn();
render(
<AnalyticsProvider track={track}>
<ShellModeProvider agentConfig={{ mode: 'SingleAgent', name: 'support' }}>
<RuntimeHarness messages={[]}>
<ComposerBusyProvider>
<ComposerContainer />
</ComposerBusyProvider>
</RuntimeHarness>
</ShellModeProvider>
</AnalyticsProvider>,
);
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(
<SlotsProvider
Expand Down
47 changes: 47 additions & 0 deletions packages/trueforge-ui/test/utils/partitionDroppedFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';

import { partitionDroppedFiles } from '@/utils/partitionDroppedFiles.js';

describe('partitionDroppedFiles', () => {
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 });
});
});
Loading