feat(client): surface search verticals outside tool disclosures

Image, shopping, and place results were only visible after expanding
the web search row, and disappeared entirely inside a collapsed tool
group. The strips moved into a shared verticals module that renders
in two spots following the attachment pattern: always below the row
for an ungrouped search, and hoisted below the group (visible while
collapsed) for grouped calls, where the row suppresses its own copy
via hideAttachments. The disclosure panel is back to sources only.
This commit is contained in:
Marco Beretta 2026-07-31 04:40:28 +02:00
parent 309d2ab3e8
commit ea9bd91a81
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 247 additions and 221 deletions

View file

@ -372,6 +372,7 @@ const Part = memo(function Part({
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
attachments={attachments}
hideAttachments={hideAttachments}
isLast={isLast}
onExpand={onToolExpand}
/>

View file

@ -24,6 +24,7 @@ import { isError, StackedToolIcons } from './ToolOutput';
import { isBashProgrammaticToolCall } from './routing';
import { ASK_USER_QUESTION } from '~/utils/approval';
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
import SearchVerticals from './verticals';
import { ROW_GLYPH_SLOT } from './rows';
import store from '~/store';
@ -565,7 +566,10 @@ export default function ToolCallGroup({
)}
</div>
{groupAttachments && groupAttachments.length > 0 && (
<AttachmentGroup attachments={groupAttachments} />
<>
<SearchVerticals attachments={groupAttachments} />
<AttachmentGroup attachments={groupAttachments} />
</>
)}
</div>
);

View file

@ -1,7 +1,7 @@
import { useMemo, useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { Globe, ChevronDown, Info, Star, MapPin } from 'lucide-react';
import { Globe, ChevronDown, Info } from 'lucide-react';
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from '@librechat/client';
import type {
TAttachment,
@ -9,9 +9,6 @@ import type {
SearchResultData,
PartMetadata,
AnswerBoxResult,
ShoppingResult,
ImageResult,
PlaceResult,
} from 'librechat-data-provider';
import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure';
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
@ -20,6 +17,7 @@ import { StackedFavicons } from '~/components/Web/Sources';
import parseJsonField from './Parts/parseJsonField';
import { useToolCallIntent } from './Parts/intent';
import { useSearchContext } from '~/Providers';
import SearchVerticals from './verticals';
import { ROW_GLYPH_SLOT } from './rows';
import cn from '~/utils/cn';
import store from '~/store';
@ -69,133 +67,6 @@ function getUniqueDomainSources(sources: ValidSource[], max: number): ValidSourc
return result;
}
const MAX_VERTICAL_ITEMS = 8;
const MAX_PLACES = 5;
function RatingBadge({ rating, ratingCount }: { rating?: number; ratingCount?: number }) {
if (rating == null) {
return null;
}
return (
<span className="flex items-center gap-1 text-[11px] text-text-secondary">
<Star className="size-3 fill-current" aria-hidden="true" />
{rating}
{ratingCount != null && <span className="text-text-secondary">({ratingCount})</span>}
</span>
);
}
function ImageStrip({ images, label }: { images: ImageResult[]; label: string }) {
return (
<ul className="flex list-none gap-2 overflow-x-auto pb-1" aria-label={label}>
{images.slice(0, MAX_VERTICAL_ITEMS).map((image, i) => {
const href = image.link || image.googleUrl || image.imageUrl;
const ratio =
image.thumbnailWidth && image.thumbnailHeight
? `${image.thumbnailWidth} / ${image.thumbnailHeight}`
: '1 / 1';
return (
<li key={image.thumbnailUrl || image.imageUrl || i} className="shrink-0">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={image.title || image.domain || label}
className="block h-28 overflow-hidden rounded-xl border border-border-light no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
style={{ aspectRatio: ratio }}
>
<img
src={image.thumbnailUrl || image.imageUrl}
alt={image.title || ''}
className="h-full w-full object-cover"
loading="lazy"
/>
</a>
</li>
);
})}
</ul>
);
}
function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: string }) {
return (
<ul className="flex list-none gap-2.5 overflow-x-auto pb-1" aria-label={label}>
{items.slice(0, MAX_VERTICAL_ITEMS).map((item, i) => (
<li key={item.productId || item.link || i} className="flex w-40 shrink-0">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="flex w-full flex-col rounded-xl border border-border-light p-2 no-underline transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
{item.imageUrl && (
<div className="mb-2 aspect-square w-full shrink-0 overflow-hidden rounded-lg bg-surface-tertiary">
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
)}
<span className="mt-auto block truncate text-xs font-medium text-text-primary">
{item.title}
</span>
{(item.price || item.source) && (
<span className="mt-0.5 block truncate text-[11px] text-text-secondary">
{[item.price, item.source].filter(Boolean).join(' · ')}
</span>
)}
{item.delivery && (
<span className="block truncate text-[11px] text-text-secondary">
{item.delivery}
</span>
)}
<span className="mt-0.5 block">
<RatingBadge rating={item.rating} ratingCount={item.ratingCount} />
</span>
</a>
</li>
))}
</ul>
);
}
function PlaceList({ places, label }: { places: PlaceResult[]; label: string }) {
return (
<ul
className="list-none overflow-hidden rounded-lg border border-border-light"
aria-label={label}
>
{places.slice(0, MAX_PLACES).map((place, i) => (
<li
key={place.identifier || place.name || i}
className={cn(
'flex items-center gap-2.5 px-3 py-2',
i > 0 && 'border-t border-border-light',
)}
>
<MapPin className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-text-primary">
{place.name}
</span>
{(place.category || place.address) && (
<span className="block truncate text-[11px] text-text-secondary">
{[place.category, place.address].filter(Boolean).join(' · ')}
</span>
)}
</span>
<span className="shrink-0">
<RatingBadge rating={place.rating} ratingCount={place.ratingCount} />
</span>
</li>
))}
</ul>
);
}
function SourceFaviconStack({ sources }: { sources: ValidSource[] }) {
const visible = getUniqueDomainSources(sources, MAX_VISIBLE_FAVICONS);
return (
@ -224,6 +95,7 @@ export default function WebSearch({
args,
output,
attachments,
hideAttachments = false,
onExpand,
runStepStatus,
}: {
@ -233,6 +105,7 @@ export default function WebSearch({
output?: string | null;
initialProgress: number;
attachments?: TAttachment[];
hideAttachments?: boolean;
onExpand?: () => void;
runStepStatus?: PartMetadata['runStepStatus'];
}) {
@ -324,24 +197,6 @@ export default function WebSearch({
return undefined;
}, [attachments]);
/** Provider verticals (Serper only): image/shopping/place results merged
* across this call's attachments. Attachment-only like the answer box. */
const verticals = useMemo(() => {
const images: ImageResult[] = [];
const shopping: ShoppingResult[] = [];
const places: PlaceResult[] = [];
for (const att of attachments ?? []) {
const data = att.type === Tools.web_search ? att[Tools.web_search] : undefined;
if (!data) {
continue;
}
images.push(...(data.images ?? []));
shopping.push(...(data.shopping ?? []));
places.push(...(data.places ?? []));
}
return { images, shopping, places };
}, [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.
@ -403,9 +258,6 @@ export default function WebSearch({
if (complete) {
const hasSourceData = sourceCount > 0;
const hasVerticals =
verticals.images.length > 0 || verticals.shopping.length > 0 || verticals.places.length > 0;
const hasExpandable = hasSourceData || hasVerticals;
const completedText = intent ?? localize('com_ui_web_searched');
const query = parseJsonField(args, 'query');
const hasDetails = !!query || !!answerBox;
@ -420,13 +272,13 @@ export default function WebSearch({
type="button"
className={cn(
'tool-status-text group/disclosure flex h-5 min-w-0 items-center gap-2 rounded-full',
hasExpandable
hasSourceData
? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none text-text-secondary',
)}
disabled={!hasExpandable}
onClick={hasExpandable ? handleToggleSources : undefined}
aria-expanded={hasExpandable ? showSourceList : undefined}
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 })}`
@ -439,7 +291,7 @@ export default function WebSearch({
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<span className="min-w-0 truncate font-medium">{completedText}</span>
{hasExpandable && (
{hasSourceData && (
<ChevronDown
className={cn(
disclosureChevronClassName,
@ -505,80 +357,62 @@ export default function WebSearch({
</HoverCard>
)}
</div>
{hasExpandable && (
{hasSourceData && (
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
<div className="overflow-hidden" ref={sourceExpandRef}>
{shouldRenderBody && (
<div className={cn(toolPanelSpacingClassName, 'mt-1.5 space-y-2')}>
{verticals.images.length > 0 && (
<ImageStrip
images={verticals.images}
label={localize('com_ui_web_search_images')}
/>
<div
className={cn(
toolPanelSpacingClassName,
'mt-1.5 max-h-[280px] overflow-y-auto rounded-lg border border-border-light',
)}
{verticals.shopping.length > 0 && (
<ShoppingStrip
items={verticals.shopping}
label={localize('com_ui_web_search_shopping')}
/>
)}
{verticals.places.length > 0 && (
<PlaceList
places={verticals.places}
label={localize('com_ui_web_search_places')}
/>
)}
{hasSourceData && (
<div className="max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
const snippet = 'snippet' in source ? source.snippet : undefined;
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
snippet ? 'items-start' : 'items-center',
i > 0 && 'border-t border-border-light',
)}
>
<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>
)}
>
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
const snippet = 'snippet' in source ? source.snippet : undefined;
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
snippet ? 'items-start' : 'items-center',
i > 0 && 'border-t border-border-light',
)}
>
<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 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-right">
<span className="block text-[11px] text-text-secondary">{domain}</span>
{source.date && (
<span className="block text-[10px] text-text-secondary">
{source.date}
</span>
</a>
);
})}
</div>
)}
)}
</span>
</a>
);
})}
</div>
)}
</div>
</div>
)}
{!hideAttachments && <SearchVerticals attachments={attachments} />}
</div>
);
}

View file

@ -0,0 +1,187 @@
import { Star, MapPin } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import type {
TAttachment,
ImageResult,
PlaceResult,
ShoppingResult,
} from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const MAX_VERTICAL_ITEMS = 8;
const MAX_PLACES = 5;
export interface SearchVerticalData {
images: ImageResult[];
shopping: ShoppingResult[];
places: PlaceResult[];
}
/** Serper vertical results (image/shopping/place) merged across a call's
* `web_search` attachments. Attachment-only: verticals never stream. */
export function collectSearchVerticals(attachments?: TAttachment[]): SearchVerticalData {
const images: ImageResult[] = [];
const shopping: ShoppingResult[] = [];
const places: PlaceResult[] = [];
for (const att of attachments ?? []) {
const data = att.type === Tools.web_search ? att[Tools.web_search] : undefined;
if (!data) {
continue;
}
images.push(...(data.images ?? []));
shopping.push(...(data.shopping ?? []));
places.push(...(data.places ?? []));
}
return { images, shopping, places };
}
function RatingBadge({ rating, ratingCount }: { rating?: number; ratingCount?: number }) {
if (rating == null) {
return null;
}
return (
<span className="flex items-center gap-1 text-[11px] text-text-secondary">
<Star className="size-3 fill-current" aria-hidden="true" />
{rating}
{ratingCount != null && <span className="text-text-secondary">({ratingCount})</span>}
</span>
);
}
function ImageStrip({ images, label }: { images: ImageResult[]; label: string }) {
return (
<ul className="flex list-none gap-2 overflow-x-auto pb-1" aria-label={label}>
{images.slice(0, MAX_VERTICAL_ITEMS).map((image, i) => {
const href = image.link || image.googleUrl || image.imageUrl;
const ratio =
image.thumbnailWidth && image.thumbnailHeight
? `${image.thumbnailWidth} / ${image.thumbnailHeight}`
: '1 / 1';
return (
<li key={image.thumbnailUrl || image.imageUrl || i} className="shrink-0">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={image.title || image.domain || label}
className="block h-28 overflow-hidden rounded-xl border border-border-light no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
style={{ aspectRatio: ratio }}
>
<img
src={image.thumbnailUrl || image.imageUrl}
alt={image.title || ''}
className="h-full w-full object-cover"
loading="lazy"
/>
</a>
</li>
);
})}
</ul>
);
}
function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: string }) {
return (
<ul className="flex list-none gap-2.5 overflow-x-auto pb-1" aria-label={label}>
{items.slice(0, MAX_VERTICAL_ITEMS).map((item, i) => (
<li key={item.productId || item.link || i} className="flex w-40 shrink-0">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="flex w-full flex-col rounded-xl border border-border-light p-2 no-underline transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
{item.imageUrl && (
<div className="mb-2 aspect-square w-full shrink-0 overflow-hidden rounded-lg bg-surface-tertiary">
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
)}
<span className="mt-auto block truncate text-xs font-medium text-text-primary">
{item.title}
</span>
{(item.price || item.source) && (
<span className="mt-0.5 block truncate text-[11px] text-text-secondary">
{[item.price, item.source].filter(Boolean).join(' · ')}
</span>
)}
{item.delivery && (
<span className="block truncate text-[11px] text-text-secondary">
{item.delivery}
</span>
)}
<span className="mt-0.5 block">
<RatingBadge rating={item.rating} ratingCount={item.ratingCount} />
</span>
</a>
</li>
))}
</ul>
);
}
function PlaceList({ places, label }: { places: PlaceResult[]; label: string }) {
return (
<ul
className="list-none overflow-hidden rounded-lg border border-border-light"
aria-label={label}
>
{places.slice(0, MAX_PLACES).map((place, i) => (
<li
key={place.identifier || place.name || i}
className={cn(
'flex items-center gap-2.5 px-3 py-2',
i > 0 && 'border-t border-border-light',
)}
>
<MapPin className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-text-primary">
{place.name}
</span>
{(place.category || place.address) && (
<span className="block truncate text-[11px] text-text-secondary">
{[place.category, place.address].filter(Boolean).join(' · ')}
</span>
)}
</span>
<span className="shrink-0">
<RatingBadge rating={place.rating} ratingCount={place.ratingCount} />
</span>
</li>
))}
</ul>
);
}
/** Always-visible search verticals. Rendered under the web search row when
* ungrouped, and hoisted below the group (like attachments) when the call
* lives inside a collapsed tool group. */
export default function SearchVerticals({ attachments }: { attachments?: TAttachment[] }) {
const localize = useLocalize();
const { images, shopping, places } = collectSearchVerticals(attachments);
if (images.length === 0 && shopping.length === 0 && places.length === 0) {
return null;
}
return (
<div className="my-1.5 space-y-2">
{images.length > 0 && (
<ImageStrip images={images} label={localize('com_ui_web_search_images')} />
)}
{shopping.length > 0 && (
<ShoppingStrip items={shopping} label={localize('com_ui_web_search_shopping')} />
)}
{places.length > 0 && (
<PlaceList places={places} label={localize('com_ui_web_search_places')} />
)}
</div>
);
}