mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: Preview grouped text file outputs
This commit is contained in:
parent
6cf3143570
commit
87f3a61f04
2 changed files with 153 additions and 92 deletions
|
|
@ -216,6 +216,18 @@ const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[]
|
|||
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]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
|
|
@ -270,11 +282,22 @@ const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[]
|
|||
</button>
|
||||
<div id={panelId} style={expandStyle}>
|
||||
<div className="overflow-hidden" ref={expandRef} aria-hidden={!isExpanded}>
|
||||
<div className="flex flex-wrap items-center gap-2.5 pt-2">
|
||||
{visibleAttachments.map((attachment, index) => (
|
||||
<FileAttachment
|
||||
<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}
|
||||
key={renderAttachmentKey('file', attachment, index)}
|
||||
showFileChip={false}
|
||||
key={renderAttachmentKey('text', attachment, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -285,92 +308,121 @@ const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[]
|
|||
});
|
||||
FileAttachmentGroup.displayName = 'FileAttachmentGroup';
|
||||
|
||||
const TextAttachment = memo(({ attachment }: { attachment: Partial<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 ?? '';
|
||||
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);
|
||||
}, []);
|
||||
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]);
|
||||
useLayoutEffect(() => {
|
||||
const el = preRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
setOverflowed(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 1);
|
||||
}, [text]);
|
||||
|
||||
const isClamped = overflowed && !expanded;
|
||||
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 && (
|
||||
<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>
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
|
|
|||
|
|
@ -762,8 +762,15 @@ describe('AttachmentGroup routing', () => {
|
|||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? '');
|
||||
expect(panel?.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
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', () => {
|
||||
|
|
@ -869,12 +876,14 @@ describe('AttachmentGroup routing', () => {
|
|||
// Mermaid render
|
||||
expect(screen.getByTestId('mermaid-render')).toBeInTheDocument();
|
||||
// JSON and plain zip are both downloadable file outputs, so they collapse together.
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_show_n_files' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
// FileContainer chips are still mounted inside the collapsed panel.
|
||||
expect(screen.getAllByTestId('file-container').length).toBeGreaterThanOrEqual(2);
|
||||
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}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue