SearchTool
Render grouped search results. Provide input.query/pattern and output.results (or pass results directly with the results prop). Use defaultOpen to keep it expanded.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Rich results
Pending search
Alt source set
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
tools/SearchTool.tsx
import React, { memo, useMemo } from 'react';
import { IconFileText } from '@tabler/icons-react';
import { useToolComplete } from '../hooks/use-tool-complete';
import type { SourceType } from '../icons/source-icons';
import { ToolRowBase } from '../ToolRowBase/ToolRowBase';
import type { ToolPart } from '../types';
import type { StepState, ToolCallStep } from '../types/timeline';
import { getPartOutput } from '../utils/format-tool';
import { useChatLabels } from '../labels/chat-labels';
import { resolveToolTitleLabels, toolRegistry } from './tool-registry';
import { noopComplete, useToolStep } from './use-tool-step';
import classes from './SearchTool.module.css';
export type SearchResult = { source: SourceType; title: string; date?: string };
export interface SearchToolLabels {
/** Shimmer label while the search runs, `Searching...` by default */
searching: string;
/** Row label once the search has finished, `Found 3 results` by default */
found: (count: number) => string;
/** Caption before the query in the results panel, `Searched for` by default */
searchedFor: string;
/** Query shown when no step carries one, `searching...` by default */
unknownQuery: string;
}
export const DEFAULT_SEARCH_TOOL_LABELS: SearchToolLabels = {
searching: 'Searching...',
found: (count) => `Found ${count} results`,
searchedFor: 'Searched for',
unknownQuery: 'searching...',
};
export interface SearchGroupRichProps {
/** Search steps rendered as one row, the first `searchQuery` is shown in the header */
toolSteps: ToolCallStep[];
/** Animation state of each step keyed by step id */
stepStates: Record<string, StepState>;
/** Called with the step id once its duration elapses */
onStepComplete: (id: string) => void;
/** Results shown in the expandable panel */
results?: SearchResult[];
/** Initial expanded state of the results panel */
defaultOpen?: boolean;
/** Row label once the search has finished, `labels.found` by default */
completeLabel?: string;
/** Overrides of the default English labels */
labels?: Partial<SearchToolLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
function CompleteTracker({
step,
isAnimating,
onComplete,
}: {
step: ToolCallStep;
isAnimating: boolean;
onComplete: (id: string) => void;
}) {
useToolComplete(isAnimating, step.duration, () => onComplete(step.id));
return null;
}
/** "Searching..." row that expands into a list of search results */
export function SearchGroupRich({
toolSteps,
stepStates,
onStepComplete,
results = [],
defaultOpen,
completeLabel,
labels: labelsProp,
className,
style,
}: SearchGroupRichProps) {
const contextLabels = useChatLabels('searchTool');
const labels = { ...DEFAULT_SEARCH_TOOL_LABELS, ...contextLabels, ...labelsProp };
const anyAnimating = toolSteps.some((s) => stepStates[s.id] === 'animating');
const searchQuery = toolSteps.find((s) => s.searchQuery)?.searchQuery ?? labels.unknownQuery;
const totalResults = results.length;
const hasExpandableContent = totalResults > 0;
return (
<>
{toolSteps.map((step) => (
<CompleteTracker
key={step.id}
step={step}
isAnimating={stepStates[step.id] === 'animating'}
onComplete={onStepComplete}
/>
))}
<ToolRowBase
shimmerLabel={labels.searching}
completeLabel={completeLabel ?? labels.found(totalResults)}
isAnimating={anyAnimating}
expandable={hasExpandableContent}
defaultOpen={defaultOpen}
className={className}
style={style}
>
<div className={classes.panel}>
<div className={classes.header}>
<span className={classes.headerLabel}>{labels.searchedFor}</span>{' '}
<span className={classes.query}>“{searchQuery}”</span>
</div>
<div className={classes.list}>
<div className={classes.items}>
{results.map((result, i) => (
<div key={i} className={classes.item}>
<span className={classes.itemIcon}>
<IconFileText size={16} />
</span>
<span className={classes.title}>{result.title}</span>
<span className={classes.meta}>{result.date || result.source}</span>
</div>
))}
</div>
</div>
</div>
</ToolRowBase>
</>
);
}
export interface SearchToolProps {
/** Tool part in AI SDK v5 shape: `{ type, toolCallId, state, input, output }` */
part: ToolPart;
/** Results override, `part.output.results` is used when omitted */
results?: SearchResult[];
/** Initial expanded state of the results panel */
defaultOpen?: boolean;
/** Overrides of the default English labels */
labels?: Partial<SearchToolLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
function normalizeResults(value: unknown): SearchResult[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const parsed = value
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const { source, title, date } = item as { source?: unknown; title?: unknown; date?: unknown };
if (typeof source !== 'string' || typeof title !== 'string') {
return null;
}
const result: SearchResult = { source: source as SourceType, title };
if (typeof date === 'string') {
result.date = date;
}
return result;
})
.filter((item): item is SearchResult => item !== null);
return parsed.length > 0 ? parsed : undefined;
}
/** Renders `tool-WebSearch` / `tool-Grep` / `tool-Glob` parts */
export const SearchTool = memo(function SearchTool({
part,
results,
defaultOpen,
labels,
className,
style,
}: SearchToolProps) {
const output = getPartOutput(part);
const { step, stepState } = useToolStep(
part,
part.type?.replace('tool-', '') || 'WebSearch',
'search'
);
const toolSteps = useMemo(() => [step], [step]);
const stepStates = useMemo(() => ({ [step.id]: stepState }), [step.id, stepState]);
const registryMeta =
part.type === 'tool-Grep' || part.type === 'tool-Glob' ? toolRegistry[part.type] : undefined;
const titleLabels = resolveToolTitleLabels(useChatLabels('toolTitles'));
return (
<SearchGroupRich
toolSteps={toolSteps}
stepStates={stepStates}
onStepComplete={noopComplete}
results={results ?? normalizeResults(output?.results)}
completeLabel={registryMeta?.title(part, titleLabels)}
labels={labels}
defaultOpen={defaultOpen}
className={className}
style={style}
/>
);
});
SearchTool.displayName = 'SearchTool';
Depends on:
API reference
Prop
Type
Required
part
ToolPart
Yes
Tool part in AI SDK v5 shape: `{ type, toolCallId, state, input, output }`
results
SearchResult[]
No
Results override, `part.output.results` is used when omitted
defaultOpen
boolean
No
Initial expanded state of the results panel
labels
Partial<SearchToolLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element