Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/visible-attachment-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-ui": patch
---

Show file-type icons, filenames, and sizes for composer and sent message attachments.
76 changes: 63 additions & 13 deletions packages/trueforge-ui/src/atoms/AttachmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type AttachmentCardSize = 'chip' | 'preview';
export type AttachmentCardProps = {
name: string;
contentType?: string;
sizeBytes?: number;
previewSrc?: string;
isImage?: boolean;
size?: AttachmentCardSize;
Expand All @@ -23,8 +24,35 @@ export type AttachmentCardProps = {
className?: string;
};

const FILE_ICON_BY_EXTENSION: Record<string, string> = {
pdf: 'file-text',
ppt: 'file-text',
pptx: 'file-text',
doc: 'file-text',
docx: 'file-text',
txt: 'file-text',
csv: 'file-spreadsheet',
xls: 'file-spreadsheet',
xlsx: 'file-spreadsheet',
json: 'file-code',
};

export function getAttachmentFileIconName(name: string): string {
const dotIndex = name.lastIndexOf('.');
const extension = dotIndex > 0 && dotIndex < name.length - 1 ? name.slice(dotIndex + 1).toLowerCase() : undefined;
return extension == null ? 'file' : (FILE_ICON_BY_EXTENSION[extension] ?? 'file');
}

function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const megabytes = bytes / (1024 * 1024);
if (megabytes < 1) return `${(bytes / 1024).toFixed(2)} KB`;
return `${megabytes.toFixed(2)} MB`;
}

