feat(client): richer web search source list

The panel only showed favicon, title, and domain while the search
artifact carries much more. Source rows now include the result snippet
(clamped to two lines) and date under the domain, and a Serper answer
box renders as a lead card above the list with its title and snippet.
Streaming behavior is unchanged; the answer box is attachment-only
since it never carries citation anchors.
This commit is contained in:
Marco Beretta 2026-07-31 03:29:14 +02:00
parent ae07f45aad
commit aff8f62882
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 89 additions and 6 deletions

View file

@ -7,6 +7,7 @@ import type {
ValidSource,
SearchResultData,
PartMetadata,
AnswerBoxResult,
} from 'librechat-data-provider';
import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure';
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
@ -175,6 +176,22 @@ export default function WebSearch({
return [];
}, [searchResults, attachments, ownTurn]);
/** Direct-answer card shown at the top of the source list. Attachment-only:
* answer boxes are provider extras (Serper) that never carry citations, so
* the streaming path does not need them. */
const answerBox = useMemo((): AnswerBoxResult | undefined => {
if (!attachments) {
return undefined;
}
for (const att of attachments) {
const answer = att.type === Tools.web_search ? att[Tools.web_search]?.answerBox : undefined;
if (answer) {
return answer;
}
}
return undefined;
}, [attachments]);
// Show favicons from the raw SERP results immediately rather than waiting for
// each source to flip to `processed`; the agents scrape barrier would otherwise
// freeze the stack on "Searching the web" for the slowest scrape's duration.
@ -284,8 +301,23 @@ export default function WebSearch({
'mt-1.5 max-h-[280px] overflow-y-auto rounded-lg border border-border-light',
)}
>
{answerBox && (answerBox.title || answerBox.snippet) && (
<div className="border-b border-border-light px-3 py-2.5">
{answerBox.title && (
<div className="text-xs font-semibold text-text-primary">
{answerBox.title}
</div>
)}
{answerBox.snippet && (
<div className="mt-0.5 text-xs leading-relaxed text-text-secondary">
{answerBox.snippet}
</div>
)}
</div>
)}
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
const snippet = 'snippet' in source ? source.snippet : undefined;
return (
<a
key={source.link}
@ -293,15 +325,33 @@ export default function WebSearch({
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
i > 0 && 'border-t border-border-light',
'flex gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
snippet ? 'items-start' : 'items-center',
(i > 0 || !!answerBox) && 'border-t border-border-light',
)}
>
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
{source.title || domain}
<FaviconImage
domain={domain}
className={cn('size-4 shrink-0 rounded-sm', snippet && 'mt-0.5')}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-text-primary">
{source.title || domain}
</span>
{snippet && (
<span className="mt-0.5 line-clamp-2 block text-[11px] leading-relaxed text-text-secondary">
{snippet}
</span>
)}
</span>
<span className="shrink-0 text-right">
<span className="block text-[11px] text-text-secondary">{domain}</span>
{source.date && (
<span className="block text-[10px] text-text-secondary">
{source.date}
</span>
)}
</span>
<span className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
</a>
);
})}

View file

@ -291,6 +291,39 @@ describe('WebSearch', () => {
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('renders snippets, dates, and the answer box in the source list', () => {
const attachment = {
type: Tools.web_search,
[Tools.web_search]: {
turn: 0,
organic: [
{
link: 'https://example.com/context',
title: 'Context windows explained',
snippet: 'How context windows change what assistants can do.',
date: 'Jun 12, 2026',
},
],
answerBox: {
title: 'What is a context window?',
snippet: 'The amount of text a model can consider at once.',
},
},
} as unknown as TAttachment;
renderWebSearch({ attachments: [attachment] });
expect(screen.getByText('What is a context window?')).toBeInTheDocument();
expect(
screen.getByText('The amount of text a model can consider at once.'),
).toBeInTheDocument();
expect(
screen.getByText('How context windows change what assistants can do.'),
).toBeInTheDocument();
expect(screen.getByText('Jun 12, 2026')).toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
});
it('uses standard tool-row spacing and reveals its chevron on hover or focus', () => {
const searchResults = makeSearchResults({
0: { organic: [makeSource('https://example.com', 'Example')] },