mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
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:
parent
309d2ab3e8
commit
ea9bd91a81
4 changed files with 247 additions and 221 deletions
|
|
@ -372,6 +372,7 @@ const Part = memo(function Part({
|
|||
isSubmitting={isSubmitting}
|
||||
runStepStatus={toolCall.runStepStatus}
|
||||
attachments={attachments}
|
||||
hideAttachments={hideAttachments}
|
||||
isLast={isLast}
|
||||
onExpand={onToolExpand}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
187
client/src/components/Chat/Messages/Content/verticals.tsx
Normal file
187
client/src/components/Chat/Messages/Content/verticals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue