1
0
Fork 0
docsearch/packages/docsearch-react/src/DocSearchModal.tsx
2025-11-26 10:35:36 -05:00

946 lines
31 KiB
TypeScript

import { useChat } from '@ai-sdk/react';
import {
type AutocompleteSource,
type AlgoliaInsightsHit,
createAutocomplete,
type AutocompleteState,
} from '@algolia/autocomplete-core';
import { useTheme } from '@docsearch/core/useTheme';
import type { ChatRequestOptions } from 'ai';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import type { SearchResponse } from 'algoliasearch/lite';
import React, { type JSX } from 'react';
import { getValidToken, postFeedback } from './askai';
import { ASK_AI_API_URL, MAX_QUERY_SIZE, USE_ASK_AI_TOKEN } from './constants';
import type { DocSearchIndex, DocSearchProps } from './DocSearch';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import type { NewConversationTranslations } from './NewConversationScreen';
import type { ScreenStateTranslations } from './ScreenState';
import { ScreenState } from './ScreenState';
import type { SearchBoxTranslations } from './SearchBox';
import { SearchBox } from './SearchBox';
import { createStoredConversations, createStoredSearches } from './stored-searches';
import type {
DocSearchHit,
DocSearchState,
InternalDocSearchHit,
StoredAskAiState,
StoredDocSearchHit,
SuggestedQuestionHit,
} from './types';
import type { AIMessage, AskAiState } from './types/AskiAi';
import { useSearchClient } from './useSearchClient';
import { useSuggestedQuestions } from './useSuggestedQuestions';
import { useTouchEvents } from './useTouchEvents';
import { useTrapFocus } from './useTrapFocus';
import { groupBy, identity, noop, removeHighlightTags, isModifierEvent, scrollTo as scrollToUtils } from './utils';
import { buildDummyAskAiHit } from './utils/ai';
import { manageLocalStorageQuota } from './utils/storage';
export type ModalTranslations = Partial<{
searchBox: SearchBoxTranslations;
newConversation: NewConversationTranslations;
footer: FooterTranslations;
}> &
ScreenStateTranslations;
export type DocSearchModalProps = DocSearchProps & {
initialScrollY: number;
onAskAiToggle: (toggle: boolean) => void;
onClose?: () => void;
isAskAiActive?: boolean;
translations?: ModalTranslations;
};
/**
* Helper function to build sources when there is no query
* useful for recent searches and favorite searches.
*/
type BuildNoQuerySourcesOptions = {
recentSearches: ReturnType<typeof createStoredSearches>;
favoriteSearches: ReturnType<typeof createStoredSearches>;
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
disableUserPersonalization: boolean;
canHandleAskAi: boolean;
};
const buildNoQuerySources = ({
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
}: BuildNoQuerySourcesOptions): Array<AutocompleteSource<InternalDocSearchHit>> => {
if (disableUserPersonalization) {
return [];
}
const sources: Array<AutocompleteSource<InternalDocSearchHit>> = [
{
sourceId: 'recentSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return recentSearches.getAll() as InternalDocSearchHit[];
},
},
{
sourceId: 'favoriteSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return favoriteSearches.getAll() as InternalDocSearchHit[];
},
},
];
return sources;
};
type BuildQuerySourcesState = Pick<AutocompleteState<InternalDocSearchHit>, 'context'>;
/**
* Helper function to build sources when there is a query
* note: we only need specific parts of the state, not the full DocSearchState.
*/
const buildQuerySources = async ({
query,
state: sourcesState,
setContext,
setStatus,
searchClient,
indexes: indices,
snippetLength,
insights,
appId,
apiKey,
maxResultsPerGroup,
transformItems = identity,
saveRecentSearch,
onClose,
}: {
query: string;
state: BuildQuerySourcesState;
setContext: (context: Partial<DocSearchState<InternalDocSearchHit>['context']>) => void;
setStatus: (status: DocSearchState<InternalDocSearchHit>['status']) => void;
searchClient: ReturnType<typeof useSearchClient>;
indexes: DocSearchIndex[];
snippetLength: React.MutableRefObject<number>;
insights: boolean;
appId?: string;
apiKey?: string;
maxResultsPerGroup?: number;
transformItems?: DocSearchProps['transformItems'];
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> => {
const insightsActive = insights;
try {
const { results } = await searchClient.search<DocSearchHit>({
requests: indices.map((index) => {
const indexName = typeof index === 'string' ? index : index.name;
const searchParams = typeof index === 'string' ? {} : index.searchParameters;
return {
query,
indexName,
attributesToRetrieve: searchParams?.attributesToRetrieve ?? [
'hierarchy.lvl0',
'hierarchy.lvl1',
'hierarchy.lvl2',
'hierarchy.lvl3',
'hierarchy.lvl4',
'hierarchy.lvl5',
'hierarchy.lvl6',
'content',
'type',
'url',
],
attributesToSnippet: searchParams?.attributesToSnippet ?? [
`hierarchy.lvl1:${snippetLength.current}`,
`hierarchy.lvl2:${snippetLength.current}`,
`hierarchy.lvl3:${snippetLength.current}`,
`hierarchy.lvl4:${snippetLength.current}`,
`hierarchy.lvl5:${snippetLength.current}`,
`hierarchy.lvl6:${snippetLength.current}`,
`content:${snippetLength.current}`,
],
snippetEllipsisText: searchParams?.snippetEllipsisText ?? '…',
highlightPreTag: searchParams?.highlightPreTag ?? '<mark>',
highlightPostTag: searchParams?.highlightPostTag ?? '</mark>',
hitsPerPage: searchParams?.hitsPerPage ?? 20,
clickAnalytics: searchParams?.clickAnalytics ?? insightsActive,
...(searchParams ?? {}),
};
}),
});
return results.flatMap((res) => {
const result = res as SearchResponse<DocSearchHit>;
const { hits, nbHits } = result;
const transformedHits = transformItems(hits);
const sources = groupBy<DocSearchHit>(transformedHits, (hit) => removeHighlightTags(hit), maxResultsPerGroup);
// We store the `lvl0`s to display them as search suggestions
// in the "no results" screen.
if ((sourcesState.context.searchSuggestions as any[]).length < Object.keys(sources).length) {
setContext({
searchSuggestions: {
...(sourcesState.context.searchSuggestions ?? []),
...Object.keys(sources),
},
});
}
if (nbHits) {
const currentNbHits = sourcesState.context.nbHits as number | undefined;
setContext({
nbHits: (currentNbHits ?? 0) + nbHits,
});
}
let insightsParams = {};
if (insightsActive) {
insightsParams = {
__autocomplete_indexName: result.index,
__autocomplete_queryID: result.queryID,
__autocomplete_algoliaCredentials: {
appId,
apiKey,
},
};
}
return Object.values<DocSearchHit[]>(sources).map((items, index) => {
return {
sourceId: `hits_${result.index}_${index}`,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
.map((groupedHits) =>
groupedHits.map((item) => {
let parent: InternalDocSearchHit | null = null;
const potentialParent = groupedHits.find(
(siblingItem) => siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1,
) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
}),
)
.flat();
},
};
});
});
} catch (error) {
// The Algolia `RetryError` happens when all the servers have
// failed, meaning that there's no chance the response comes
// back. This is the right time to display an error.
// See https://github.com/algolia/algoliasearch-client-javascript/blob/2ffddf59bc765cd1b664ee0346b28f00229d6e12/packages/transporter/src/errors/createRetryError.ts#L5
if ((error as Error).name === 'RetryError') {
setStatus('error');
}
throw error;
}
};
export function DocSearchModal({
appId,
apiKey,
askAi,
maxResultsPerGroup,
theme,
onClose = noop,
transformItems = identity,
hitComponent = Hit,
resultsFooterComponent = (): JSX.Element | null => null,
navigator,
initialScrollY = 0,
transformSearchClient = identity,
disableUserPersonalization = false,
initialQuery: initialQueryFromProp = '',
translations = {},
getMissingResultsUrl,
insights = false,
onAskAiToggle,
isAskAiActive = false,
recentSearchesLimit = 7,
recentSearchesWithFavoritesLimit = 4,
indices = [],
indexName,
searchParameters,
...props
}: DocSearchModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
query: '',
collections: [],
completion: null,
context: {},
isOpen: false,
activeItemId: null,
status: 'idle',
});
// check if the instance is configured to handle ask ai
const canHandleAskAi = Boolean(askAi);
let placeholder = translations?.searchBox?.placeholderText || props.placeholder || 'Search docs';
if (canHandleAskAi) {
placeholder = translations?.searchBox?.placeholderText || 'Search docs or ask AI a question';
}
if (isAskAiActive) {
placeholder = translations?.searchBox?.placeholderTextAskAi || 'Ask another question...';
}
const containerRef = React.useRef<HTMLDivElement | null>(null);
const modalRef = React.useRef<HTMLDivElement | null>(null);
const formElementRef = React.useRef<HTMLDivElement | null>(null);
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const snippetLength = React.useRef<number>(15);
const initialQueryFromSelection = React.useRef(
typeof window !== 'undefined' ? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE) : '',
).current;
const initialQuery = React.useRef(initialQueryFromProp || initialQueryFromSelection).current;
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
const askAiConfig = typeof askAi === 'object' ? askAi : null;
const askAiConfigurationId = typeof askAi === 'string' ? askAi : askAiConfig?.assistantId || null;
const askAiSearchParameters = askAiConfig?.searchParameters;
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const suggestedQuestions = useSuggestedQuestions({
assistantId: askAiConfigurationId,
searchClient,
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
});
// Format the `indexes` to be used until `indexName` and `searchParameters` props are fully removed.
const indexes: DocSearchIndex[] = [];
if (indexName && indexName !== '') {
indexes.push({
name: indexName,
searchParameters,
});
}
if (indices.length > 0) {
indices.forEach((index) => {
indexes.push(typeof index === 'string' ? { name: index } : index);
});
}
if (indexes.length < 1) {
throw new Error('Must supply either `indexName` or `indices` for DocSearch to work');
}
const defaultIndexName = indexes[0].name;
// storage
const conversations = React.useRef(
createStoredConversations<StoredAskAiState>({
key: `__DOCSEARCH_ASKAI_CONVERSATIONS__${askAiConfig?.indexName || defaultIndexName}`,
limit: 10,
}),
).current;
const favoriteSearches = React.useRef(
createStoredSearches<StoredDocSearchHit>({
key: `__DOCSEARCH_FAVORITE_SEARCHES__${defaultIndexName}`,
limit: 10,
}),
).current;
const recentSearches = React.useRef(
createStoredSearches<StoredDocSearchHit>({
key: `__DOCSEARCH_RECENT_SEARCHES__${defaultIndexName}`,
limit: favoriteSearches.getAll().length === 0 ? recentSearchesLimit : recentSearchesWithFavoritesLimit,
}),
).current;
const [stoppedStream, setStoppedStream] = React.useState(false);
const {
messages,
sendMessage,
status,
setMessages,
error: askAiError,
stop: stopAskAiStreaming,
} = useChat<AIMessage>({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
transport: new DefaultChatTransport({
api: ASK_AI_API_URL,
headers: async (): Promise<Record<string, string>> => {
if (!askAiConfigurationId) {
throw new Error('Ask AI assistant ID is required');
}
let token: string | null = null;
if (USE_ASK_AI_TOKEN) {
token = await getValidToken({
assistantId: askAiConfigurationId,
});
}
return {
...(token ? { authorization: `TOKEN ${token}` } : {}),
'X-Algolia-API-Key': askAiConfig?.apiKey || apiKey,
'X-Algolia-Application-Id': askAiConfig?.appId || appId,
'X-Algolia-Index-Name': askAiConfig?.indexName || defaultIndexName,
'X-Algolia-Assistant-Id': askAiConfigurationId || '',
'X-AI-SDK-Version': 'v5',
};
},
body: askAiSearchParameters ? { searchParameters: askAiSearchParameters } : {},
}),
});
const prevStatus = React.useRef(status);
React.useEffect(() => {
if (disableUserPersonalization) {
return;
}
// if we just transitioned from "streaming" → "ready", persist
if (prevStatus.current === 'streaming' && status === 'ready') {
// if we stopped the stream, store it on the most recent message
if (stoppedStream && messages.at(-1)) {
messages.at(-1)!.metadata = {
stopped: true,
};
}
for (const part of messages[0].parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
}
}
}
prevStatus.current = status;
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
const createSyntheticParent = React.useCallback(function createSyntheticParent(
item: InternalDocSearchHit,
): InternalDocSearchHit {
// Find the deepest non-null hierarchy level
const hierarchy = item.hierarchy;
const levels = ['lvl6', 'lvl5', 'lvl4', 'lvl3', 'lvl2', 'lvl1', 'lvl0'] as const;
const deepestLevel = levels.find((level) => hierarchy[level]);
return {
...item,
type: deepestLevel || 'lvl0', // Use the deepest available level as type
content: null, // Clear content since this represents a section, not specific content
};
}, []);
const saveRecentSearch = React.useCallback(
function saveRecentSearch(item: InternalDocSearchHit) {
if (disableUserPersonalization) {
return;
}
// We don't store `content` record, but their parent if available.
// If no parent exists, create a synthetic parent from the hierarchy.
const search = item.type === 'content' ? item.__docsearch_parent || createSyntheticParent(item) : item;
// We save the recent search only if it's not favorited.
if (search && favoriteSearches.getAll().findIndex((x) => x.objectID === search.objectID) === -1) {
recentSearches.add(search);
}
},
[favoriteSearches, recentSearches, disableUserPersonalization, createSyntheticParent],
);
const sendItemClickEvent = React.useCallback(
(item: InternalDocSearchHit) => {
if (!state.context.algoliaInsightsPlugin || !item.__autocomplete_id) return;
const insightsItem = item as AlgoliaInsightsHit;
const insightsClickParams = {
eventName: 'Item Selected',
index: insightsItem.__autocomplete_indexName,
items: [insightsItem],
positions: [item.__autocomplete_id],
queryID: insightsItem.__autocomplete_queryID,
};
state.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(insightsClickParams);
},
[state.context.algoliaInsightsPlugin],
);
const autocompleteRef =
React.useRef<
ReturnType<
typeof createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>
>
>(undefined);
const handleSelectAskAiQuestion = React.useCallback(
(toggle: boolean, query: string, suggestedQuestion: SuggestedQuestionHit | undefined = undefined) => {
if (toggle && askAiState === 'new-conversation') {
// We're starting a new conversation, clear out current messages
setMessages([]);
setAskAiState('initial');
}
const messageOptions: ChatRequestOptions = {};
if (suggestedQuestion) {
messageOptions.body = {
suggestedQuestionId: suggestedQuestion.objectID,
};
}
onAskAiToggle(toggle);
setStoppedStream(false);
sendMessage(
{
role: 'user',
parts: [
{
type: 'text',
text: query,
},
],
},
messageOptions,
);
if (dropdownRef.current) {
// some test environments (like jsdom) don't implement element.scrollTo
const el = dropdownRef.current;
if (typeof (el as any).scrollTo === 'function') {
el.scrollTo({ top: 0, behavior: 'smooth' });
} else {
// fallback for environments without scrollTo support
el.scrollTop = 0;
}
}
// clear the query
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
},
[onAskAiToggle, sendMessage, askAiState, setAskAiState, setMessages],
);
// feedback handler
const handleFeedbackSubmit = React.useCallback(
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
if (!askAiConfigurationId || !appId) return;
const res = await postFeedback({
assistantId: askAiConfigurationId,
thumbs,
messageId,
appId,
});
if (res.status >= 300) throw new Error('Failed, try again later');
conversations.addFeedback?.(messageId, thumbs === 1 ? 'like' : 'dislike');
},
[askAiConfigurationId, appId, conversations],
);
if (!autocompleteRef.current) {
autocompleteRef.current = createAutocomplete({
id: 'docsearch',
defaultActiveItemId: 0,
openOnFocus: true,
initialState: {
query: initialQuery,
context: {
searchSuggestions: [],
},
},
insights: Boolean(insights),
navigator,
onStateChange(changes) {
setState(changes.state);
},
getSources({ query, state: sourcesState, setContext, setStatus }) {
if (!query) {
const noQuerySources = buildNoQuerySources({
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
canHandleAskAi,
});
const recentConversationSource: Array<AutocompleteSource<InternalDocSearchHit & { messages?: AIMessage[] }>> =
canHandleAskAi
? [
{
sourceId: 'recentConversations',
getItems(): InternalDocSearchHit[] {
if (disableUserPersonalization) {
return [];
}
return conversations.getAll() as unknown as InternalDocSearchHit[];
},
onSelect({ item }): void {
if (item.messages) {
setMessages(item.messages as any);
onAskAiToggle(true);
}
},
},
]
: [];
return [...noQuerySources, ...recentConversationSource];
}
const querySourcesState: BuildQuerySourcesState = {
context: sourcesState.context,
};
const algoliaSourcesPromise = buildQuerySources({
query,
state: querySourcesState,
setContext,
setStatus,
searchClient,
indexes,
snippetLength,
insights: Boolean(insights),
appId,
apiKey,
maxResultsPerGroup,
transformItems,
saveRecentSearch,
onClose,
});
// Ask AI source
const askAiSource: Array<AutocompleteSource<InternalDocSearchHit>> = canHandleAskAi
? [
{
sourceId: 'askAI',
getItems(): InternalDocSearchHit[] {
// return a single item representing the Ask AI action
// placeholder data matching the InternalDocSearchHit structure
const askItem: InternalDocSearchHit = {
type: 'askAI',
query,
url_without_anchor: '',
objectID: `ask-ai-button`,
content: null,
url: '',
anchor: null,
hierarchy: {
lvl0: 'Ask AI', // Or contextually relevant
lvl1: query,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
_highlightResult: {} as any,
_snippetResult: {} as any,
__docsearch_parent: null,
};
return [askItem];
},
onSelect({ item }): void {
if (item.type === 'askAI' && item.query) {
handleSelectAskAiQuestion(true, item.query);
}
},
},
]
: [];
// Combine Algolia results (once resolved) with the Ask AI source
return algoliaSourcesPromise.then((algoliaSources) => {
return [...askAiSource, ...algoliaSources];
});
},
});
}
const autocomplete = autocompleteRef.current;
const { getEnvironmentProps, getRootProps, refresh } = autocomplete;
useTouchEvents({
getEnvironmentProps,
panelElement: dropdownRef.current,
formElement: formElementRef.current,
inputElement: inputRef.current,
});
useTrapFocus({ container: containerRef.current });
useTheme({ theme });
React.useEffect(() => {
document.body.classList.add('DocSearch--active');
return (): void => {
document.body.classList.remove('DocSearch--active');
// IE11 doesn't support `scrollTo` so we check that the method exists
// first.
window.scrollTo?.(0, initialScrollY);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Proactively manage localStorage quota to prevent crashes
React.useEffect(() => {
manageLocalStorageQuota();
}, []);
React.useLayoutEffect(() => {
// Calculate the scrollbar width to compensate for removed scrollbar
const scrollBarWidth = window.innerWidth - document.body.clientWidth;
// Prevent layout shift by adding appropriate margin to the body
document.body.style.marginInlineEnd = `${scrollBarWidth}px`;
return (): void => {
document.body.style.marginInlineEnd = '0px';
};
}, []);
React.useEffect(() => {
const isMobileMediaQuery = window.matchMedia('(max-width: 768px)');
if (isMobileMediaQuery.matches) {
snippetLength.current = 5;
}
}, []);
React.useEffect(() => {
if (dropdownRef.current && !isAskAiActive) {
scrollToUtils(dropdownRef.current);
}
}, [state.query, isAskAiActive]);
// We don't focus the input when there's an initial query (i.e. Selection
// Search) because users rather want to see the results directly, without the
// keyboard appearing.
// We therefore need to refresh the autocomplete instance to load all the
// results, which is usually triggered on focus.
React.useEffect(() => {
if (initialQuery.length > 0) {
refresh();
if (inputRef.current) {
inputRef.current.focus();
}
}
}, [initialQuery, refresh]);
// We rely on a CSS property to set the modal height to the full viewport height
// because all mobile browsers don't compute their height the same way.
// See https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
React.useEffect(() => {
function setFullViewportHeight(): void {
if (modalRef.current) {
const vh = window.innerHeight * 0.01;
modalRef.current.style.setProperty('--docsearch-vh', `${vh}px`);
}
}
setFullViewportHeight();
window.addEventListener('resize', setFullViewportHeight);
return (): void => {
window.removeEventListener('resize', setFullViewportHeight);
};
}, []);
// Refresh the autocomplete results when ask ai is toggled off
// helps return to the previous ac state and start screen
React.useEffect(() => {
if (!isAskAiActive) {
autocomplete.refresh();
setMessages([]);
}
}, [isAskAiActive, autocomplete, setMessages]);
// Track external state in order to manage internal askAiState
React.useEffect(() => {
setAskAiState('initial');
}, [isAskAiActive, setAskAiState]);
const onStopAskAiStreaming = async (): Promise<void> => {
setStoppedStream(true);
await stopAskAiStreaming();
};
const handleNewConversation = (): void => {
setAskAiState('new-conversation');
};
const handleViewConversationHistory = (): void => {
setAskAiState('conversation-history');
};
const selectSuggestedQuestion = (suggestedQuestion: SuggestedQuestionHit): void => {
handleSelectAskAiQuestion(true, suggestedQuestion.question, suggestedQuestion);
};
// hide the dropdown on idle and no collections
let showDocsearchDropdown = true;
const hasCollections = state.collections.some((collection) => collection.items.length > 0);
if (state.status === 'idle' && hasCollections === false && state.query.length === 0 && !isAskAiActive) {
showDocsearchDropdown = false;
}
return (
<div
ref={containerRef}
{...getRootProps({ 'aria-expanded': true })}
className={[
'DocSearch',
'DocSearch-Container',
state.status === 'stalled' && 'DocSearch-Container--Stalled',
state.status === 'error' && 'DocSearch-Container--Errored',
]
.filter(Boolean)
.join(' ')}
role="button"
tabIndex={0}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div className="DocSearch-Modal" ref={modalRef}>
<header className="DocSearch-SearchBar" ref={formElementRef}>
<SearchBox
{...autocomplete}
state={state}
placeholder={placeholder || 'Search docs'}
autoFocus={initialQuery.length === 0}
inputRef={inputRef}
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
translations={searchBoxTranslations}
isAskAiActive={isAskAiActive}
askAiStatus={status}
askAiState={askAiState}
setAskAiState={setAskAiState}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {
handleSelectAskAiQuestion(true, query);
}}
onStopAskAiStreaming={onStopAskAiStreaming}
onNewConversation={handleNewConversation}
onViewConversationHistory={handleViewConversationHistory}
/>
</header>
{showDocsearchDropdown && (
<div className="DocSearch-Dropdown" ref={dropdownRef}>
<ScreenState
{...autocomplete}
indexName={defaultIndexName}
state={state}
hitComponent={hitComponent}
resultsFooterComponent={resultsFooterComponent}
disableUserPersonalization={disableUserPersonalization}
recentSearches={recentSearches}
favoriteSearches={favoriteSearches}
conversations={conversations}
inputRef={inputRef}
translations={screenStateTranslations}
getMissingResultsUrl={getMissingResultsUrl}
isAskAiActive={isAskAiActive}
canHandleAskAi={canHandleAskAi}
messages={messages}
askAiError={askAiError}
status={status}
hasCollections={hasCollections}
askAiState={askAiState}
selectAskAiQuestion={handleSelectAskAiQuestion}
suggestedQuestions={suggestedQuestions}
selectSuggestedQuestion={selectSuggestedQuestion}
onAskAiToggle={onAskAiToggle}
onItemClick={(item, event) => {
// if the item is askAI toggle the screen
if (item.type === 'askAI' && item.query) {
// if the item is askAI and the anchor is stored
if (item.anchor === 'stored' && 'messages' in item) {
setMessages(item.messages as any);
onAskAiToggle(true);
} else {
handleSelectAskAiQuestion(true, item.query);
}
setAskAiState('initial');
event.preventDefault();
return;
}
// If insights is active, send insights click event
sendItemClickEvent(item);
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
}}
onFeedback={handleFeedbackSubmit}
/>
</div>
)}
<footer className="DocSearch-Footer">
<Footer translations={footerTranslations} isAskAiActive={isAskAiActive} />
</footer>
</div>
</div>
);
}