diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index 9ba2647bed..a2f2be3082 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -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) && ( +
+ {answerBox.title && ( +
+ {answerBox.title} +
+ )} + {answerBox.snippet && ( +
+ {answerBox.snippet} +
+ )} +
+ )} {allSources.map((source, i) => { const domain = getCleanDomain(source.link); + const snippet = 'snippet' in source ? source.snippet : undefined; return ( 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', )} > - - - {source.title || domain} + + + + {source.title || domain} + + {snippet && ( + + {snippet} + + )} + + + {domain} + {source.date && ( + + {source.date} + + )} - {domain} ); })} diff --git a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx index 59692eed4e..b4907d0e0e 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx @@ -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')] },