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 && (
+
+ )}
+
+ );
+}