export function AttachmentCard({
name,
sizeBytes,
previewSrc,
isImage = false,
size = 'chip',
Expand All @@ -50,24 +78,46 @@ export function AttachmentCard({
);
}

const imageChip = isImage && previewSrc != null;
const fileIcon = getAttachmentFileIconName(name);

return (
<div
data-slot="aui_attachment-chip"
style={previewRem != null ? { maxWidth: `${previewRem}rem` } : undefined}
className={cn('aui-attachment-chip relative size-14 shrink-0', className)}
className={cn(
'aui-attachment-chip relative shrink-0',
imageChip
? 'size-14'
: 'bg-secondary-bg flex max-w-full min-w-0 items-center gap-3 rounded-lg border border-primary-button-bg/20 p-3',
!imageChip && onRemove != null && 'pe-10',
className,
)}
>
<Tooltip content={name} side="top" triggerClassName="size-full">
<div className="bg-secondary-bg relative size-full overflow-hidden rounded-[calc(var(--composer-radius,1.5rem)-var(--composer-padding,8px))] border border-primary-button-bg/20">
<Avatar className="size-full rounded-none">
<AvatarImage src={isImage ? previewSrc : undefined} alt={name} className="object-cover" />
{/* bg-none drops the default gradient (bg-image group), which bg-secondary-bg alone would not override. */}
<AvatarFallback className="rounded-none bg-secondary-bg bg-none text-text-secondary">
<Icon name="file" size="1.5rem" className="text-text-secondary" />
</AvatarFallback>
</Avatar>
</div>
</Tooltip>
<span className="sr-only">{name}</span>
{imageChip ? (
<>
<div className="bg-secondary-bg relative size-full overflow-hidden rounded-[calc(var(--composer-radius,1.5rem)-var(--composer-padding,8px))] border border-primary-button-bg/20">
<Avatar className="size-full rounded-none">
<AvatarImage src={previewSrc} alt={name} className="object-cover" />
<AvatarFallback className="rounded-none bg-secondary-bg bg-none text-text-secondary">
<Icon name="file" size="1.5rem" className="text-text-secondary" />
</AvatarFallback>
</Avatar>
</div>
</>
) : (
<>
<Icon name={fileIcon} size="2.5rem" className="text-text-secondary" />
<div className="flex min-w-0 flex-col gap-1">
<Tooltip content={name} side="top" triggerClassName="min-w-0">
<span className="text-text-primary min-w-0 truncate text-sm font-medium">{name}</span>
</Tooltip>
{sizeBytes != null ? (
<span className="text-text-secondary text-xs">{formatFileSize(sizeBytes)}</span>
) : null}
</div>
</>
)}
{onRemove && (
<button
type="button"
Expand Down
26 changes: 24 additions & 2 deletions packages/trueforge-ui/src/containers/AttachmentsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ import { USER_MESSAGE_ATTACHMENT_PREVIEW_REM } from '../constants/attachments.js
import { useSlot } from '../theme/SlotsProvider.js';
import { isImageAttachment, useAttachmentPreviewSrc } from './useAttachmentPreviewSrc.js';

function base64DataUriByteSize(data: string): number | undefined {
const commaIndex = data.indexOf(',');
if (commaIndex < 0 || !data.slice(0, commaIndex).endsWith(';base64')) return undefined;

const payload = data.slice(commaIndex + 1).replace(/\s/g, '');
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
}

function useAttachmentSizeBytes(): number | undefined {
const fileSize = useAuiState(s => ('file' in s.attachment ? s.attachment.file?.size : undefined));
const data = useAuiState(s => {
const filePart = s.attachment.content?.find(part => part.type === 'file');
return filePart?.type === 'file' ? filePart.data : undefined;
});
return fileSize ?? (data == null ? undefined : base64DataUriByteSize(data));
}

function ComposerAttachmentItem() {
const AttachmentPreviewDialog = useSlot('AttachmentPreviewDialog');
const AttachmentCard = useSlot('AttachmentCard');
Expand All @@ -15,12 +33,14 @@ function ComposerAttachmentItem() {
const type = useAuiState(s => s.attachment.type);
const isImage = isImageAttachment(type, contentType);
const previewSrc = useAttachmentPreviewSrc();
const sizeBytes = useAttachmentSizeBytes();

return (
<AttachmentPreviewDialog previewSrc={previewSrc}>
<AttachmentCard
name={name}
contentType={contentType}
sizeBytes={sizeBytes}
previewSrc={previewSrc}
isImage={isImage}
size="chip"
Expand All @@ -38,11 +58,13 @@ function MessageAttachmentItem() {
const type = useAuiState(s => s.attachment.type);
const isImage = isImageAttachment(type, contentType);
const previewSrc = useAttachmentPreviewSrc();
const sizeBytes = useAttachmentSizeBytes();

const card = (
<AttachmentCard
name={name}
contentType={contentType}
sizeBytes={sizeBytes}
previewSrc={previewSrc}
isImage={isImage}
size={isImage ? 'preview' : 'chip'}
Expand All @@ -59,15 +81,15 @@ function MessageAttachmentItem() {

export function ComposerAttachmentsContainer() {
return (
<div className="aui-composer-attachments flex w-full flex-row flex-wrap items-center gap-2 empty:hidden">
<div className="aui-composer-attachments flex w-full min-w-0 flex-row flex-nowrap items-center gap-2 overflow-x-auto overflow-y-hidden empty:hidden">
Comment thread
harshil-2096 marked this conversation as resolved.
<ComposerPrimitive.Attachments>{() => <ComposerAttachmentItem />}</ComposerPrimitive.Attachments>
</div>
);
}

export function MessageAttachmentsContainer() {
return (
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row flex-wrap justify-end gap-2">
<MessagePrimitive.Attachments>{() => <MessageAttachmentItem />}</MessagePrimitive.Attachments>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions packages/trueforge-ui/src/icons/IconRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import {
EllipsisVertical,
ExternalLink,
File,
FileCode2,
FileSpreadsheet,
FileText,
Github,
GripVertical,
History,
Expand Down Expand Up @@ -152,6 +155,9 @@ const defaults: Record<string, IconEntry> = {
'chevrons-up-down': ChevronsUpDown,
loader: Loader2,
file: File,
'file-code': FileCode2,
'file-spreadsheet': FileSpreadsheet,
'file-text': FileText,
clone: Copy,
copy: Copy,
'dollar-sign': DollarSign,
Expand Down
27 changes: 24 additions & 3 deletions packages/trueforge-ui/test/atoms/AttachmentCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { AttachmentCard } from '@/atoms/AttachmentCard.js';
import { AttachmentCard, getAttachmentFileIconName } from '@/atoms/AttachmentCard.js';

describe('AttachmentCard', () => {
it('renders an image preview with accessible text and a configured size', () => {
Expand Down Expand Up @@ -30,13 +30,16 @@ describe('AttachmentCard', () => {
previewSrc="/thumbnail.png"
isImage
previewRem={8}
sizeBytes={72}
onRemove={onRemove}
/>,
);

const chip = container.querySelector('[data-slot="aui_attachment-chip"]');
expect(chip).toHaveStyle({ maxWidth: '8rem' });
expect(screen.getByRole('img', { name: 'long-image-name.png' })).toHaveAttribute('src', '/thumbnail.png');
expect(screen.queryByText('long-image-name.png')).not.toBeInTheDocument();
expect(screen.queryByText('0.07 KB')).not.toBeInTheDocument();

const removeButton = screen.getByRole('button', { name: 'Remove file' });
expect(removeButton).toHaveAttribute('type', 'button');
Expand All @@ -45,12 +48,30 @@ describe('AttachmentCard', () => {
expect(onRemove).toHaveBeenCalledOnce();
});

it('falls back to a file chip when image preview data is unavailable', () => {
const { container } = render(<AttachmentCard name="report.pdf" contentType="application/pdf" isImage />);
it('renders visible file metadata when image preview data is unavailable', () => {
const { container } = render(
<AttachmentCard name="report.pdf" contentType="application/pdf" isImage sizeBytes={72} />,
);

expect(container.querySelector('[data-slot="aui_attachment-chip"]')).toBeInTheDocument();
expect(screen.getByText('report.pdf')).toBeInTheDocument();
expect(screen.getByText('0.07 KB')).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove file' })).not.toBeInTheDocument();
});

it.each([
['report.pdf', 'file-text'],
['slides.pptx', 'file-text'],
['document.docx', 'file-text'],
['notes.txt', 'file-text'],
['results.csv', 'file-spreadsheet'],
['workbook.xlsx', 'file-spreadsheet'],
['config.json', 'file-code'],
['bundle.zip', 'file'],
['photo.png', 'file'],
['attachment', 'file'],
])('maps %s to the %s icon', (name, expectedIcon) => {
expect(getAttachmentFileIconName(name)).toBe(expectedIcon);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,18 @@ describe('attachment containers', () => {
const file = new File(['image'], 'diagram.png', { type: 'image/png' });
fireEvent.change(input, { target: { files: [file] } });

expect(await screen.findByText('diagram.png')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('attachment-preview-slot')).toHaveAttribute(
'data-preview-src',
'blob:composer-preview',
);
});
expect(screen.getByRole('img', { name: 'diagram.png' })).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Remove file' }));

await waitFor(() => {
expect(screen.queryByText('diagram.png')).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'diagram.png' })).not.toBeInTheDocument();
});
expect(remove).toHaveBeenCalledTimes(1);
expect(remove.mock.calls[0]?.[0]).toMatchObject({
Expand All @@ -144,6 +144,34 @@ describe('attachment containers', () => {
});
});

it('shows the staged file name and size', async () => {
const adapter = createAttachmentAdapter(async () => {});

render(
<AttachmentRuntimeHarness adapter={adapter}>
<ComposerAttachmentPickerContainer />
<ComposerAttachmentsContainer />
</AttachmentRuntimeHarness>,
);

fireEvent.click(screen.getByRole('button', { name: 'Add Attachment' }));
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (input === null) {
throw new Error('Expected attachment file input');
}

const file = new File([new Uint8Array(2048)], 'report.pdf', { type: 'application/pdf' });
fireEvent.change(input, { target: { files: [file] } });

expect(await screen.findByText('report.pdf')).toBeInTheDocument();
expect(screen.getByText('2.00 KB')).toBeInTheDocument();
expect(document.querySelector('.aui-composer-attachments')).toHaveClass(
'flex-nowrap',
'overflow-x-auto',
'overflow-y-hidden',
);
});

it('opens a full-size preview for a sent image attachment', () => {
render(
<RuntimeHarness
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('UserMessageContainer', () => {
});

it('renders a file attachment chip above the text bubble', () => {
const fileData = `data:application/pdf;base64,${btoa('x'.repeat(1024))}`;
renderUserMessage([
{
role: 'user',
Expand All @@ -68,7 +69,7 @@ describe('UserMessageContainer', () => {
type: 'file',
mimeType: 'application/pdf',
filename: 'report.pdf',
data: 'data:application/pdf;base64,AAAA',
data: fileData,
},
],
},
Expand All @@ -78,6 +79,8 @@ describe('UserMessageContainer', () => {
expect(screen.getByText('See attached')).toBeInTheDocument();
const chip = screen.getByText('report.pdf').closest("[data-slot='aui_attachment-chip']");
expect(chip).toHaveStyle({ maxWidth: '12rem' });
expect(screen.getByText('1.00 KB')).toBeInTheDocument();
expect(document.querySelector('.aui-user-message-attachments-end')).toHaveClass('flex-wrap');
});

it('shows the action bar when the thread is idle', () => {
Expand Down
Loading