From ea9bd91a81be55b9f8c80d3607833da33b529ea5 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Fri, 31 Jul 2026 04:40:28 +0200
Subject: [PATCH] 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.
---
.../components/Chat/Messages/Content/Part.tsx | 1 +
.../Chat/Messages/Content/ToolCallGroup.tsx | 6 +-
.../Chat/Messages/Content/WebSearch.tsx | 274 ++++--------------
.../Chat/Messages/Content/verticals.tsx | 187 ++++++++++++
4 files changed, 247 insertions(+), 221 deletions(-)
create mode 100644 client/src/components/Chat/Messages/Content/verticals.tsx
diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx
index 56e2b22280..342d5bfe2a 100644
--- a/client/src/components/Chat/Messages/Content/Part.tsx
+++ b/client/src/components/Chat/Messages/Content/Part.tsx
@@ -372,6 +372,7 @@ const Part = memo(function Part({
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
attachments={attachments}
+ hideAttachments={hideAttachments}
isLast={isLast}
onExpand={onToolExpand}
/>
diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
index d4c661677f..6e982b66c6 100644
--- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
@@ -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({
)}
{groupAttachments && groupAttachments.length > 0 && (
-
+ <>
+
+
+ >
)}
);
diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx
index cf8f0883c0..6f9be452f7 100644
--- a/client/src/components/Chat/Messages/Content/WebSearch.tsx
+++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx
@@ -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 (
-
-
- {rating}
- {ratingCount != null && ({ratingCount})}
-
- );
-}
-
-function ImageStrip({ images, label }: { images: ImageResult[]; label: string }) {
- return (
-
- {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 (
- -
-
-
-
-
- );
- })}
-
- );
-}
-
-function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: string }) {
- return (
-
- );
-}
-
-function PlaceList({ places, label }: { places: PlaceResult[]; label: string }) {
- return (
-
- {places.slice(0, MAX_PLACES).map((place, i) => (
- - 0 && 'border-t border-border-light',
- )}
- >
-
-
-
- {place.name}
-
- {(place.category || place.address) && (
-
- {[place.category, place.address].filter(Boolean).join(' · ')}
-
- )}
-
-
-
-
-
- ))}
-
- );
-}
-
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({
)}
{completedText}
- {hasExpandable && (
+ {hasSourceData && (
)}
- {hasExpandable && (
+ {hasSourceData && (
{shouldRenderBody && (
-
- {verticals.images.length > 0 && (
-
+
0 && (
-
- )}
- {verticals.places.length > 0 && (
-
- )}
- {hasSourceData && (
-
- )}
+ )}
+
+
+ );
+ })}
)}
)}
+ {!hideAttachments &&
}
);
}
diff --git a/client/src/components/Chat/Messages/Content/verticals.tsx b/client/src/components/Chat/Messages/Content/verticals.tsx
new file mode 100644
index 0000000000..e3b841179e
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/verticals.tsx
@@ -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 (
+
+
+ {rating}
+ {ratingCount != null && ({ratingCount})}
+
+ );
+}
+
+function ImageStrip({ images, label }: { images: ImageResult[]; label: string }) {
+ return (
+
+ {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 (
+ -
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: string }) {
+ return (
+
+ );
+}
+
+function PlaceList({ places, label }: { places: PlaceResult[]; label: string }) {
+ return (
+
+ {places.slice(0, MAX_PLACES).map((place, i) => (
+ - 0 && 'border-t border-border-light',
+ )}
+ >
+
+
+
+ {place.name}
+
+ {(place.category || place.address) && (
+
+ {[place.category, place.address].filter(Boolean).join(' · ')}
+
+ )}
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+ {images.length > 0 && (
+
+ )}
+ {shopping.length > 0 && (
+
+ )}
+ {places.length > 0 && (
+
+ )}
+
+ );
+}