fix(client): gate retrieval expansion on renderable results

The row offered an expand affordance whenever raw output existed even
when no sources parsed, opening an empty panel. Expansion now keys off
parsed results, which also covers attachment-only sources that arrive
without text output, and collapses if results disappear.
This commit is contained in:
Marco Beretta 2026-07-31 02:26:25 +02:00
parent b01680826b
commit 59702efcc2
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 61 additions and 9 deletions

View file

@ -7,6 +7,7 @@ import type { TAttachment, TFile, PartMetadata } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { ToolIcon, OutputRenderer, isError } from './ToolOutput';
import { resolveToolCallPhase } from '~/utils/toolCallPhase';
import { toolPanelSpacingClassName } from './disclosure';
import FilePreviewDialog from './FilePreviewDialog';
import { sortPagesByRelevance, cn } from '~/utils';
import { useToolCallIntent } from './Parts/intent';
@ -380,8 +381,6 @@ export default function RetrievalCall({
});
const hasOutput = !!output && !isError(output);
const autoExpand = useRecoilValue(store.autoExpandTools);
const [showOutput, setShowOutput] = useState(() => autoExpand && hasOutput);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showOutput);
const fileSources = useMemo(() => extractFileSources(attachments), [attachments]);
const parsedResults = useMemo(
@ -402,6 +401,9 @@ export default function RetrievalCall({
);
const hasResults = displayResults.length > 0;
const hasExpandableContent = phase !== 'failed' && hasResults;
const [showOutput, setShowOutput] = useState(() => autoExpand && hasExpandableContent);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showOutput);
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
@ -437,10 +439,14 @@ export default function RetrievalCall({
}, [displayResults, previewIndex]);
useEffect(() => {
if (autoExpand && hasOutput) {
if (!hasExpandableContent) {
setShowOutput(false);
return;
}
if (autoExpand) {
setShowOutput(true);
}
}, [autoExpand, hasOutput]);
}, [autoExpand, hasExpandableContent]);
const handleToggleOutput = useCallback(() => {
setShowOutput((prev) => {
@ -453,7 +459,7 @@ export default function RetrievalCall({
}, [onExpand]);
return (
<div className="my-1">
<div>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{(() => {
if (phase === 'running') {
@ -474,7 +480,7 @@ export default function RetrievalCall({
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
phase={phase}
onClick={hasOutput ? handleToggleOutput : undefined}
onClick={hasExpandableContent ? handleToggleOutput : undefined}
inProgressText={intent ?? localize('com_ui_searching_files')}
/** A cancelled step must not read "Retrieved files" beside a
* cancellation icon while the live region says "Cancelled". */
@ -485,14 +491,14 @@ export default function RetrievalCall({
}
durationMs={runStepDurationMs}
icon={<ToolIcon type="file_search" isAnimating={phase === 'running'} />}
hasInput={hasOutput}
hasInput={hasExpandableContent}
isExpanded={showOutput}
/>
</div>
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
{hasOutput && hasResults && (
<div className="my-2 flex flex-col gap-2">
{hasExpandableContent && (
<div className={cn(toolPanelSpacingClassName, 'flex flex-col gap-2')}>
{displayResults.map((item, i) => {
return (
<div

View file

@ -73,6 +73,7 @@ jest.mock('../ToolOutput', () => ({
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(' '),
sortPagesByRelevance: (pages: number[]) => pages,
logger: { error: jest.fn(), debug: jest.fn() },
}));
@ -168,6 +169,51 @@ describe('RetrievalCall - LGCY-02: Collapsible output panel', () => {
expect(screen.queryByText('file results here')).not.toBeInTheDocument();
});
it('does not offer expansion when output has no renderable results', () => {
renderRetrievalCall({
output: 'Search completed without matches',
initialProgress: 1,
isSubmitting: false,
});
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-has-input', 'false');
fireEvent.click(progressText);
expect(progressText).toHaveAttribute('data-expanded', 'false');
});
it('offers attachment results even when the tool has no text output', () => {
renderRetrievalCall({
initialProgress: 1,
isSubmitting: false,
attachments: [
{
type: 'file_search',
toolCallId: 'call-1',
file_search: {
sources: [
{
fileId: 'file-123',
fileName: 'notes.txt',
relevance: 0.8,
content: 'Attachment result content',
pages: [],
pageRelevance: {},
},
],
},
},
] as unknown as TAttachment[],
});
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-has-input', 'true');
fireEvent.click(progressText);
expect(screen.getByText('Attachment result content')).toBeInTheDocument();
});
it('does not show panel when output contains error', () => {
renderRetrievalCall({
output: 'error processing tool',