From 564a3a3707f699f44505170bc12a7f60638d5429 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:47:54 +0200 Subject: [PATCH] refactor(client): move web search answer box into a details hover card The inline answer card read as assistant prose sitting inside the source list. The panel is back to sources only; an Info trigger at the right edge of the row (hover-revealed like the chevron, focus-openable for keyboard) opens a hover card with the search query, the source count, and the provider answer box when present. WebSearch now receives the tool call args to surface the query. --- .../Chat/Messages/Content/WebSearch.tsx | 137 ++++++++++++------ .../Content/__tests__/WebSearch.test.tsx | 48 ++++-- client/src/locales/en/translation.json | 2 + 3 files changed, 134 insertions(+), 53 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index a2f2be3082..d7df66a0bb 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -1,7 +1,8 @@ import { useMemo, useState, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { Tools } from 'librechat-data-provider'; -import { Globe, ChevronDown } from 'lucide-react'; +import { Globe, ChevronDown, Info } from 'lucide-react'; +import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from '@librechat/client'; import type { TAttachment, ValidSource, @@ -13,6 +14,7 @@ import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclos import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard'; import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks'; import { StackedFavicons } from '~/components/Web/Sources'; +import parseJsonField from './Parts/parseJsonField'; import { useToolCallIntent } from './Parts/intent'; import { useSearchContext } from '~/Providers'; import { ROW_GLYPH_SLOT } from './rows'; @@ -225,6 +227,7 @@ export default function WebSearch({ const autoExpand = useRecoilValue(store.autoExpandTools); const sourceCount = allSources.length; + const [showDetails, setShowDetails] = useState(false); const [showSourceList, setShowSourceList] = useState(() => autoExpand && sourceCount > 0); const { style: sourceExpandStyle, ref: sourceExpandRef } = useExpandCollapse(showSourceList); const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showSourceList); @@ -253,44 +256,104 @@ export default function WebSearch({ if (complete) { const hasSourceData = sourceCount > 0; const completedText = intent ?? localize('com_ui_web_searched'); + const query = parseJsonField(args, 'query'); + const hasDetails = !!query || !!answerBox; return ( -
+
{completedText} - + {hasDetails && ( + + setShowDetails(true)} + onBlur={() => setShowDetails(false)} + aria-label={localize('com_ui_web_search_details')} + > + + + +
+ {query && ( +
+
+ {localize('com_ui_search_query')} +
+
{query}
+
+ )} +
+ {localize( + sourceCount === 1 + ? 'com_ui_web_search_source' + : 'com_ui_web_search_sources', + { count: sourceCount }, + )} +
+ {answerBox && (answerBox.title || answerBox.snippet) && ( +
+ {answerBox.title && ( +
+ {answerBox.title} +
+ )} + {answerBox.snippet && ( +
+ {answerBox.snippet} +
+ )} +
+ )} +
+
+
+
)} - +
{hasSourceData && (
@@ -301,20 +364,6 @@ 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; @@ -327,7 +376,7 @@ export default function WebSearch({ className={cn( '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', + i > 0 && 'border-t border-border-light', )} > ({ com_ui_web_searched: 'Searched the web', com_ui_web_search_source: `${values?.count ?? 1} source`, com_ui_web_search_sources: `${values?.count ?? 0} sources`, + com_ui_web_search_details: 'Search details', + com_ui_search_query: 'Query', }; return translations[key] || key; }, @@ -56,6 +58,7 @@ jest.mock('~/components/Web/Sources', () => ({ jest.mock('lucide-react', () => ({ Globe: () => , + Info: () => , ChevronDown: ({ className }: { className?: string }) => ( ), @@ -88,6 +91,7 @@ function renderWebSearch({ isSubmitting = false, isLast = false, initialProgress = 1, + args, output, }: { searchResults?: Record; @@ -95,6 +99,7 @@ function renderWebSearch({ isSubmitting?: boolean; isLast?: boolean; initialProgress?: number; + args?: string | Record; output?: string | null; }) { return render( @@ -104,6 +109,7 @@ function renderWebSearch({ initialProgress={initialProgress} isSubmitting={isSubmitting} isLast={isLast} + args={args} output={output} attachments={attachments} /> @@ -291,7 +297,7 @@ describe('WebSearch', () => { expect(matches.length).toBeGreaterThanOrEqual(1); }); - it('renders snippets, dates, and the answer box in the source list', () => { + it('renders snippets and dates in the source list', () => { const attachment = { type: Tools.web_search, [Tools.web_search]: { @@ -304,6 +310,24 @@ describe('WebSearch', () => { date: 'Jun 12, 2026', }, ], + }, + } as unknown as TAttachment; + + renderWebSearch({ attachments: [attachment] }); + + 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('tucks the query and answer box behind the details hover card', async () => { + const attachment = { + type: Tools.web_search, + [Tools.web_search]: { + turn: 0, + organic: [{ link: 'https://example.com/context', title: 'Context windows explained' }], answerBox: { title: 'What is a context window?', snippet: 'The amount of text a model can consider at once.', @@ -311,17 +335,22 @@ describe('WebSearch', () => { }, } as unknown as TAttachment; - renderWebSearch({ attachments: [attachment] }); + renderWebSearch({ + attachments: [attachment], + args: { query: 'largest context window LLM 2026' }, + }); - expect(screen.getByText('What is a context window?')).toBeInTheDocument(); + expect(screen.queryByText('What is a context window?')).not.toBeInTheDocument(); + + const trigger = screen.getByLabelText('Search details'); + fireEvent.focus(trigger); + + expect(await screen.findByText('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(); + expect(screen.getByText('largest context window LLM 2026')).toBeInTheDocument(); + expect(screen.getByText('1 source')).toBeInTheDocument(); }); it('uses standard tool-row spacing and reveals its chevron on hover or focus', () => { @@ -332,7 +361,8 @@ describe('WebSearch', () => { renderWebSearch({ searchResults }); const button = screen.getByRole('button', { name: /Searched the web/ }); - expect(button.parentElement).toHaveClass('my-1'); + expect(button.parentElement).toHaveClass('h-5'); + expect(button.parentElement?.parentElement).toHaveClass('my-1'); expect(button).not.toHaveClass('py-1'); expect(button).not.toHaveClass('transition-colors'); const chevron = screen.getByTestId('chevron-icon'); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1eea75bb61..76029faeae 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1983,6 +1983,7 @@ "com_ui_search_language": "Search languages...", "com_ui_search_people_placeholder": "Search for people or groups by name or email", "com_ui_search_projects": "Search projects", + "com_ui_search_query": "Query", "com_ui_search_result_count": "{{count}} result found", "com_ui_search_results_count": "{{count}} results found", "com_ui_search_skills": "Search skills...", @@ -2560,6 +2561,7 @@ "com_ui_web_search_scraper_tavily_key": "Get your Tavily API key", "com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)", "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", + "com_ui_web_search_details": "Search details", "com_ui_web_search_source": "{{count}} source", "com_ui_web_search_sources": "{{count}} sources", "com_ui_web_searched": "Searched the web",