mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
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.
This commit is contained in:
parent
aff8f62882
commit
564a3a3707
3 changed files with 134 additions and 53 deletions
|
|
@ -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 (
|
||||
<div className="my-1">
|
||||
<div className="group/websearch my-1">
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{completedText}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'tool-status-text group/disclosure flex h-5 items-center gap-2 rounded-full',
|
||||
hasSourceData
|
||||
? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
|
||||
: 'pointer-events-none text-text-secondary',
|
||||
)}
|
||||
disabled={!hasSourceData}
|
||||
onClick={hasSourceData ? handleToggleSources : undefined}
|
||||
aria-expanded={hasSourceData ? showSourceList : undefined}
|
||||
aria-label={
|
||||
hasSourceData
|
||||
? `${completedText} - ${localize(sourceCount === 1 ? 'com_ui_web_search_source' : 'com_ui_web_search_sources', { count: sourceCount })}`
|
||||
: completedText
|
||||
}
|
||||
>
|
||||
<span className={ROW_GLYPH_SLOT} aria-hidden="true">
|
||||
<div className="relative flex h-5 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'tool-status-text group/disclosure flex h-5 min-w-0 items-center gap-2 rounded-full',
|
||||
hasSourceData
|
||||
? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
|
||||
: 'pointer-events-none text-text-secondary',
|
||||
)}
|
||||
disabled={!hasSourceData}
|
||||
onClick={hasSourceData ? handleToggleSources : undefined}
|
||||
aria-expanded={hasSourceData ? showSourceList : undefined}
|
||||
aria-label={
|
||||
hasSourceData
|
||||
? `${completedText} - ${localize(sourceCount === 1 ? 'com_ui_web_search_source' : 'com_ui_web_search_sources', { count: sourceCount })}`
|
||||
: completedText
|
||||
}
|
||||
>
|
||||
{hasSourceData ? (
|
||||
<SourceFaviconStack sources={allSources} />
|
||||
) : (
|
||||
<Globe className="size-4 shrink-0 text-text-secondary" />
|
||||
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-medium">{completedText}</span>
|
||||
{hasSourceData && (
|
||||
<ChevronDown
|
||||
className={cn(disclosureChevronClassName, 'size-3.5', showSourceList && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-medium">{completedText}</span>
|
||||
{hasSourceData && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
disclosureChevronClassName,
|
||||
'size-3.5',
|
||||
showSourceList && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{hasDetails && (
|
||||
<HoverCard openDelay={50} open={showDetails} onOpenChange={setShowDetails}>
|
||||
<HoverCardTrigger
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'ml-auto inline-flex cursor-help items-center justify-center rounded-md p-1 text-text-secondary opacity-0 transition-all duration-200 ease-out',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'group-focus-within/websearch:opacity-100 group-hover/websearch:opacity-100',
|
||||
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
onFocus={() => setShowDetails(true)}
|
||||
onBlur={() => setShowDetails(false)}
|
||||
aria-label={localize('com_ui_web_search_details')}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="top" className="z-[999] w-80">
|
||||
<div className="max-h-[60vh] space-y-2 overflow-y-auto">
|
||||
{query && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_ui_search_query')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-text-primary">{query}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-text-secondary">
|
||||
{localize(
|
||||
sourceCount === 1
|
||||
? 'com_ui_web_search_source'
|
||||
: 'com_ui_web_search_sources',
|
||||
{ count: sourceCount },
|
||||
)}
|
||||
</div>
|
||||
{answerBox && (answerBox.title || answerBox.snippet) && (
|
||||
<div className="border-t border-border-light pt-2">
|
||||
{answerBox.title && (
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{answerBox.title}
|
||||
</div>
|
||||
)}
|
||||
{answerBox.snippet && (
|
||||
<div className="mt-1 text-xs leading-relaxed text-text-secondary">
|
||||
{answerBox.snippet}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hasSourceData && (
|
||||
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
|
||||
<div className="overflow-hidden" ref={sourceExpandRef}>
|
||||
|
|
@ -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) && (
|
||||
<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;
|
||||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
<FaviconImage
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ jest.mock('~/hooks', () => ({
|
|||
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: () => <span data-testid="globe-icon" />,
|
||||
Info: () => <span data-testid="info-icon" />,
|
||||
ChevronDown: ({ className }: { className?: string }) => (
|
||||
<span data-testid="chevron-icon" className={className} />
|
||||
),
|
||||
|
|
@ -88,6 +91,7 @@ function renderWebSearch({
|
|||
isSubmitting = false,
|
||||
isLast = false,
|
||||
initialProgress = 1,
|
||||
args,
|
||||
output,
|
||||
}: {
|
||||
searchResults?: Record<string, SearchResultData>;
|
||||
|
|
@ -95,6 +99,7 @@ function renderWebSearch({
|
|||
isSubmitting?: boolean;
|
||||
isLast?: boolean;
|
||||
initialProgress?: number;
|
||||
args?: string | Record<string, unknown>;
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue