🗂️ refactor: Collapse Generated File Chips (#13116)

* fix: Collapse generated file chips

* style: Apply file chip formatting

* style: Sort grouped file locale key

* fix: Collapse text-backed file outputs

* style: Format text-backed file grouping

* fix: Preview grouped text file outputs

* fix: Count downloadable file outputs

* test: Cover grouped text preview clamp
This commit is contained in:
Danny Avila 2026-05-14 07:47:05 -04:00 committed by GitHub
parent c582e87e3b
commit 176e07755e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 363 additions and 95 deletions

View file

@ -1,5 +1,5 @@
import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Loader2, AlertCircle, Download } from 'lucide-react';
import { Loader2, AlertCircle, Download, ChevronDown, Files as FilesIcon } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import type { ToolArtifactType } from '~/utils/artifacts';
@ -20,7 +20,7 @@ import Image from '~/components/Chat/Messages/Content/Image';
import ToolMermaidArtifact from './ToolMermaidArtifact';
import ToolArtifactCard from './ToolArtifactCard';
import { useAttachmentLink } from './LogLink';
import { useLocalize, useAttachmentPreviewSync } from '~/hooks';
import { useLocalize, useAttachmentPreviewSync, useExpandCollapse } from '~/hooks';
import { cn, getFileType } from '~/utils';
const COLLAPSED_MAX_HEIGHT = 320;
@ -197,92 +197,232 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
);
});
const TextAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[] }) => {
const localize = useLocalize();
const preId = useId();
const preRef = useRef<HTMLPreElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [expanded, setExpanded] = useState(false);
// Decided once after layout: does the text actually overflow the collapsed
// height? Char count is a poor proxy (a 100-char file with many newlines can
// overflow; 800 chars of dense single-line text may not), so we measure.
const [overflowed, setOverflowed] = useState(false);
const file = attachment as TFile & TAttachmentMetadata;
const { handleDownload } = useAttachmentLink({
href: attachment.filepath ?? '',
filename: attachment.filename ?? '',
file_id: file.file_id,
user: file.user,
source: file.source,
});
const extension = attachment.filename?.split('.').pop();
const text = file.text ?? '';
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
}, []);
useLayoutEffect(() => {
const el = preRef.current;
if (!el) {
return;
const panelId = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
const visibleAttachments = useMemo(
() => attachments.filter((attachment) => Boolean(attachment.filepath)),
[attachments],
);
const count = visibleAttachments.length;
const summary = useMemo(() => {
const names = visibleAttachments.map((attachment) => displayFilename(attachment.filename));
if (names.length <= 2) {
return names.join(', ');
}
setOverflowed(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 1);
}, [text]);
return `${names.slice(0, 2).join(', ')} ${localize('com_ui_plus_n_more', {
0: String(names.length - 2),
})}`;
}, [visibleAttachments, localize]);
const groupedAttachments = useMemo(() => {
const files: TAttachment[] = [];
const textPreviews: TAttachment[] = [];
for (const attachment of visibleAttachments) {
if (isTextAttachment(attachment)) {
textPreviews.push(attachment);
continue;
}
files.push(attachment);
}
return { files, textPreviews };
}, [visibleAttachments]);
const isClamped = overflowed && !expanded;
if (count === 0) {
return null;
}
if (count === 1) {
const [attachment] = visibleAttachments;
if (!attachment) {
return null;
}
return (
<div className="my-2 flex flex-wrap items-center gap-2.5">
<FileAttachment attachment={attachment} key={renderAttachmentKey('file', attachment, 0)} />
</div>
);
}
const fileCount = localize('com_ui_n_files', { 0: String(count) });
const buttonLabel = isExpanded
? localize('com_ui_hide_n_files', { 0: String(count) })
: localize('com_ui_show_n_files', { 0: String(count) });
return (
<div
className={cn(
'text-attachment-container flex w-full flex-col gap-1.5',
'transition-all duration-300 ease-out',
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{
transformOrigin: 'center top',
willChange: 'opacity, transform',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
{attachment.filepath && (
<FileContainer
file={attachment}
onClick={handleDownload}
overrideType={extension}
displayName={displayFilename(attachment.filename)}
containerClassName="max-w-fit"
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
/>
)}
<div className="rounded-lg bg-surface-secondary p-4">
<pre
id={preId}
ref={preRef}
className={cn(
'whitespace-pre-wrap break-words font-mono text-sm leading-6 text-text-primary',
isClamped ? 'overflow-hidden' : 'overflow-auto',
)}
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
>
{text}
</pre>
{overflowed && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={preId}
className="mt-2 text-xs text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
{expanded ? localize('com_ui_collapse') : localize('com_ui_show_all')}
</button>
<div className="my-2 w-full max-w-full">
<button
type="button"
aria-expanded={isExpanded}
aria-controls={panelId}
aria-label={buttonLabel}
onClick={() => setIsExpanded((prev) => !prev)}
className={cn(
'inline-flex w-full max-w-full items-center gap-2 rounded-lg py-1 pr-2 text-sm',
'text-text-secondary transition-colors hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
)}
>
<FilesIcon className="size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0 font-medium">{fileCount}</span>
{summary.length > 0 && (
<span className="min-w-0 truncate text-left text-xs font-normal" title={summary}>
{'— '}
{summary}
</span>
)}
<ChevronDown
className={cn(
'ml-auto size-4 shrink-0 transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
</button>
<div id={panelId} style={expandStyle}>
<div className="overflow-hidden" ref={expandRef} aria-hidden={!isExpanded}>
<div className="flex flex-col gap-2.5 pt-2">
{groupedAttachments.files.length > 0 && (
<div className="flex flex-wrap items-center gap-2.5">
{groupedAttachments.files.map((attachment, index) => (
<FileAttachment
attachment={attachment}
key={renderAttachmentKey('file', attachment, index)}
/>
))}
</div>
)}
{groupedAttachments.textPreviews.map((attachment, index) => (
<TextAttachment
attachment={attachment}
showFileChip={false}
key={renderAttachmentKey('text', attachment, index)}
/>
))}
</div>
</div>
</div>
</div>
);
});
FileAttachmentGroup.displayName = 'FileAttachmentGroup';
const TextAttachment = memo(
({
attachment,
showFileChip = true,
}: {
attachment: Partial<TAttachment>;
showFileChip?: boolean;
}) => {
const localize = useLocalize();
const preId = useId();
const preRef = useRef<HTMLPreElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [expanded, setExpanded] = useState(false);
// Decided once after layout: does the text actually overflow the collapsed
// height? Char count is a poor proxy (a 100-char file with many newlines can
// overflow; 800 chars of dense single-line text may not), so we measure.
const [overflowed, setOverflowed] = useState(false);
const file = attachment as TFile & TAttachmentMetadata;
const { handleDownload } = useAttachmentLink({
href: attachment.filepath ?? '',
filename: attachment.filename ?? '',
file_id: file.file_id,
user: file.user,
source: file.source,
});
const extension = attachment.filename?.split('.').pop();
const text = file.text ?? '';
const visibleFilename = displayFilename(attachment.filename);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
}, []);
useLayoutEffect(() => {
const el = preRef.current;
if (!el) {
return;
}
setOverflowed(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 1);
}, [text]);
const isClamped = overflowed && !expanded;
return (
<div
className={cn(
'text-attachment-container flex w-full flex-col gap-1.5',
'transition-all duration-300 ease-out',
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{
transformOrigin: 'center top',
willChange: 'opacity, transform',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
{attachment.filepath && showFileChip && (
<FileContainer
file={attachment}
onClick={handleDownload}
overrideType={extension}
displayName={displayFilename(attachment.filename)}
containerClassName="max-w-fit"
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
/>
)}
<div className="overflow-hidden rounded-lg bg-surface-secondary">
{!showFileChip && (
<div className="flex items-center justify-between gap-2 border-b border-border-light px-3 py-2">
<span className="min-w-0 truncate text-sm font-medium" title={visibleFilename}>
{visibleFilename}
</span>
{attachment.filepath && (
<button
type="button"
onClick={handleDownload}
aria-label={`${localize('com_ui_download')} ${visibleFilename}`}
title={localize('com_ui_download')}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
<Download className="size-4" aria-hidden="true" />
</button>
)}
</div>
)}
<div className="p-4">
<pre
id={preId}
ref={preRef}
className={cn(
'whitespace-pre-wrap break-words font-mono text-sm leading-6 text-text-primary',
isClamped ? 'overflow-hidden' : 'overflow-auto',
)}
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
>
{text}
</pre>
{overflowed && (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={preId}
className="mt-2 text-xs text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
{expanded ? localize('com_ui_collapse') : localize('com_ui_show_all')}
</button>
)}
</div>
</div>
</div>
);
},
);
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
const [isLoaded, setIsLoaded] = useState(false);
@ -449,19 +589,24 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }
mermaidArtifacts.sort(bySalience);
imageAttachments.sort(bySalience);
const downloadableFileAttachments = fileAttachments.filter((attachment) =>
Boolean(attachment.filepath),
);
const downloadableTextAttachments = textAttachments.filter((attachment) =>
Boolean(attachment.filepath),
);
const textOnlyAttachments = textAttachments.filter((attachment) => !attachment.filepath);
const groupDownloadableFiles =
downloadableFileAttachments.length + downloadableTextAttachments.length > 1;
const groupedFileAttachments = groupDownloadableFiles
? [...downloadableFileAttachments, ...downloadableTextAttachments].sort(bySalience)
: downloadableFileAttachments;
const visibleTextAttachments = groupDownloadableFiles ? textOnlyAttachments : textAttachments;
return (
<>
{fileAttachments.length > 0 && (
<div className="my-2 flex flex-wrap items-center gap-2.5">
{fileAttachments.map((attachment, index) =>
attachment.filepath ? (
<FileAttachment
attachment={attachment}
key={renderAttachmentKey('file', attachment, index)}
/>
) : null,
)}
</div>
{groupedFileAttachments.length > 0 && (
<FileAttachmentGroup attachments={groupedFileAttachments} />
)}
{(resolvedPanel.length > 0 || pendingPanel.length > 0) && (
<div className="my-2 flex flex-wrap items-center gap-2">
@ -492,9 +637,9 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }
))}
</div>
)}
{textAttachments.length > 0 && (
{visibleTextAttachments.length > 0 && (
<div className="my-2 flex flex-col gap-3">
{textAttachments.map((attachment, index) => (
{visibleTextAttachments.map((attachment, index) => (
<TextAttachment
attachment={attachment}
key={renderAttachmentKey('text', attachment, index)}

View file

@ -16,6 +16,10 @@ jest.mock('~/hooks', () => ({
* routing tests don't exercise the preview flow itself stub it
* to a no-op so it doesn't blow up jsdom rendering. */
useAttachmentPreviewSync: () => ({ status: 'ready', previewError: undefined, isPolling: false }),
useExpandCollapse: (isExpanded: boolean) => ({
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
ref: { current: null },
}),
}));
jest.mock('../LogLink', () => ({
@ -716,6 +720,7 @@ describe('AttachmentGroup routing', () => {
bytes: 1024,
} as Partial<TAttachment>);
const { container } = renderWith(<AttachmentGroup attachments={[empty, real]} />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_n_files' }));
const chips = Array.from(container.querySelectorAll('[data-testid="file-container"]'));
expect(chips.length).toBe(2);
const filenames = chips.map((c) => c.textContent ?? '');
@ -724,6 +729,50 @@ describe('AttachmentGroup routing', () => {
expect(filenames[1]).toMatch(/placeholder\.zip/);
});
it('keeps multiple downloadable files in their own collapsed group while images render outwardly', () => {
const first = baseAttachment({
file_id: 'file-a',
filename: 'a.zip',
type: 'application/zip',
} as Partial<TAttachment>);
const second = baseAttachment({
file_id: 'file-b',
filename: 'b.zip',
type: 'application/zip',
} as Partial<TAttachment>);
const json = baseAttachment({
file_id: 'file-c',
filename: 'c.json',
type: 'application/json',
text: '{"c":true}',
} as Partial<TAttachment>);
const image = baseAttachment({
file_id: 'image-a',
filename: 'preview.png',
type: 'image/png',
width: 16,
height: 16,
} as Partial<TAttachment>);
const { container } = renderWith(
<AttachmentGroup attachments={[first, second, json, image]} />,
);
const toggle = screen.getByRole('button', { name: 'com_ui_show_n_files' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? '');
expect(panel?.firstElementChild).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByTestId('image')).toBeInTheDocument();
expect(screen.getAllByTestId('file-container').map((chip) => chip.textContent)).not.toContain(
'c.json',
);
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('c.json')).toBeInTheDocument();
expect(container.querySelector('pre')?.textContent).toBe('{"c":true}');
});
it('passes a non-dotfile filename through to FileContainer unchanged', () => {
/** `displayFilename` deliberately leaves non-dotfile names alone
* the `-<6 hex>` tail on `archive-deadbe.zip` could be either a
@ -826,9 +875,15 @@ describe('AttachmentGroup routing', () => {
expect(screen.getByText('index.html')).toBeInTheDocument();
// Mermaid render
expect(screen.getByTestId('mermaid-render')).toBeInTheDocument();
// Inline text fallback for JSON (CSV now goes to SPREADSHEET)
expect(container.querySelector('pre')).not.toBeNull();
// FileContainer for the plain zip (and potentially others)
expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0);
// JSON and plain zip are both downloadable file outputs, so they collapse together.
const toggle = screen.getByRole('button', { name: 'com_ui_show_n_files' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
const chipLabels = screen.getAllByTestId('file-container').map((chip) => chip.textContent);
expect(chipLabels).toContain('archive.zip');
expect(chipLabels).not.toContain('data.json');
fireEvent.click(toggle);
expect(screen.getByText('data.json')).toBeInTheDocument();
expect(container.querySelector('pre')?.textContent).toBe('{"a":1}');
});
});

View file

@ -17,6 +17,10 @@ jest.mock('~/hooks', () => ({
* deferred-preview lifecycle. Stub to a no-op for tests that
* don't exercise the preview flow. */
useAttachmentPreviewSync: () => ({ status: 'ready', previewError: undefined, isPolling: false }),
useExpandCollapse: (isExpanded: boolean) => ({
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
ref: { current: null },
}),
}));
const mockHandleDownload = jest.fn();
@ -194,4 +198,64 @@ describe('AttachmentGroup', () => {
expect(container.querySelector('pre')).toBeNull();
expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0);
});
it('does not collapse a single downloadable text preview with a non-downloadable placeholder', () => {
const attachments = [
textAttachment({
file_id: 'placeholder',
filename: 'placeholder.zip',
filepath: '',
type: 'application/zip',
text: undefined as unknown as string,
}),
textAttachment({
file_id: 'json',
filename: 'output.json',
filepath: '/files/output.json',
text: '{"ok":true}',
}),
] as TAttachment[];
const { container } = render(<AttachmentGroup attachments={attachments} />);
expect(screen.queryByRole('button', { name: 'com_ui_show_n_files' })).not.toBeInTheDocument();
expect(container.querySelector('pre')?.textContent).toBe('{"ok":true}');
expect(screen.getByTestId('file-container')).toHaveTextContent('output.json');
});
it('keeps long grouped text previews clamped until the nested preview is expanded', () => {
setScrollHeight(800);
const longJson = Array.from({ length: 1000 }, (_, index) => `{"line":${index}}`).join('\n');
const attachments = [
textAttachment({
file_id: 'archive',
filename: 'archive.zip',
type: 'application/zip',
text: undefined as unknown as string,
}),
textAttachment({
file_id: 'json',
filename: 'output.json',
filepath: '/files/output.json',
text: longJson,
}),
] as TAttachment[];
const { container } = render(<AttachmentGroup attachments={attachments} />);
const groupToggle = screen.getByRole('button', { name: 'com_ui_show_n_files' });
fireEvent.click(groupToggle);
expect(screen.getByText('output.json')).toBeInTheDocument();
const pre = container.querySelector('pre');
expect(pre).not.toBeNull();
expect(pre).toHaveStyle({ maxHeight: '320px' });
const previewToggle = screen.getByRole('button', { name: 'Show all' });
expect(previewToggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(previewToggle);
expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
});

View file

@ -1088,6 +1088,7 @@
"com_ui_hide": "Hide",
"com_ui_hide_code": "Hide Code",
"com_ui_hide_image_details": "Hide Image Details",
"com_ui_hide_n_files": "Hide {{0}} files",
"com_ui_hide_password": "Hide password",
"com_ui_hide_qr": "Hide QR Code",
"com_ui_high": "High",
@ -1236,6 +1237,7 @@
"com_ui_more_info": "More info",
"com_ui_my_prompts": "My Prompts",
"com_ui_my_skills": "My Skills",
"com_ui_n_files": "{{0}} files",
"com_ui_name": "Name",
"com_ui_name_sort": "Sort by Name",
"com_ui_navigate_results": "Navigate results",
@ -1304,6 +1306,7 @@
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
"com_ui_permissions_updated_success": "Permissions updated successfully",
"com_ui_pin": "Pin",
"com_ui_plus_n_more": "+{{0}} more",
"com_ui_preferences_updated": "Preferences updated successfully",
"com_ui_prev": "Prev",
"com_ui_prev_result": "Previous result",
@ -1471,6 +1474,7 @@
"com_ui_show_code": "Show Code",
"com_ui_show_image_details": "Show Image Details",
"com_ui_show_less": "Show less",
"com_ui_show_n_files": "Show {{0}} files",
"com_ui_show_more": "Show more",
"com_ui_show_password": "Show password",
"com_ui_show_qr": "Show QR Code",