diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index 65aa00ed1c..e6525ff8c4 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -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 ); }); -const TextAttachment = memo(({ attachment }: { attachment: Partial }) => { +const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[] }) => { const localize = useLocalize(); - const preId = useId(); - const preRef = useRef(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 ( +
+ +
+ ); + } + + 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 ( -
- {attachment.filepath && ( - - )} -
-
-          {text}
-        
- {overflowed && ( - +
+ +
+
+
+ {groupedAttachments.files.length > 0 && ( +
+ {groupedAttachments.files.map((attachment, index) => ( + + ))} +
+ )} + {groupedAttachments.textPreviews.map((attachment, index) => ( + + ))} +
+
); }); +FileAttachmentGroup.displayName = 'FileAttachmentGroup'; + +const TextAttachment = memo( + ({ + attachment, + showFileChip = true, + }: { + attachment: Partial; + showFileChip?: boolean; + }) => { + const localize = useLocalize(); + const preId = useId(); + const preRef = useRef(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 ( +
+ {attachment.filepath && showFileChip && ( + + )} +
+ {!showFileChip && ( +
+ + {visibleFilename} + + {attachment.filepath && ( + + )} +
+ )} +
+
+              {text}
+            
+ {overflowed && ( + + )} +
+
+
+ ); + }, +); 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 && ( -
- {fileAttachments.map((attachment, index) => - attachment.filepath ? ( - - ) : null, - )} -
+ {groupedFileAttachments.length > 0 && ( + )} {(resolvedPanel.length > 0 || pendingPanel.length > 0) && (
@@ -492,9 +637,9 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] } ))}
)} - {textAttachments.length > 0 && ( + {visibleTextAttachments.length > 0 && (
- {textAttachments.map((attachment, index) => ( + {visibleTextAttachments.map((attachment, index) => ( ({ * 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); const { container } = renderWith(); + 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); + const second = baseAttachment({ + file_id: 'file-b', + filename: 'b.zip', + type: 'application/zip', + } as Partial); + const json = baseAttachment({ + file_id: 'file-c', + filename: 'c.json', + type: 'application/json', + text: '{"c":true}', + } as Partial); + const image = baseAttachment({ + file_id: 'image-a', + filename: 'preview.png', + type: 'image/png', + width: 16, + height: 16, + } as Partial); + + const { container } = renderWith( + , + ); + + 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}'); }); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx index f97c05d281..6996550c52 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx @@ -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(); + + 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(); + 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', + ); + }); }); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c21aed0340..16325fad64 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",