mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🗂️ 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:
parent
c582e87e3b
commit
176e07755e
4 changed files with 363 additions and 95 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue