1
0
Fork 0

feat(askai): Split Ask AI modal into own component (#2884)

* feat(askai): Split Ask AI modal into own component

* refactor(react): share modal utilities

* refactor(react): share search box form

* refactor(react): extract start screen sections

* refactor(react): extract shared modal hooks

* fix: lint adapter

* refactor(react): reorganize modal files

* fix: type error in examples

* fix: remove ai modal from adapter for now, fix import paths of react package
This commit is contained in:
Paul Jankowski 2026-05-19 13:36:23 -04:00 committed by GitHub
parent c9f49e1fd2
commit 83759e1e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2549 additions and 1172 deletions

View file

@ -140,6 +140,14 @@ function createStorage<TItem>(key: string): StorageInterface<TItem> {
- Use `React.useMemo` for expensive computations
- Prefer destructuring props in function signature
### React Component Structure
- `src/components/ui/` contains reusable rendering components.
- Prefer domain-light primitives in `src/components/ui/` when possible.
- Feature-scoped UI components may live in `src/components/ui/` when their feature scope is explicit in the filename, such as `RecentConversationsResults.tsx`.
- `src/components/` contains scoped composition components that own feature flow, branching, and state orchestration.
- Keep AI-specific behavior out of generic UI primitives. If a UI component is AI-specific, make that scope clear in its name.
```typescript
function DocSearchComponent(
props: DocSearchProps,
@ -226,6 +234,8 @@ describe('ComponentName', () => {
packages/
docsearch-react/
src/
components/ # Scoped React composition components
ui/ # Reusable rendering components and explicitly scoped UI pieces
__tests__/ # Test files
icons/ # Icon components
types/ # Type definitions

View file

@ -6,7 +6,7 @@
*/
import type { AskAiConfig } from '@docsearch/docusaurus-adapter';
import type { DocSearchModalProps, DocSearchTranslations } from '@docsearch/react';
import type { DocSearchAskAiModalProps, DocSearchTranslations } from '@docsearch/react';
import translations from '@theme/SearchTranslations';
import type { FacetFilters } from 'algoliasearch/lite';
import { useCallback, useMemo, useState } from 'react';
@ -21,14 +21,14 @@ interface DocSearchPropsLite {
appId: string;
placeholder?: string;
translations?: DocSearchTranslations;
searchParameters?: DocSearchModalProps['searchParameters'];
searchParameters?: DocSearchAskAiModalProps['searchParameters'];
askAi?: AskAiConfig;
}
type OnAskAiToggle = NonNullable<DocSearchModalProps['onAskAiToggle']>;
type OnAskAiToggle = NonNullable<DocSearchAskAiModalProps['onAskAiToggle']>;
type AskAiConfigWithoutSidePanel = Omit<AskAiConfig, 'sidePanel'>;
type DocSearchAskAi = Exclude<DocSearchModalProps['askAi'], string | undefined>;
type DocSearchModalPropsLite = Partial<Omit<DocSearchModalProps, 'askAi'>>;
type DocSearchAskAi = Exclude<DocSearchAskAiModalProps['askAi'], string | undefined>;
type DocSearchModalPropsLite = Partial<Omit<DocSearchAskAiModalProps, 'askAi'>>;
type UseAskAiResult = {
canHandleAskAi: boolean;
@ -96,7 +96,7 @@ export function useAlgoliaAskAi(props: DocSearchPropsLite): UseAskAiResult {
const canHandleAskAi = Boolean(askAi);
const currentPlaceholder = isAskAiActive
? translations.modal?.searchBox?.placeholderTextAskAi
? (translations.modal?.searchBox as { placeholderTextAskAi?: string } | undefined)?.placeholderTextAskAi
: translations.modal?.searchBox?.placeholderText || props?.placeholder;
const onAskAiToggle = useCallback<OnAskAiToggle>((askAiToggle: boolean) => {

View file

@ -1,3 +1,4 @@
/* eslint-disable import/dynamic-import-chunkname */
/**
* Copyright (c) Facebook, Inc. And its affiliates.
*
@ -9,6 +10,7 @@ import type { AutocompleteState } from '@algolia/autocomplete-core';
import type { ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter';
import type {
InternalDocSearchHit,
DocSearchAskAiModalProps,
DocSearchModal as DocSearchModalType,
DocSearchModalProps,
StoredDocSearchHit,
@ -45,16 +47,20 @@ type DocSearchProps = Omit<DocSearchModalProps, 'initialScrollY' | 'onClose'> &
contextualSearch?: string;
externalUrlRegex?: string;
searchPagePath: boolean | string;
askAi?: Exclude<(DocSearchModalProps & { askAi: unknown })['askAi'], string | undefined>;
askAi?: Exclude<DocSearchAskAiModalProps['askAi'], string | undefined>;
};
type AskAiTogglePayload = {
query: string;
messageId?: string;
suggestedQuestionId?: string;
};
// eslint-disable-next-line no-warning-comments
// TODO: Will be handled later with refactor to use DocSearchAskAiModal
// type AskAiTogglePayload = {
// query: string;
// messageId?: string;
// suggestedQuestionId?: string;
// };
type OnAskAiToggle = (toggle: boolean, payload?: AskAiTogglePayload) => void;
// eslint-disable-next-line no-warning-comments
// TODO: Will be handled later with refactor to use DocSearchAskAiModal
// type OnAskAiToggle = (toggle: boolean, payload?: AskAiTogglePayload) => void;
type NavigatorNavigateParams = Parameters<NonNullable<NonNullable<DocSearchModalProps['navigator']>['navigate']>>[0];
interface AlgoliaSearchBarProps extends Omit<DocSearchProps, 'askAi'> {
@ -71,7 +77,6 @@ function importDocSearchModalIfNeeded(): Promise<void> {
return Promise.resolve();
}
// eslint-disable-next-line import/dynamic-import-chunkname
return Promise.all([import('@docsearch/react/modal'), import('@docsearch/react/style'), import('./styles.css')]).then(
([{ DocSearchModal: Modal }]) => {
DocSearchModal = Modal;
@ -85,7 +90,6 @@ async function importDocSearchSidepanelIfNeeded(): Promise<void> {
return Promise.resolve();
}
// eslint-disable-next-line import/dynamic-import-chunkname
return Promise.all([import('@docsearch/react/sidepanel'), import('@docsearch/react/style/sidepanel')]).then(
([{ Sidepanel }]) => {
DocSearchSidepanel = Sidepanel;
@ -252,17 +256,19 @@ function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) {
onAskAiToggle(false);
}, [onAskAiToggle]);
const handleAskAiToggle = useCallback<OnAskAiToggle>(
(active, payload) => {
if (active && sidePanelEnabled) {
closeModal();
openSidepanel(payload);
return;
}
onAskAiToggle(active);
},
[closeModal, onAskAiToggle, openSidepanel, sidePanelEnabled],
);
// eslint-disable-next-line no-warning-comments
// TODO: Will be handled later with refactor to use DocSearchAskAiModal
// const handleAskAiToggle = useCallback<OnAskAiToggle>(
// (active, payload) => {
// if (active && sidePanelEnabled) {
// closeModal();
// openSidepanel(payload);
// return;
// }
// onAskAiToggle(active);
// },
// [closeModal, onAskAiToggle, openSidepanel, sidePanelEnabled],
// );
// cleanup search container
useEffect(() => {
@ -342,33 +348,33 @@ function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) {
DocSearchModal &&
searchContainer.current &&
createPortal(
<DocSearchModal
initialScrollY={window.scrollY}
initialQuery={initialQuery}
navigator={navigator}
transformItems={transformItems}
hitComponent={Hit}
transformSearchClient={transformSearchClient}
interceptAskAiEvent={(payload) => {
React.createElement(DocSearchModal, {
initialScrollY: window.scrollY,
initialQuery,
navigator,
transformItems,
hitComponent: Hit,
transformSearchClient,
interceptAskAiEvent: (
payload: Parameters<NonNullable<DocSearchAskAiModalProps['interceptAskAiEvent']>>[0],
) => {
if (!sidePanelEnabled) {
return false;
}
closeModal();
openSidepanel(payload);
return true;
}}
onClose={closeModal}
{...(resultsFooterSearchPagePath && {
},
onClose: closeModal,
...(resultsFooterSearchPagePath && {
resultsFooterComponent,
})}
placeholder={currentPlaceholder}
{...(props as DocSearchProps)}
translations={props.translations?.modal ?? translations.modal}
searchParameters={searchParameters}
{...extraAskAiProps}
isHybridModeSupported={sidePanelEnabled}
onAskAiToggle={handleAskAiToggle as DocSearchModalProps['onAskAiToggle']}
/>,
}),
placeholder: currentPlaceholder,
...(props as DocSearchProps),
translations: props.translations?.modal ?? translations.modal,
searchParameters,
...extraAskAiProps,
}),
searchContainer.current,
)}

View file

@ -1,7 +1,6 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/core';
import { DocSearchButton } from '@docsearch/modal/button';
import { DocSearchModal } from '@docsearch/modal/modal';
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import type { JSX } from 'react';
export default function AgentStudio(): JSX.Element {
@ -12,7 +11,7 @@ export default function AgentStudio(): JSX.Element {
buttonText: 'Ask AI with Agent Studio',
}}
/>
<DocSearchModal
<DocSearchAskAiModal
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="a00716d83c64f6c61905c078b7d5ab66"

View file

@ -1,10 +1,10 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/react';
import { DocSearchAI } from '@docsearch/react';
import type { JSX } from 'react';
export default function BasicAskAI(): JSX.Element {
return (
<DocSearch
<DocSearchAI
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"

View file

@ -1,13 +1,13 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/core';
import { DocSearchButton, DocSearchModal } from '@docsearch/modal';
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import { type JSX } from 'react';
export default function Composable(): JSX.Element {
return (
<DocSearch>
<DocSearchButton translations={{ buttonText: 'Composable API' }} />
<DocSearchModal
<DocSearchAskAiModal
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"

View file

@ -1,18 +1,18 @@
/* eslint-disable react/react-in-jsx-scope */
import { useDocSearchKeyboardEvents } from '@docsearch/core/useDocSearchKeyboardEvents';
import type { DocSearchAskAiModal as DocSearchAskAiModalType } from '@docsearch/react/askaiModal';
import { DocSearchButton } from '@docsearch/react/button';
import type { DocSearchModal as DocSearchModalType } from '@docsearch/react/modal';
import { useCallback, useRef, useState, type JSX } from 'react';
import { createPortal } from 'react-dom';
let DocSearchModal: typeof DocSearchModalType | null = null;
let DocSearchModal: typeof DocSearchAskAiModalType | null = null;
function importDocSearchModalIfNeeded(): Promise<void> {
if (DocSearchModal) {
return Promise.resolve();
}
// eslint-disable-next-line import/dynamic-import-chunkname
return Promise.all([import('@docsearch/react/modal')]).then(([{ DocSearchModal: Modal }]) => {
return Promise.all([import('@docsearch/react/askaiModal')]).then(([{ DocSearchAskAiModal: Modal }]) => {
DocSearchModal = Modal;
});
}

View file

@ -1,6 +1,6 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/core';
import { DocSearchButton, DocSearchModal } from '@docsearch/modal';
import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import { Sidepanel, SidepanelButton } from '@docsearch/sidepanel';
import type { JSX } from 'react';
@ -8,7 +8,7 @@ export default function BasicHybrid(): JSX.Element {
return (
<DocSearch>
<DocSearchButton />
<DocSearchModal
<DocSearchAskAiModal
indexName="docsearch"
appId="beta3G7FSQDJR3"
apiKey="0faad3eae2ba413c16355a0f8670c201"

View file

@ -1,5 +1,5 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/react';
import { DocSearchAI } from '@docsearch/react';
import type { JSX } from 'react';
// this type matches the structure of the provided example hit
@ -21,7 +21,7 @@ import type { JSX } from 'react';
export default function WTransformItems(): JSX.Element {
return (
<DocSearch
<DocSearchAI
indexName="crawler_doc"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"

View file

@ -30,6 +30,7 @@
"pw:ui": "start-server-and-test 'bun run website:test' http://localhost:3000 'playwright test --ui'",
"lint:css": "stylelint **/src/**/*.css",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"playground:build": "bun --filter @docsearch/react-example build",
"playground:start": "bun --filter @docsearch/react-example dev -- --host",
"playground-js:start": "bun --filter @docsearch/js-example dev -- --host",

View file

@ -0,0 +1 @@
export * from './dist/esm/DocSearchAskAiModal.js';

View file

@ -18,11 +18,13 @@
"dist/",
"button.js",
"index.js",
"modal.js"
"modal.js",
"askai.js"
],
"exports": {
".": "./dist/esm/index.js",
"./button": "./dist/esm/DocSearchButton.js",
"./askai": "./dist/esm/DocSearchAskAiModal.js",
"./modal": "./dist/esm/DocSearchModal.js"
},
"source": "src/index.ts",

View file

@ -0,0 +1,45 @@
import { useDocSearch } from '@docsearch/core';
import type { DocSearchAskAiModalProps as ReactDocSearchAskAiModalProps } from '@docsearch/react/askaiModal';
import { DocSearchAskAiModal as Modal } from '@docsearch/react/askaiModal';
import type { JSX } from 'react';
import React from 'react';
import { createPortal } from 'react-dom';
export type DocSearchAskAiModalProps = Omit<
ReactDocSearchAskAiModalProps,
| 'initialScrollY'
| 'isAskAiActive'
| 'isHybridModeSupported'
| 'keyboardShortcuts'
| 'onAskAiToggle'
| 'onClose'
| 'theme'
>;
export function DocSearchAskAiModal(props: DocSearchAskAiModalProps): JSX.Element | null {
const { isModalActive, onAskAiToggle, closeModal, isAskAiActive, initialQuery, registerView, isHybridModeSupported } =
useDocSearch();
const containerElement = React.useMemo(() => props.portalContainer ?? document.body, [props.portalContainer]);
const initialScroll = React.useMemo(() => window.scrollY, []);
React.useEffect(() => {
registerView('modal');
}, [registerView]);
const modalProps: ReactDocSearchAskAiModalProps = React.useMemo(
() => ({
...props,
isAskAiActive,
initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll,
onAskAiToggle,
onClose: closeModal,
isHybridModeSupported,
}),
[props, isAskAiActive, initialQuery, initialScroll, onAskAiToggle, closeModal, isHybridModeSupported],
);
return isModalActive ? createPortal(<Modal {...modalProps} />, containerElement) : null;
}

View file

@ -7,18 +7,11 @@ import { createPortal } from 'react-dom';
export type DocSearchModalProps = Omit<
ReactDocSearchModalProps,
| 'initialScrollY'
| 'isAskAiActive'
| 'isHybridModeSupported'
| 'keyboardShortcuts'
| 'onAskAiToggle'
| 'onClose'
| 'theme'
'initialScrollY' | 'keyboardShortcuts' | 'onClose' | 'theme'
>;
export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const { isModalActive, onAskAiToggle, closeModal, isAskAiActive, initialQuery, registerView, isHybridModeSupported } =
useDocSearch();
const { isModalActive, closeModal, initialQuery, registerView } = useDocSearch();
const containerElement = React.useMemo(() => props.portalContainer ?? document.body, [props.portalContainer]);
@ -31,14 +24,11 @@ export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const modalProps: ReactDocSearchModalProps = React.useMemo(
() => ({
...props,
isAskAiActive,
initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll,
onAskAiToggle,
onClose: closeModal,
isHybridModeSupported,
}),
[props, isAskAiActive, initialQuery, initialScroll, onAskAiToggle, closeModal, isHybridModeSupported],
[props, initialQuery, initialScroll, closeModal],
);
return isModalActive ? createPortal(<Modal {...modalProps} />, containerElement) : null;

View file

@ -1,2 +1,3 @@
export * from './DocSearchButton';
export * from './DocSearchAskAiModal';
export * from './DocSearchModal';

View file

@ -27,6 +27,7 @@ export default defineConfig([
entry: {
index: 'src/index.ts',
DocSearchButton: 'src/DocSearchButton.tsx',
DocSearchAskAiModal: 'src/DocSearchAskAiModal.tsx',
DocSearchModal: 'src/DocSearchModal.tsx',
},
outDir: 'dist/esm',

View file

@ -0,0 +1 @@
export { DocSearchAskAiModal } from './dist/esm/DocSearchAskAiModal.js';

View file

@ -0,0 +1 @@
export { DocSearchAI } from './dist/esm/DocSearchAI.js';

View file

@ -16,6 +16,8 @@
"sideEffects": false,
"files": [
"dist/",
"askaiModal.js",
"docsearchAi.js",
"button.js",
"modal.js",
"useTheme.js",
@ -26,6 +28,8 @@
],
"exports": {
".": "./dist/esm/index.js",
"./askaiModal": "./dist/esm/DocSearchAskAiModal.js",
"./docsearchAi": "./dist/esm/DocSearchAI.js",
"./button": "./dist/esm/DocSearchButton.js",
"./modal": "./dist/esm/DocSearchModal.js",
"./style": "./style/index.js",

View file

@ -2,9 +2,9 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import React, { type JSX, useMemo, useState, useEffect } from 'react';
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
import type { AskAiScreenStateProps } from './AskAiScreenState';
import { AlertIcon, LoadingIcon } from './icons';
import { MemoizedMarkdown } from './MemoizedMarkdown';
import type { ScreenStateProps } from './ScreenState';
import type { StoredSearchPlugin } from './stored-searches';
import { ToolCall } from './ToolCall';
import type { InternalDocSearchHit, StoredAskAiState } from './types';
@ -63,7 +63,7 @@ export type AskAiScreenTranslations = Partial<{
startNewConversationButtonText: string;
}>;
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
type AskAiScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
messages: AIMessage[];
status: UseChatHelpers<AIMessage>['status'];
askAiError?: Error;

View file

@ -0,0 +1,122 @@
import type { UseChatHelpers } from '@ai-sdk/react';
import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
import React from 'react';
import type { AskAiScreenTranslations } from './AskAiScreen';
import { AskAiScreen } from './AskAiScreen';
import type { AskAiStartScreenTranslations } from './components/AskAiStartScreen';
import { AskAiStartScreen } from './components/AskAiStartScreen';
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
import type { DocSearchProps } from './DocSearch';
import type { ErrorScreenTranslations } from './ErrorScreen';
import { ErrorScreen } from './ErrorScreen';
import type { NewConversationTranslations } from './NewConversationScreen';
import { NewConversationScreen } from './NewConversationScreen';
import type { NoResultsScreenTranslations } from './NoResultsScreen';
import { NoResultsScreen } from './NoResultsScreen';
import type { ResultsScreenTranslations } from './ResultsScreen';
import { ResultsScreen } from './ResultsScreen';
import type { StoredSearchPlugin } from './stored-searches';
import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit, SuggestedQuestionHit } from './types';
import type { AIMessage, AskAiState } from './types/AskiAi';
export type AskAiScreenStateTranslations = Partial<{
errorScreen: ErrorScreenTranslations;
startScreen: AskAiStartScreenTranslations;
noResultsScreen: NoResultsScreenTranslations;
resultsScreen: ResultsScreenTranslations;
askAiScreen: AskAiScreenTranslations;
newConversation: NewConversationTranslations;
}>;
export interface AskAiScreenStateProps<TItem extends BaseItem>
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
state: AutocompleteState<TItem>;
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
conversations: StoredSearchPlugin<StoredAskAiState>;
onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void;
onAskAiToggle: (toggle: boolean) => void;
isAskAiActive: boolean;
canHandleAskAi: boolean;
inputRef: React.MutableRefObject<HTMLInputElement | null>;
hitComponent: DocSearchProps['hitComponent'];
indexName: DocSearchProps['indexName'];
messages: UseChatHelpers<AIMessage>['messages'];
status: UseChatHelpers<AIMessage>['status'];
askAiError?: Error;
disableUserPersonalization: boolean;
resultsFooterComponent: DocSearchProps['resultsFooterComponent'];
translations: AskAiScreenStateTranslations;
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
hasCollections: boolean;
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
askAiState: AskAiState;
selectAskAiQuestion: (toggle: boolean, query: string) => void;
suggestedQuestions: SuggestedQuestionHit[];
selectSuggestedQuestion: (question: SuggestedQuestionHit) => void;
onNewConversation: () => void;
agentStudio?: boolean;
}
export const AskAiScreenState = React.memo(
({ translations = {}, ...props }: AskAiScreenStateProps<InternalDocSearchHit>) => {
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'conversation-history') {
return <ConversationHistoryScreen {...props} />;
}
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'new-conversation') {
return (
<NewConversationScreen
translations={translations?.newConversation}
selectSuggestedQuestion={props.selectSuggestedQuestion}
suggestedQuestions={props.suggestedQuestions}
/>
);
}
if (props.isAskAiActive && props.canHandleAskAi) {
return (
<AskAiScreen
{...props}
messages={props.messages}
status={props.status}
askAiError={props.askAiError}
translations={translations?.askAiScreen}
agentStudio={props.agentStudio}
/>
);
}
if (props.state?.status === 'error') {
return <ErrorScreen translations={translations?.errorScreen} />;
}
if (!props.state.query) {
return (
<AskAiStartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />
);
}
if (!props.hasCollections && !props.canHandleAskAi) {
return <NoResultsScreen {...props} translations={translations?.noResultsScreen} />;
}
return (
<>
<ResultsScreen {...props} translations={translations?.resultsScreen} />
{props.canHandleAskAi && props.state.collections.length === 1 && (
// if there's one collection it is the ask ai action, show the no results screen
<NoResultsScreen {...props} translations={translations?.noResultsScreen} />
)}
</>
);
},
function areEqual(_prevProps, nextProps) {
// We don't update the screen when Autocomplete is loading or stalled to
// avoid UI flashes:
// - Empty screen → Results screen
// - NoResults screen → NoResults screen with another query
return nextProps.state.status === 'loading' || nextProps.state.status === 'stalled';
},
);

View file

@ -1,13 +1,13 @@
import type { JSX } from 'react';
import React from 'react';
import type { AskAiScreenStateProps } from './AskAiScreenState';
import { CloseIcon, SparklesIcon } from './icons';
import { Results } from './Results';
import type { ResultsScreenTranslations } from './ResultsScreen';
import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types';
type ConversationHistoryScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
type ConversationHistoryScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
translations?: ResultsScreenTranslations;
};

View file

@ -6,11 +6,11 @@ import React, { type JSX } from 'react';
import { createPortal } from 'react-dom';
import { DocSearchButton } from './DocSearchButton';
import type { ButtonTranslations } from './DocSearchButton';
import { DocSearchModal } from './DocSearchModal';
import type { ModalTranslations } from './DocSearchModal';
import type { DocSearchHit, DocSearchTheme, InternalDocSearchHit, StoredDocSearchHit } from './types';
import type { ButtonTranslations, ModalTranslations } from '.';
export type { DocSearchRef } from '@docsearch/core';
export type DocSearchTranslations = Partial<{
@ -128,17 +128,6 @@ export interface DocSearchProps {
* @see {@link https://docsearch.algolia.com/docs/api#indices}
*/
indices?: Array<DocSearchIndex | string>;
/**
* Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object.
*/
askAi?: DocSearchAskAi | string;
/**
* Intercept Ask AI requests (e.g. Submitting a prompt or selecting a suggested question).
*
* Return `true` to prevent the default modal Ask AI flow (no toggle, no sendMessage).
* Useful to route Ask AI into a different UI (e.g. `@docsearch/sidepanel-js`) without flicker.
*/
interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void;
/**
* Theme overrides applied to the modal and related components.
*/
@ -244,6 +233,20 @@ export interface DocSearchProps {
keyboardShortcuts?: DocSearchModalShortcuts;
}
export interface DocSearchAIProps extends DocSearchProps {
/**
* Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object.
*/
askAi: DocSearchAskAi | string;
/**
* Intercept Ask AI requests (e.g. Submitting a prompt or selecting a suggested question).
*
* Return `true` to prevent the default modal Ask AI flow (no toggle, no sendMessage).
* Useful to route Ask AI into a different UI (e.g. `@docsearch/sidepanel-js`) without flicker.
*/
interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void;
}
function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef<DocSearchRef>): JSX.Element {
return (
<DocSearchProvider {...props} ref={ref}>
@ -255,16 +258,7 @@ function DocSearchComponent(props: DocSearchProps, ref: React.ForwardedRef<DocSe
export const DocSearch = React.forwardRef(DocSearchComponent);
export function DocSearchInner(props: DocSearchProps): JSX.Element {
const {
searchButtonRef,
keyboardShortcuts,
isModalActive,
isAskAiActive,
initialQuery,
onAskAiToggle,
openModal,
closeModal,
} = useDocSearch();
const { searchButtonRef, keyboardShortcuts, isModalActive, initialQuery, openModal, closeModal } = useDocSearch();
return (
<>
@ -281,8 +275,6 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element {
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}
isAskAiActive={isAskAiActive}
onAskAiToggle={onAskAiToggle}
onClose={closeModal}
/>,
props.portalContainer ?? document.body,

View file

@ -0,0 +1,57 @@
import { DocSearch as DocSearchProvider, useDocSearch } from '@docsearch/core';
import type { DocSearchRef } from '@docsearch/core';
import React, { type JSX } from 'react';
import { createPortal } from 'react-dom';
import type { DocSearchAIProps } from './DocSearch';
import { DocSearchAskAiModal } from './DocSearchAskAiModal';
import { DocSearchButton } from './DocSearchButton';
function DocSearchAIComponent(props: DocSearchAIProps, ref: React.ForwardedRef<DocSearchRef>): JSX.Element {
return (
<DocSearchProvider {...props} ref={ref}>
<DocSearchAIInner {...props} />
</DocSearchProvider>
);
}
export const DocSearchAI = React.forwardRef(DocSearchAIComponent);
export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
const {
searchButtonRef,
keyboardShortcuts,
isModalActive,
isAskAiActive,
initialQuery,
onAskAiToggle,
openModal,
closeModal,
isHybridModeSupported,
} = useDocSearch();
return (
<>
<DocSearchButton
keyboardShortcuts={keyboardShortcuts}
ref={searchButtonRef}
translations={props.translations?.button}
onClick={openModal}
/>
{isModalActive &&
createPortal(
<DocSearchAskAiModal
{...props}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}
isAskAiActive={isAskAiActive}
isHybridModeSupported={isHybridModeSupported}
onAskAiToggle={onAskAiToggle}
onClose={closeModal}
/>,
props.portalContainer ?? document.body,
)}
</>
);
}

View file

@ -0,0 +1,505 @@
import { createAutocomplete } from '@algolia/autocomplete-core';
import type { InitialAskAiMessage, OnAskAiToggle } from '@docsearch/core';
import type { ChatRequestOptions } from 'ai';
import React, { type JSX } from 'react';
import type { AskAiScreenStateTranslations } from './AskAiScreenState';
import { AskAiScreenState } from './AskAiScreenState';
import type { AskAiSearchBoxTranslations } from './components/AskAiSearchBox';
import { AskAiSearchBox } from './components/AskAiSearchBox';
import { ModalShell } from './components/ui/ModalShell';
import type { DocSearchAIProps } from './DocSearch';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
import { useModalEnvironment } from './hooks/useModalEnvironment';
import { useModalRefs } from './hooks/useModalRefs';
import { useRefreshOnInitialQuery } from './hooks/useRefreshOnInitialQuery';
import { useSaveRecentSearch } from './hooks/useSaveRecentSearch';
import { useStoredDocSearches } from './hooks/useStoredDocSearches';
import type { NewConversationTranslations } from './NewConversationScreen';
import type { DocSearchState, InternalDocSearchHit, StoredAskAiMessage, SuggestedQuestionHit } from './types';
import type { AskAiState } from './types/AskiAi';
import { useAskAi } from './useAskAi';
import { useSearchClient } from './useSearchClient';
import { useSuggestedQuestions } from './useSuggestedQuestions';
import { identity, isModifierEvent, noop, scrollTo as scrollToUtils } from './utils';
import { buildDummyAskAiHit, isThreadDepthError } from './utils/ai';
import { buildAskAiActionSources, buildRecentConversationSources } from './utils/createAskAiSources';
import { buildNoQuerySources, buildQuerySources, type BuildQuerySourcesState } from './utils/createDocSearchSources';
import { normalizeDocSearchIndexes } from './utils/normalizeDocSearchIndexes';
export type DocSearchAskAiModalTranslations = AskAiScreenStateTranslations &
Partial<{
searchBox: AskAiSearchBoxTranslations;
newConversation: NewConversationTranslations;
footer: FooterTranslations;
}>;
export type DocSearchAskAiModalProps = DocSearchAIProps & {
initialScrollY: number;
onAskAiToggle: OnAskAiToggle;
onClose?: () => void;
isAskAiActive?: boolean;
translations?: DocSearchAskAiModalTranslations;
isHybridModeSupported?: boolean;
};
export function DocSearchAskAiModal({
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,
interceptAskAiEvent,
isAskAiActive = false,
recentSearchesLimit = 7,
recentSearchesWithFavoritesLimit = 4,
indices = [],
indexName,
searchParameters,
isHybridModeSupported = false,
...props
}: DocSearchAskAiModalProps): 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, modalRef, formElementRef, dropdownRef, inputRef, snippetLength } = useModalRefs();
const { initialQuery, initialQueryFromSelection } = useInitialModalQuery(initialQueryFromProp);
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 askAiUseStagingEnv = askAiConfig?.useStagingEnv || false;
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const suggestedQuestions = useSuggestedQuestions({
assistantId: askAiConfigurationId,
searchClient,
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
});
const agentStudio = askAiConfig?.agentStudio ?? false;
const indexes = normalizeDocSearchIndexes({
indexName,
indices,
searchParameters,
});
const defaultIndexName = indexes[0].name;
const { favoriteSearches, recentSearches } = useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
recentSearchesWithFavoritesLimit,
});
const [stoppedStream, setStoppedStream] = React.useState(false);
const { messages, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback, conversations } =
useAskAi({
assistantId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId,
indexName: askAiConfig?.indexName || defaultIndexName,
searchParameters: askAiSearchParameters,
useStagingEnv: askAiUseStagingEnv,
agentStudio,
});
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]);
// Check if there's a thread depth error (AI-217)
const hasThreadDepthError = React.useMemo(() => {
return status === 'error' && isThreadDepthError(askAiError as Error | undefined);
}, [status, askAiError]);
const saveRecentSearch = useSaveRecentSearch({
favoriteSearches,
recentSearches,
disableUserPersonalization,
});
const sendItemClickEvent = useSendItemClickEvent(state);
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) {
const initialMessage: InitialAskAiMessage = {
query,
suggestedQuestionId: suggestedQuestion?.objectID,
};
if (interceptAskAiEvent?.(initialMessage)) {
// Consumer handled it. Avoid *all* default Ask AI behavior.
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
return;
}
}
if (toggle && askAiState === 'new-conversation') {
setAskAiState('initial');
}
onAskAiToggle(toggle, {
query,
suggestedQuestionId: suggestedQuestion?.objectID,
});
// If we're in hybrid mode, we don't need to send the message,
// it will be handled by the Sidepanel.
if (isHybridModeSupported) return;
setStoppedStream(false);
const messageOptions: ChatRequestOptions = {};
if (suggestedQuestion) {
messageOptions.body = {
suggestedQuestionId: suggestedQuestion.objectID,
};
}
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('');
}
},
[askAiState, onAskAiToggle, isHybridModeSupported, sendMessage, dropdownRef, interceptAskAiEvent],
);
// feedback handler
const handleFeedbackSubmit = React.useCallback(
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
if (!askAiConfigurationId || !appId) return;
await sendFeedback(messageId, thumbs);
},
[askAiConfigurationId, appId, sendFeedback],
);
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,
});
const recentConversationSource = canHandleAskAi
? buildRecentConversationSources({
conversations,
disableUserPersonalization,
setMessages,
onAskAiToggle,
})
: [];
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,
});
const askAiSource = canHandleAskAi ? buildAskAiActionSources({ query, handleSelectAskAiQuestion }) : [];
// 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;
useModalEnvironment({
getEnvironmentProps,
containerRef,
dropdownRef,
formElementRef,
inputRef,
initialScrollY,
modalRef,
snippetLength,
theme,
});
React.useEffect(() => {
if (dropdownRef.current && !isAskAiActive) {
scrollToUtils(dropdownRef.current);
}
}, [state.query, isAskAiActive, dropdownRef]);
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
// 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 => {
setMessages([]);
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 (
<ModalShell
state={state}
containerRef={containerRef}
modalRef={modalRef}
formElementRef={formElementRef}
dropdownRef={dropdownRef}
getRootProps={getRootProps}
showDropdown={showDocsearchDropdown}
searchBox={
<AskAiSearchBox
{...autocomplete}
state={state}
placeholder={placeholder || 'Search docs'}
autoFocus={initialQuery.length === 0}
inputRef={inputRef}
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
translations={searchBoxTranslations}
isAskAiActive={isAskAiActive}
askAiStatus={status}
askAiError={askAiError}
askAiState={askAiState}
setAskAiState={setAskAiState}
isThreadDepthError={hasThreadDepthError && askAiState !== 'new-conversation'}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {
handleSelectAskAiQuestion(true, query);
}}
onStopAskAiStreaming={onStopAskAiStreaming}
onNewConversation={handleNewConversation}
onViewConversationHistory={handleViewConversationHistory}
/>
}
screenState={
<AskAiScreenState
{...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}
agentStudio={agentStudio}
onAskAiToggle={onAskAiToggle}
onNewConversation={handleNewConversation}
onItemClick={(item, event) => {
if (item.type === 'askAI' && item.query) {
if (item.anchor === 'stored' && 'messages' in item) {
setMessages(item.messages as any);
const initialMessage: InitialAskAiMessage = {
query: item.query,
messageId: (item.messages as StoredAskAiMessage[])[0].id,
};
if (interceptAskAiEvent?.(initialMessage)) {
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
event.preventDefault();
return;
}
onAskAiToggle(true, initialMessage);
} else {
handleSelectAskAiQuestion(true, item.query);
}
setAskAiState('initial');
event.preventDefault();
return;
}
sendItemClickEvent(item);
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
}}
onFeedback={handleFeedbackSubmit}
/>
}
footer={<Footer translations={footerTranslations} isAskAiActive={isAskAiActive} />}
onClose={onClose}
/>
);
}

View file

@ -1,291 +1,43 @@
import {
type AutocompleteSource,
type AlgoliaInsightsHit,
createAutocomplete,
type AutocompleteState,
} from '@algolia/autocomplete-core';
import type { InitialAskAiMessage, OnAskAiToggle } from '@docsearch/core';
import { useTheme } from '@docsearch/core/useTheme';
import type { ChatRequestOptions } from 'ai';
import type { SearchResponse } from 'algoliasearch/lite';
import { createAutocomplete } from '@algolia/autocomplete-core';
import React, { type JSX } from 'react';
import { MAX_QUERY_SIZE } from './constants';
import type { DocSearchIndex, DocSearchProps } from './DocSearch';
import type { KeywordSearchBoxTranslations } from './components/KeywordSearchBox';
import { KeywordSearchBox } from './components/KeywordSearchBox';
import { ModalShell } from './components/ui/ModalShell';
import type { DocSearchProps } from './DocSearch';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import type { NewConversationTranslations } from './NewConversationScreen';
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
import { useModalEnvironment } from './hooks/useModalEnvironment';
import { useModalRefs } from './hooks/useModalRefs';
import { useRefreshOnInitialQuery } from './hooks/useRefreshOnInitialQuery';
import { useSaveRecentSearch } from './hooks/useSaveRecentSearch';
import { useStoredDocSearches } from './hooks/useStoredDocSearches';
import type { ScreenStateTranslations } from './ScreenState';
import { ScreenState } from './ScreenState';
import type { SearchBoxTranslations } from './SearchBox';
import { SearchBox } from './SearchBox';
import { createStoredSearches } from './stored-searches';
import type {
DocSearchHit,
DocSearchState,
InternalDocSearchHit,
StoredAskAiMessage,
StoredDocSearchHit,
SuggestedQuestionHit,
} from './types';
import type { AIMessage, AskAiState } from './types/AskiAi';
import { useAskAi } from './useAskAi';
import type { DocSearchState, InternalDocSearchHit } from './types';
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, isThreadDepthError } from './utils/ai';
import { manageLocalStorageQuota } from './utils/storage';
import { identity, isModifierEvent, noop, scrollTo as scrollToUtils } from './utils';
import { buildNoQuerySources, buildQuerySources, type BuildQuerySourcesState } from './utils/createDocSearchSources';
import { normalizeDocSearchIndexes } from './utils/normalizeDocSearchIndexes';
export type ModalTranslations = Partial<{
searchBox: SearchBoxTranslations;
newConversation: NewConversationTranslations;
searchBox: KeywordSearchBoxTranslations;
footer: FooterTranslations;
}> &
ScreenStateTranslations;
export type DocSearchModalProps = DocSearchProps & {
initialScrollY: number;
onAskAiToggle: OnAskAiToggle;
interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void;
onClose?: () => void;
isAskAiActive?: boolean;
translations?: ModalTranslations;
isHybridModeSupported?: boolean;
};
/**
* 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,
@ -300,15 +52,11 @@ export function DocSearchModal({
translations = {},
getMissingResultsUrl,
insights = false,
onAskAiToggle,
interceptAskAiEvent,
isAskAiActive = false,
recentSearchesLimit = 7,
recentSearchesWithFavoritesLimit = 4,
indices = [],
indexName,
searchParameters,
isHybridModeSupported = false,
...props
}: DocSearchModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
@ -322,173 +70,31 @@ export function DocSearchModal({
status: 'idle',
});
// check if the instance is configured to handle ask ai
const canHandleAskAi = Boolean(askAi);
const placeholder = translations?.searchBox?.placeholderText || props.placeholder || 'Search docs';
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 { containerRef, modalRef, formElementRef, dropdownRef, inputRef, snippetLength } = useModalRefs();
const { initialQuery, initialQueryFromSelection } = useInitialModalQuery(initialQueryFromProp);
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 askAiUseStagingEnv = askAiConfig?.useStagingEnv || false;
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const suggestedQuestions = useSuggestedQuestions({
assistantId: askAiConfigurationId,
searchClient,
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
const indexes = normalizeDocSearchIndexes({
indexName,
indices,
searchParameters,
});
const agentStudio = askAiConfig?.agentStudio ?? false;
// 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 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, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback, conversations } =
useAskAi({
assistantId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId,
indexName: askAiConfig?.indexName || defaultIndexName,
searchParameters: askAiSearchParameters,
useStagingEnv: askAiUseStagingEnv,
agentStudio,
});
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]);
// Check if there's a thread depth error (AI-217)
const hasThreadDepthError = React.useMemo(() => {
return status === 'error' && isThreadDepthError(askAiError as Error | undefined);
}, [status, askAiError]);
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 { favoriteSearches, recentSearches } = useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
recentSearchesWithFavoritesLimit,
});
const saveRecentSearch = useSaveRecentSearch({
favoriteSearches,
recentSearches,
disableUserPersonalization,
});
const sendItemClickEvent = useSendItemClickEvent(state);
const autocompleteRef =
React.useRef<
@ -502,87 +108,6 @@ export function DocSearchModal({
>
>(undefined);
const handleSelectAskAiQuestion = React.useCallback(
(toggle: boolean, query: string, suggestedQuestion: SuggestedQuestionHit | undefined = undefined) => {
if (toggle) {
const initialMessage: InitialAskAiMessage = {
query,
suggestedQuestionId: suggestedQuestion?.objectID,
};
if (interceptAskAiEvent?.(initialMessage)) {
// Consumer handled it. Avoid *all* default Ask AI behavior.
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
return;
}
}
if (toggle && askAiState === 'new-conversation') {
setAskAiState('initial');
}
onAskAiToggle(toggle, {
query,
suggestedQuestionId: suggestedQuestion?.objectID,
});
// If we're in hybrid mode, we don't need to send the message,
// it will be handled by the Sidepanel.
if (isHybridModeSupported) return;
setStoppedStream(false);
const messageOptions: ChatRequestOptions = {};
if (suggestedQuestion) {
messageOptions.body = {
suggestedQuestionId: suggestedQuestion.objectID,
};
}
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, interceptAskAiEvent, sendMessage, askAiState, setAskAiState, isHybridModeSupported],
);
// feedback handler
const handleFeedbackSubmit = React.useCallback(
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
if (!askAiConfigurationId || !appId) return;
await sendFeedback(messageId, thumbs);
},
[askAiConfigurationId, appId, sendFeedback],
);
if (!autocompleteRef.current) {
autocompleteRef.current = createAutocomplete({
id: 'docsearch',
@ -607,30 +132,8 @@ export function DocSearchModal({
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];
return noQuerySources;
}
const querySourcesState: BuildQuerySourcesState = {
@ -654,49 +157,7 @@ export function DocSearchModal({
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];
});
return algoliaSourcesPromise;
},
});
}
@ -705,253 +166,79 @@ export function DocSearchModal({
const { getEnvironmentProps, getRootProps, refresh } = autocomplete;
useTouchEvents({
useModalEnvironment({
getEnvironmentProps,
panelElement: dropdownRef.current,
formElement: formElementRef.current,
inputElement: inputRef.current,
containerRef,
dropdownRef,
formElementRef,
inputRef,
initialScrollY,
modalRef,
snippetLength,
theme,
});
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) {
if (dropdownRef.current) {
scrollToUtils(dropdownRef.current);
}
}, [state.query, isAskAiActive]);
}, [state.query, dropdownRef]);
// 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 => {
setMessages([]);
setAskAiState('new-conversation');
};
const handleViewConversationHistory = (): void => {
setAskAiState('conversation-history');
};
const selectSuggestedQuestion = (suggestedQuestion: SuggestedQuestionHit): void => {
handleSelectAskAiQuestion(true, suggestedQuestion.question, suggestedQuestion);
};
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
// 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) {
if (state.status === 'idle' && hasCollections === false && state.query.length === 0) {
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}
askAiError={askAiError}
askAiState={askAiState}
setAskAiState={setAskAiState}
isThreadDepthError={hasThreadDepthError && askAiState !== 'new-conversation'}
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}
agentStudio={agentStudio}
onAskAiToggle={onAskAiToggle}
onNewConversation={handleNewConversation}
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);
const initialMessage: InitialAskAiMessage = {
query: item.query,
messageId: (item.messages as StoredAskAiMessage[])[0].id,
};
if (interceptAskAiEvent?.(initialMessage)) {
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
event.preventDefault();
return;
}
onAskAiToggle(true, initialMessage);
} 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>
<ModalShell
state={state}
containerRef={containerRef}
modalRef={modalRef}
formElementRef={formElementRef}
dropdownRef={dropdownRef}
getRootProps={getRootProps}
showDropdown={showDocsearchDropdown}
searchBox={
<KeywordSearchBox
{...autocomplete}
state={state}
placeholder={placeholder || 'Search docs'}
autoFocus={initialQuery.length === 0}
inputRef={inputRef}
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
translations={searchBoxTranslations}
onClose={onClose}
/>
}
screenState={
<ScreenState
{...autocomplete}
indexName={defaultIndexName}
state={state}
hitComponent={hitComponent}
resultsFooterComponent={resultsFooterComponent}
disableUserPersonalization={disableUserPersonalization}
recentSearches={recentSearches}
favoriteSearches={favoriteSearches}
inputRef={inputRef}
translations={screenStateTranslations}
getMissingResultsUrl={getMissingResultsUrl}
hasCollections={hasCollections}
onItemClick={(item, event) => {
sendItemClickEvent(item);
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
}}
/>
}
footer={<Footer translations={footerTranslations} />}
onClose={onClose}
/>
);
}

View file

@ -12,6 +12,7 @@ export type NoResultsScreenTranslations = Partial<{
}>;
type NoResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
canHandleAskAi?: boolean;
translations?: NoResultsScreenTranslations;
};

View file

@ -1,32 +1,23 @@
import type { UseChatHelpers } from '@ai-sdk/react';
import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
import React from 'react';
import type { AskAiScreenTranslations } from './AskAiScreen';
import { AskAiScreen } from './AskAiScreen';
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
import type { KeywordStartScreenTranslations } from './components/KeywordStartScreen';
import { KeywordStartScreen } from './components/KeywordStartScreen';
import type { DocSearchProps } from './DocSearch';
import type { ErrorScreenTranslations } from './ErrorScreen';
import { ErrorScreen } from './ErrorScreen';
import type { NewConversationTranslations } from './NewConversationScreen';
import { NewConversationScreen } from './NewConversationScreen';
import type { NoResultsScreenTranslations } from './NoResultsScreen';
import { NoResultsScreen } from './NoResultsScreen';
import type { ResultsScreenTranslations } from './ResultsScreen';
import { ResultsScreen } from './ResultsScreen';
import type { StartScreenTranslations } from './StartScreen';
import { StartScreen } from './StartScreen';
import type { StoredSearchPlugin } from './stored-searches';
import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit, SuggestedQuestionHit } from './types';
import type { AIMessage, AskAiState } from './types/AskiAi';
import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
export type ScreenStateTranslations = Partial<{
errorScreen: ErrorScreenTranslations;
startScreen: StartScreenTranslations;
startScreen: KeywordStartScreenTranslations;
noResultsScreen: NoResultsScreenTranslations;
resultsScreen: ResultsScreenTranslations;
askAiScreen: AskAiScreenTranslations;
newConversation: NewConversationTranslations;
}>;
export interface ScreenStateProps<TItem extends BaseItem>
@ -34,81 +25,34 @@ export interface ScreenStateProps<TItem extends BaseItem>
state: AutocompleteState<TItem>;
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
conversations: StoredSearchPlugin<StoredAskAiState>;
onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void;
onAskAiToggle: (toggle: boolean) => void;
isAskAiActive: boolean;
canHandleAskAi: boolean;
inputRef: React.MutableRefObject<HTMLInputElement | null>;
hitComponent: DocSearchProps['hitComponent'];
indexName: DocSearchProps['indexName'];
messages: UseChatHelpers<AIMessage>['messages'];
status: UseChatHelpers<AIMessage>['status'];
askAiError?: Error;
disableUserPersonalization: boolean;
resultsFooterComponent: DocSearchProps['resultsFooterComponent'];
translations: ScreenStateTranslations;
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
hasCollections: boolean;
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
askAiState: AskAiState;
selectAskAiQuestion: (toggle: boolean, query: string) => void;
suggestedQuestions: SuggestedQuestionHit[];
selectSuggestedQuestion: (question: SuggestedQuestionHit) => void;
onNewConversation: () => void;
agentStudio?: boolean;
}
export const ScreenState = React.memo(
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'conversation-history') {
return <ConversationHistoryScreen {...props} />;
}
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'new-conversation') {
return (
<NewConversationScreen
translations={translations?.newConversation}
selectSuggestedQuestion={props.selectSuggestedQuestion}
suggestedQuestions={props.suggestedQuestions}
/>
);
}
if (props.isAskAiActive && props.canHandleAskAi) {
return (
<AskAiScreen
{...props}
messages={props.messages}
status={props.status}
askAiError={props.askAiError}
translations={translations?.askAiScreen}
agentStudio={props.agentStudio}
/>
);
}
if (props.state?.status === 'error') {
return <ErrorScreen translations={translations?.errorScreen} />;
}
if (!props.state.query) {
return <StartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />;
return (
<KeywordStartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />
);
}
if (!props.hasCollections && !props.canHandleAskAi) {
if (!props.hasCollections) {
return <NoResultsScreen {...props} translations={translations?.noResultsScreen} />;
}
return (
<>
<ResultsScreen {...props} translations={translations?.resultsScreen} />
{props.canHandleAskAi && props.state.collections.length === 1 && (
// if there's one collection it is the ask ai action, show the no results screen
<NoResultsScreen {...props} translations={translations?.noResultsScreen} />
)}
</>
);
return <ResultsScreen {...props} translations={translations?.resultsScreen} />;
},
function areEqual(_prevProps, nextProps) {
// We don't update the screen when Autocomplete is loading or stalled to

View file

@ -5,12 +5,17 @@ import { describe, it, expect, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { DocSearch as DocSearchComponent } from '../DocSearch';
import type { DocSearchProps } from '../DocSearch';
import type { DocSearchAIProps, DocSearchProps } from '../DocSearch';
import { DocSearchAI as DocSearchAIComponent } from '../DocSearchAI';
function DocSearch(props: Partial<DocSearchProps>): JSX.Element {
return <DocSearchComponent appId="woo" apiKey="foo" indexName="bar" {...props} />;
}
function DocSearchAI(props: Partial<DocSearchAIProps>): JSX.Element {
return <DocSearchAIComponent appId="woo" apiKey="foo" indexName="bar" askAi="assistant" {...props} />;
}
// mock empty response
function noResultSearch(_queries: any, _requestOptions?: any): Promise<any> {
return new Promise((resolve) => {
@ -264,7 +269,7 @@ describe('api', () => {
describe('ask AI integration', () => {
it('updates placeholder when ask AI is available', async () => {
render(<DocSearch askAi="assistant" />);
render(<DocSearchAI />);
await act(async () => {
fireEvent.click(await screen.findByText('Search'));
@ -275,8 +280,7 @@ describe('api', () => {
it('opens ask AI screen and returns to search', async () => {
render(
<DocSearch
askAi="assistant"
<DocSearchAI
transformSearchClient={(searchClient) => ({
...searchClient,
search: noResultSearch,

View file

@ -0,0 +1,109 @@
import { render, cleanup } from '@testing-library/react';
import React, { type JSX } from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { MAX_QUERY_SIZE } from '../constants';
import { useInitialModalQuery } from '../hooks/useInitialModalQuery';
import { useModalRefs } from '../hooks/useModalRefs';
import { useRefreshOnInitialQuery } from '../hooks/useRefreshOnInitialQuery';
describe('modal hooks', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('useInitialModalQuery', () => {
function TestComponent({
initialQueryFromProp,
onResult,
}: {
initialQueryFromProp: string;
onResult: (result: ReturnType<typeof useInitialModalQuery>) => void;
}): null {
onResult(useInitialModalQuery(initialQueryFromProp));
return null;
}
it('preserves explicit initial query precedence over selected text', () => {
const onResult = vi.fn();
vi.spyOn(window, 'getSelection').mockReturnValue({
toString: () => 'selected text',
} as Selection);
render(<TestComponent initialQueryFromProp="explicit query" onResult={onResult} />);
expect(onResult).toHaveBeenCalledWith({
initialQuery: 'explicit query',
initialQueryFromSelection: 'selected text',
});
});
it('slices selected text with MAX_QUERY_SIZE', () => {
const onResult = vi.fn();
const selection = 'a'.repeat(MAX_QUERY_SIZE + 10);
vi.spyOn(window, 'getSelection').mockReturnValue({
toString: () => selection,
} as Selection);
render(<TestComponent initialQueryFromProp="" onResult={onResult} />);
expect(onResult).toHaveBeenCalledWith({
initialQuery: selection.slice(0, MAX_QUERY_SIZE),
initialQueryFromSelection: selection.slice(0, MAX_QUERY_SIZE),
});
});
});
describe('useRefreshOnInitialQuery', () => {
function TestComponent({ initialQuery, refresh }: { initialQuery: string; refresh: () => void }): JSX.Element {
const inputRef = React.useRef<HTMLInputElement | null>(null);
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
return <input ref={inputRef} />;
}
it('calls refresh and focuses input only when an initial query exists', () => {
const refresh = vi.fn();
const { rerender } = render(<TestComponent initialQuery="" refresh={refresh} />);
expect(refresh).not.toHaveBeenCalled();
expect(document.activeElement).toBe(document.body);
rerender(<TestComponent initialQuery="query" refresh={refresh} />);
expect(refresh).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(document.querySelector('input'));
});
});
describe('useModalRefs', () => {
function TestComponent({ onResult }: { onResult: (result: ReturnType<typeof useModalRefs>) => void }): null {
onResult(useModalRefs());
return null;
}
it('returns stable refs with the expected initial values', () => {
const onResult = vi.fn();
const { rerender } = render(<TestComponent onResult={onResult} />);
const firstResult = onResult.mock.calls[0][0] as ReturnType<typeof useModalRefs>;
expect(firstResult.containerRef.current).toBeNull();
expect(firstResult.modalRef.current).toBeNull();
expect(firstResult.formElementRef.current).toBeNull();
expect(firstResult.dropdownRef.current).toBeNull();
expect(firstResult.inputRef.current).toBeNull();
expect(firstResult.snippetLength.current).toBe(15);
rerender(<TestComponent onResult={onResult} />);
expect(onResult.mock.calls[1][0]).toEqual(firstResult);
});
});
});

View file

@ -0,0 +1,218 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { AskAiSearchBox } from '../components/AskAiSearchBox';
import { KeywordSearchBox } from '../components/KeywordSearchBox';
import { SearchBoxForm } from '../components/ui/SearchBoxForm';
function createState(overrides = {}): any {
return {
query: '',
collections: [],
status: 'idle',
...overrides,
};
}
function createAutocomplete(overrides = {}): any {
const onChange = vi.fn();
const onKeyDown = vi.fn();
return {
getFormProps: vi.fn(() => ({
onReset: vi.fn(),
})),
getInputProps: vi.fn(() => ({
'aria-autocomplete': 'list',
onChange,
onKeyDown,
})),
getLabelProps: vi.fn(() => ({
htmlFor: 'docsearch-input',
})),
setQuery: vi.fn(),
...overrides,
};
}
function renderSearchBoxForm(props = {}): ReturnType<typeof render> {
const autocomplete = createAutocomplete();
return render(
<SearchBoxForm
{...autocomplete}
state={createState()}
autoFocus={false}
inputRef={React.createRef<HTMLInputElement>()}
isFromSelection={false}
placeholder="Search docs"
clearButtonTitle="Clear"
clearButtonAriaLabel="Clear the query"
closeButtonText="Close"
closeButtonAriaLabel="Close"
searchInputLabel="Search"
onClose={vi.fn()}
{...props}
/>,
);
}
describe('SearchBoxForm', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('renders the shared form, input, clear button, close button, and search label', () => {
renderSearchBoxForm({
state: createState({ query: 'docsearch' }),
});
expect(document.querySelector('.DocSearch-Form')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search docs')).toBeInTheDocument();
expect(document.querySelector('.DocSearch-Clear')).toHaveTextContent('Clear');
expect(screen.getByRole('button', { name: 'Close' })).toHaveAttribute('title', 'Close');
expect(document.querySelector('.DocSearch-MagnifierLabel')).toHaveTextContent('Search');
});
it('renders the loading indicator while keyword search is stalled', () => {
renderSearchBoxForm({
state: createState({ status: 'stalled' }),
});
expect(document.querySelector('.DocSearch-LoadingIndicator')).toBeInTheDocument();
expect(document.querySelector('.DocSearch-MagnifierLabel')).not.toBeInTheDocument();
});
it('allows scoped input props and slots to override base autocomplete props', () => {
const onKeyDown = vi.fn();
const onChange = vi.fn();
renderSearchBoxForm({
leadingElement: <span data-testid="leading">Leading</span>,
inputOverlay: <span data-testid="overlay">Overlay</span>,
actionsBeforeClose: <button type="button">Action</button>,
hideInput: true,
inputProps: {
disabled: true,
enterKeyHint: 'enter',
onChange,
onKeyDown,
},
});
const input = screen.getByPlaceholderText('Search docs');
expect(screen.getByTestId('leading')).toBeInTheDocument();
expect(screen.getByTestId('overlay')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
expect(input).toBeDisabled();
expect(input).toHaveAttribute('enterkeyhint', 'enter');
expect(input).toHaveAttribute('hidden');
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.change(input, { target: { value: 'ask' } });
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
});
});
describe('KeywordSearchBox', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('passes keyword search defaults to the shared form', () => {
render(
<KeywordSearchBox
{...createAutocomplete()}
state={createState()}
autoFocus={false}
inputRef={React.createRef<HTMLInputElement>()}
isFromSelection={false}
placeholder="Search docs"
onClose={vi.fn()}
/>,
);
expect(document.querySelector('.DocSearch-Clear')).toHaveTextContent('Clear');
expect(screen.getByRole('button', { name: 'Close' })).toHaveAttribute('title', 'Close');
expect(screen.getByPlaceholderText('Search docs')).toHaveAttribute('enterkeyhint', 'search');
});
});
describe('AskAiSearchBox', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function renderAskAiSearchBox(props = {}): ReturnType<typeof render> {
return render(
<AskAiSearchBox
{...createAutocomplete()}
state={createState({ query: 'follow up', collections: [null, null, { items: [{ objectID: '1' }] }] })}
autoFocus={false}
inputRef={React.createRef<HTMLInputElement>()}
isFromSelection={false}
placeholder="Ask another question..."
isAskAiActive={true}
askAiStatus="ready"
askAiState="initial"
setAskAiState={vi.fn()}
onAskAgain={vi.fn()}
onAskAiToggle={vi.fn()}
onClose={vi.fn()}
onNewConversation={vi.fn()}
onStopAskAiStreaming={vi.fn()}
onViewConversationHistory={vi.fn()}
{...props}
/>,
);
}
it('renders Ask AI-specific back action and conversation menu actions', () => {
renderAskAiSearchBox();
expect(screen.getByRole('button', { name: 'Back to keyword search' })).toBeInTheDocument();
expect(screen.getByText('Start a new conversation')).toBeInTheDocument();
expect(screen.getByText('Conversation history')).toBeInTheDocument();
});
it('renders the stop streaming action and disables the input while streaming', () => {
renderAskAiSearchBox({ askAiStatus: 'streaming' });
expect(document.querySelector('.DocSearch-StopStreaming')).toBeInTheDocument();
expect(screen.getByDisplayValue('')).toBeDisabled();
expect(screen.getByText('Answering...')).toBeInTheDocument();
});
it('intercepts Enter while Ask AI is active to ask again', () => {
const onAskAgain = vi.fn();
renderAskAiSearchBox({ onAskAgain });
fireEvent.keyDown(screen.getByPlaceholderText('Ask another question...'), { key: 'Enter' });
expect(onAskAgain).toHaveBeenCalledWith('follow up');
});
it('uses thread-depth behavior in Ask AI mode', () => {
const onNewConversation = vi.fn();
renderAskAiSearchBox({ isThreadDepthError: true, onNewConversation });
const input = screen.getByPlaceholderText('Conversation limit reached');
expect(input).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Back to keyword search' }));
expect(onNewConversation).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,192 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import React, { type JSX } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { AskAiStartScreen } from '../components/AskAiStartScreen';
import { KeywordStartScreen } from '../components/KeywordStartScreen';
import { RecentConversationsResults } from '../components/ui/RecentConversationsResults';
import { StoredSearchesSections } from '../components/ui/StoredSearchesSections';
import type { InternalDocSearchHit, StoredAskAiState } from '../types';
afterEach(() => {
cleanup();
});
function createHit(objectID: string, title: string): InternalDocSearchHit {
return {
objectID,
content: null,
url: `https://example.com/${objectID}`,
url_without_anchor: `https://example.com/${objectID}`,
type: 'lvl1',
anchor: null,
hierarchy: {
lvl0: 'Docs',
lvl1: title,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
_highlightResult: {
content: { value: '', matchLevel: 'none', matchedWords: [] },
hierarchy: {
lvl0: { value: 'Docs', matchLevel: 'none', matchedWords: [] },
lvl1: { value: title, matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
},
hierarchy_camel: [],
},
_snippetResult: {
content: { value: '', matchLevel: 'none' },
hierarchy: {
lvl0: { value: 'Docs', matchLevel: 'none', matchedWords: [] },
lvl1: { value: title, matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
},
hierarchy_camel: [],
},
__docsearch_parent: null,
};
}
function createCollection(sourceId: string, items: InternalDocSearchHit[]): unknown {
return {
source: { sourceId },
items,
};
}
function Hit({ children }: { children: React.ReactNode }): JSX.Element {
return <>{children}</>;
}
function createProps(collections: unknown[]) {
return {
state: {
collections,
query: '',
status: 'idle',
},
recentSearches: {
add: vi.fn(),
remove: vi.fn(),
},
favoriteSearches: {
add: vi.fn(),
remove: vi.fn(),
},
conversations: {
add: vi.fn(),
remove: vi.fn(),
},
refresh: vi.fn(),
getListProps: vi.fn(() => ({})),
getItemProps: vi.fn(({ onClick }) => ({ onClick })),
onItemClick: vi.fn(),
inputRef: React.createRef<HTMLInputElement>(),
hitComponent: Hit,
indexName: 'docs',
disableUserPersonalization: false,
resultsFooterComponent: null,
hasCollections: true,
} as any;
}
describe('start screen components', () => {
it('renders stored searches and runs save/remove actions', () => {
const recentHit = createHit('recent', 'Recent result');
const favoriteHit = createHit('favorite', 'Favorite result');
const props = createProps([
createCollection('recentSearches', [recentHit]),
createCollection('favoriteSearches', [favoriteHit]),
]);
render(<StoredSearchesSections {...props} />);
expect(screen.getByText('Recent result')).toBeInTheDocument();
expect(screen.getByText('Favorite result')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Save this search'));
expect(props.favoriteSearches.add).toHaveBeenCalledWith(recentHit);
expect(props.recentSearches.remove).toHaveBeenCalledWith(recentHit);
fireEvent.click(screen.getByTitle('Remove this search from history'));
expect(props.recentSearches.remove).toHaveBeenCalledWith(recentHit);
fireEvent.click(screen.getByTitle('Remove this search from favorites'));
expect(props.favoriteSearches.remove).toHaveBeenCalledWith(favoriteHit);
expect(props.refresh).toHaveBeenCalledTimes(3);
});
it('renders recent conversations and removes them', () => {
const conversation = createHit('conversation', 'Conversation result') as InternalDocSearchHit & StoredAskAiState;
const props = createProps([
createCollection('recentSearches', []),
createCollection('favoriteSearches', []),
createCollection('recentConversations', [conversation]),
]);
render(<RecentConversationsResults {...props} />);
expect(screen.getByText('Conversation result')).toBeInTheDocument();
fireEvent.click(screen.getByTitle('Remove this conversation from history'));
expect(props.conversations.remove).toHaveBeenCalledWith(conversation);
expect(props.refresh).toHaveBeenCalledTimes(1);
});
it('keeps the keyword start screen scoped to stored searches', () => {
const props = createProps([
createCollection('recentSearches', []),
createCollection('favoriteSearches', []),
createCollection('recentConversations', [createHit('conversation', 'Conversation result')]),
]);
render(
<KeywordStartScreen
{...props}
translations={{
recentSearchesTitle: 'Search history',
noRecentSearchesText: 'Nothing yet',
}}
/>,
);
expect(screen.queryByText('Recent conversations')).not.toBeInTheDocument();
});
it('composes stored searches and recent conversations for Ask AI', () => {
const props = createProps([
createCollection('recentSearches', []),
createCollection('favoriteSearches', []),
createCollection('recentConversations', [createHit('conversation', 'Conversation result')]),
]);
render(
<AskAiStartScreen
{...props}
translations={{
noRecentSearchesText: 'No saved searches',
recentConversationsTitle: 'Recent chats',
removeRecentConversationButtonTitle: 'Remove chat',
}}
/>,
);
expect(screen.getByText('Recent chats')).toBeInTheDocument();
expect(screen.getByText('Conversation result')).toBeInTheDocument();
expect(screen.getByTitle('Remove chat')).toBeInTheDocument();
});
});

View file

@ -2,23 +2,16 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
import React, { type JSX, type RefObject } from 'react';
import { MAX_QUERY_SIZE } from './constants';
import {
LoadingIcon,
CloseIcon,
SearchIcon,
StopIcon,
MoreVerticalIcon,
NewConversationIcon,
ConversationHistoryIcon,
} from './icons';
import { BackIcon } from './icons/BackIcon';
import { Menu } from './Menu';
import { ModalHeading } from './ModalHeading';
import type { InternalDocSearchHit } from './types';
import type { AIMessage, AskAiState } from './types/AskiAi';
import { ConversationHistoryIcon, MoreVerticalIcon, NewConversationIcon, StopIcon } from '../icons';
import { BackIcon } from '../icons/BackIcon';
import { Menu } from '../Menu';
import { ModalHeading } from '../ModalHeading';
import type { InternalDocSearchHit } from '../types';
import type { AIMessage, AskAiState } from '../types/AskiAi';
export type SearchBoxTranslations = Partial<{
import { SearchBoxForm } from './ui/SearchBoxForm';
export type AskAiSearchBoxTranslations = Partial<{
clearButtonTitle: string;
clearButtonAriaLabel: string;
closeButtonText: string;
@ -38,7 +31,7 @@ export type SearchBoxTranslations = Partial<{
threadDepthErrorPlaceholder: string;
}>;
interface SearchBoxProps
interface AskAiSearchBoxProps
extends AutocompleteApi<InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
state: AutocompleteState<InternalDocSearchHit>;
autoFocus: boolean;
@ -52,7 +45,7 @@ interface SearchBoxProps
askAiStatus: UseChatHelpers<AIMessage>['status'];
askAiError?: Error;
isFromSelection: boolean;
translations?: SearchBoxTranslations;
translations?: AskAiSearchBoxTranslations;
askAiState: AskAiState;
setAskAiState: (state: AskAiState) => void;
onNewConversation: () => void;
@ -60,13 +53,13 @@ interface SearchBoxProps
isThreadDepthError?: boolean;
}
export function SearchBox({
export function AskAiSearchBox({
translations = {},
askAiState,
onAskAiToggle,
setAskAiState,
...props
}: SearchBoxProps): JSX.Element {
}: AskAiSearchBoxProps): JSX.Element {
const {
clearButtonTitle = 'Clear',
clearButtonAriaLabel = 'Clear the query',
@ -82,21 +75,6 @@ export function SearchBox({
viewConversationHistoryText = 'Conversation history',
threadDepthErrorPlaceholder = 'Conversation limit reached',
} = translations;
const { onReset } = props.getFormProps({
inputElement: props.inputRef.current,
});
React.useEffect(() => {
if (props.autoFocus && props.inputRef.current) {
props.inputRef.current.focus();
}
}, [props.autoFocus, props.inputRef]);
React.useEffect(() => {
if (props.isFromSelection && props.inputRef.current) {
props.inputRef.current.select();
}
}, [props.isFromSelection, props.inputRef]);
const hasRecentConversations = React.useMemo(() => {
const askAiSource = props.state.collections[2];
@ -111,14 +89,11 @@ export function SearchBox({
const baseInputProps = props.getInputProps({
inputElement: props.inputRef.current!,
autoFocus: props.autoFocus,
maxLength: MAX_QUERY_SIZE,
});
const blockedKeys = new Set(['ArrowUp', 'ArrowDown', 'Enter']);
const origOnKeyDown = baseInputProps.onKeyDown;
const origOnChange = baseInputProps.onChange;
const isAskAiStreaming = props.askAiStatus === 'streaming' || props.askAiStatus === 'submitted';
const isKeywordSearchLoading = props.state.status === 'stalled';
const renderMoreOptions = props.isAskAiActive && askAiState !== 'conversation-history';
// Use the thread depth error state passed from parent
@ -159,7 +134,6 @@ export function SearchBox({
* https://github.com/algolia/autocomplete/blob/next/packages/autocomplete-core/src/getDefaultProps.ts.
*/
const inputProps = {
...baseInputProps,
enterKeyHint: props.isAskAiActive ? ('enter' as const) : ('search' as const),
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
// block these up, down, enter listeners when Ask AI is active
@ -205,117 +179,75 @@ export function SearchBox({
onAskAiToggle(false);
}, [askAiState, isThreadDepthError, onAskAiToggle, setAskAiState, props]);
return (
const leadingElement = props.isAskAiActive ? (
<button
type="button"
tabIndex={0}
className="DocSearch-Action DocSearch-AskAi-Return"
title={backToKeywordSearchButtonText}
aria-label={backToKeywordSearchButtonAriaLabel}
onClick={handleAskAiBackClick}
>
<BackIcon />
</button>
) : undefined;
const inputOverlay = heading ? <ModalHeading heading={heading} shimmer={isAskAiStreaming} /> : null;
const actionsBeforeClose = (
<>
<form
className="DocSearch-Form"
onSubmit={(event) => {
event.preventDefault();
}}
onReset={onReset}
>
{props.isAskAiActive ? (
<>
<button
type="button"
tabIndex={0}
className="DocSearch-Action DocSearch-AskAi-Return"
title={backToKeywordSearchButtonText}
aria-label={backToKeywordSearchButtonAriaLabel}
onClick={handleAskAiBackClick}
>
<BackIcon />
</button>
</>
) : (
<>
{isKeywordSearchLoading && (
<div className="DocSearch-LoadingIndicator">
<LoadingIcon />
</div>
)}
{!isKeywordSearchLoading && (
<label className="DocSearch-MagnifierLabel" {...props.getLabelProps()}>
<SearchIcon />
<span className="DocSearch-VisuallyHiddenForAccessibility">{searchInputLabel}</span>
</label>
)}
</>
)}
{heading && <ModalHeading heading={heading} shimmer={isAskAiStreaming} />}
<input
className="DocSearch-Input"
ref={props.inputRef}
{...inputProps}
placeholder={searchPlaceholder}
hidden={Boolean(heading)}
/>
<div className="DocSearch-Actions">
<button
className="DocSearch-Clear"
type="reset"
aria-label={clearButtonAriaLabel}
hidden={!props.state.query}
tabIndex={props.state.query ? 0 : -1}
aria-hidden={!props.state.query ? 'true' : 'false'}
>
{clearButtonTitle}
</button>
{props.state.query && <div className="DocSearch-Divider" />}
{isAskAiStreaming && (
<>
<button
type="button"
className="DocSearch-Action DocSearch-StopStreaming"
onClick={props.onStopAskAiStreaming}
>
<StopIcon />
</button>
<div className="DocSearch-Divider" />
</>
)}
{renderMoreOptions && (
<>
<Menu>
<Menu.Trigger className="DocSearch-Action">
<MoreVerticalIcon />
</Menu.Trigger>
<Menu.Content>
<Menu.Item onClick={props.onNewConversation}>
<NewConversationIcon />
{startNewConversationText}
</Menu.Item>
{hasRecentConversations && (
<Menu.Item onClick={props.onViewConversationHistory}>
<ConversationHistoryIcon />
{viewConversationHistoryText}
</Menu.Item>
)}
</Menu.Content>
</Menu>
<div className="DocSearch-Divider" />
</>
)}
{isAskAiStreaming && (
<>
<button
type="button"
title={closeButtonText}
className="DocSearch-Action DocSearch-Close"
aria-label={closeButtonAriaLabel}
onClick={props.onClose}
className="DocSearch-Action DocSearch-StopStreaming"
onClick={props.onStopAskAiStreaming}
>
<CloseIcon />
<StopIcon />
</button>
</div>
</form>
<div className="DocSearch-Divider" />
</>
)}
{renderMoreOptions && (
<>
<Menu>
<Menu.Trigger className="DocSearch-Action">
<MoreVerticalIcon />
</Menu.Trigger>
<Menu.Content>
<Menu.Item onClick={props.onNewConversation}>
<NewConversationIcon />
{startNewConversationText}
</Menu.Item>
{hasRecentConversations && (
<Menu.Item onClick={props.onViewConversationHistory}>
<ConversationHistoryIcon />
{viewConversationHistoryText}
</Menu.Item>
)}
</Menu.Content>
</Menu>
<div className="DocSearch-Divider" />
</>
)}
</>
);
return (
<SearchBoxForm
{...props}
placeholder={searchPlaceholder}
clearButtonTitle={clearButtonTitle}
clearButtonAriaLabel={clearButtonAriaLabel}
closeButtonText={closeButtonText}
closeButtonAriaLabel={closeButtonAriaLabel}
searchInputLabel={searchInputLabel}
leadingElement={leadingElement}
inputOverlay={inputOverlay}
hideInput={Boolean(heading)}
actionsBeforeClose={actionsBeforeClose}
inputProps={inputProps}
/>
);
}

View file

@ -0,0 +1,25 @@
import React, { type JSX } from 'react';
import type { AskAiScreenStateProps } from '../AskAiScreenState';
import type { InternalDocSearchHit } from '../types';
import type { RecentConversationsResultsTranslations } from './ui/RecentConversationsResults';
import { RecentConversationsResults } from './ui/RecentConversationsResults';
import type { StoredSearchesSectionsTranslations } from './ui/StoredSearchesSections';
import { StoredSearchesSections } from './ui/StoredSearchesSections';
export type AskAiStartScreenTranslations = RecentConversationsResultsTranslations & StoredSearchesSectionsTranslations;
type AskAiStartScreenProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
hasCollections: boolean;
translations?: AskAiStartScreenTranslations;
};
export function AskAiStartScreen({ translations = {}, ...props }: AskAiStartScreenProps): JSX.Element | null {
return (
<div className="DocSearch-Dropdown-Container">
<StoredSearchesSections {...props} translations={translations} />
<RecentConversationsResults {...props} translations={translations} />
</div>
);
}

View file

@ -0,0 +1,51 @@
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
import React, { type JSX, type RefObject } from 'react';
import type { InternalDocSearchHit } from '../types';
import { SearchBoxForm } from './ui/SearchBoxForm';
export type KeywordSearchBoxTranslations = Partial<{
clearButtonTitle: string;
clearButtonAriaLabel: string;
closeButtonText: string;
closeButtonAriaLabel: string;
placeholderText: string;
enterKeyHint: string;
searchInputLabel: string;
}>;
interface KeywordSearchBoxProps
extends AutocompleteApi<InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
state: AutocompleteState<InternalDocSearchHit>;
autoFocus: boolean;
inputRef: RefObject<HTMLInputElement | null>;
onClose: () => void;
placeholder: string;
isFromSelection: boolean;
translations?: KeywordSearchBoxTranslations;
}
export function KeywordSearchBox({ translations = {}, ...props }: KeywordSearchBoxProps): JSX.Element {
const {
clearButtonTitle = 'Clear',
clearButtonAriaLabel = 'Clear the query',
closeButtonText = 'Close',
closeButtonAriaLabel = 'Close',
searchInputLabel = 'Search',
} = translations;
return (
<SearchBoxForm
{...props}
clearButtonTitle={clearButtonTitle}
clearButtonAriaLabel={clearButtonAriaLabel}
closeButtonText={closeButtonText}
closeButtonAriaLabel={closeButtonAriaLabel}
searchInputLabel={searchInputLabel}
inputProps={{
enterKeyHint: 'search',
}}
/>
);
}

View file

@ -0,0 +1,22 @@
import React, { type JSX } from 'react';
import type { ScreenStateProps } from '../ScreenState';
import type { InternalDocSearchHit } from '../types';
import type { StoredSearchesSectionsTranslations } from './ui/StoredSearchesSections';
import { StoredSearchesSections } from './ui/StoredSearchesSections';
export type KeywordStartScreenTranslations = StoredSearchesSectionsTranslations;
type KeywordStartScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
hasCollections: boolean;
translations?: KeywordStartScreenTranslations;
};
export function KeywordStartScreen({ translations = {}, ...props }: KeywordStartScreenProps): JSX.Element | null {
return (
<div className="DocSearch-Dropdown-Container">
<StoredSearchesSections {...props} translations={translations} />
</div>
);
}

View file

@ -0,0 +1,73 @@
import type { AutocompleteApi } from '@algolia/autocomplete-core';
import React, { type JSX } from 'react';
import type { DocSearchState, InternalDocSearchHit } from '../../types';
export type ModalShellProps = {
state: DocSearchState<InternalDocSearchHit>;
containerRef: React.RefObject<HTMLDivElement | null>;
modalRef: React.RefObject<HTMLDivElement | null>;
formElementRef: React.RefObject<HTMLDivElement | null>;
dropdownRef: React.RefObject<HTMLDivElement | null>;
getRootProps: AutocompleteApi<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>['getRootProps'];
onClose: () => void;
showDropdown: boolean;
searchBox: React.ReactNode;
screenState: React.ReactNode;
footer: React.ReactNode;
};
export function ModalShell({
state,
containerRef,
modalRef,
formElementRef,
dropdownRef,
getRootProps,
onClose,
showDropdown,
searchBox,
screenState,
footer,
}: ModalShellProps): JSX.Element {
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}
</header>
{showDropdown && (
<div className="DocSearch-Dropdown" ref={dropdownRef}>
{screenState}
</div>
)}
<footer className="DocSearch-Footer">{footer}</footer>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import React, { type JSX } from 'react';
import type { AskAiScreenStateProps } from '../../AskAiScreenState';
import { CloseIcon, SparklesIcon } from '../../icons';
import { Results } from '../../Results';
import type { InternalDocSearchHit } from '../../types';
export type RecentConversationsResultsTranslations = Partial<{
recentConversationsTitle: string;
removeRecentConversationButtonTitle: string;
}>;
type RecentConversationsResultsProps = Omit<AskAiScreenStateProps<InternalDocSearchHit>, 'translations'> & {
translations?: RecentConversationsResultsTranslations;
};
export function RecentConversationsResults({
translations = {},
...props
}: RecentConversationsResultsProps): JSX.Element {
const {
recentConversationsTitle = 'Recent conversations',
removeRecentConversationButtonTitle = 'Remove this conversation from history',
} = translations;
return (
<Results
{...props}
title={recentConversationsTitle}
collection={props.state.collections[2]}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<SparklesIcon />
</div>
)}
renderAction={({ item }) => (
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={removeRecentConversationButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.conversations.remove(item);
props.refresh();
}}
>
<CloseIcon />
</button>
</div>
)}
/>
);
}

View file

@ -0,0 +1,130 @@
import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core';
import React, { type JSX, type RefObject } from 'react';
import { MAX_QUERY_SIZE } from '../../constants';
import { CloseIcon, LoadingIcon, SearchIcon } from '../../icons';
import type { InternalDocSearchHit } from '../../types';
interface SearchBoxFormProps
extends AutocompleteApi<InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
state: AutocompleteState<InternalDocSearchHit>;
autoFocus: boolean;
inputRef: RefObject<HTMLInputElement | null>;
isFromSelection: boolean;
placeholder: string;
onClose: () => void;
clearButtonTitle: string;
clearButtonAriaLabel: string;
closeButtonText: string;
closeButtonAriaLabel: string;
searchInputLabel: string;
inputProps?: Partial<React.InputHTMLAttributes<HTMLInputElement>>;
leadingElement?: React.ReactNode;
inputOverlay?: React.ReactNode;
actionsBeforeClose?: React.ReactNode;
hideInput?: boolean;
}
export function SearchBoxForm({
actionsBeforeClose,
autoFocus,
clearButtonAriaLabel,
clearButtonTitle,
closeButtonAriaLabel,
closeButtonText,
hideInput,
inputOverlay,
inputProps,
inputRef,
isFromSelection,
leadingElement,
onClose,
placeholder,
searchInputLabel,
state,
...autocomplete
}: SearchBoxFormProps): JSX.Element {
const { onReset } = autocomplete.getFormProps({
inputElement: inputRef.current,
});
React.useEffect(() => {
if (autoFocus && inputRef.current) {
inputRef.current.focus();
}
}, [autoFocus, inputRef]);
React.useEffect(() => {
if (isFromSelection && inputRef.current) {
inputRef.current.select();
}
}, [isFromSelection, inputRef]);
const baseInputProps = autocomplete.getInputProps({
inputElement: inputRef.current!,
autoFocus,
maxLength: MAX_QUERY_SIZE,
});
const isKeywordSearchLoading = state.status === 'stalled';
return (
<form
className="DocSearch-Form"
onSubmit={(event) => {
event.preventDefault();
}}
onReset={onReset}
>
{leadingElement ||
(isKeywordSearchLoading ? (
<div className="DocSearch-LoadingIndicator">
<LoadingIcon />
</div>
) : (
<label className="DocSearch-MagnifierLabel" {...autocomplete.getLabelProps()}>
<SearchIcon />
<span className="DocSearch-VisuallyHiddenForAccessibility">{searchInputLabel}</span>
</label>
))}
{inputOverlay}
<input
className="DocSearch-Input"
ref={inputRef}
{...baseInputProps}
{...inputProps}
placeholder={placeholder}
hidden={hideInput}
/>
<div className="DocSearch-Actions">
<button
className="DocSearch-Clear"
type="reset"
aria-label={clearButtonAriaLabel}
hidden={!state.query}
tabIndex={state.query ? 0 : -1}
aria-hidden={!state.query ? 'true' : 'false'}
>
{clearButtonTitle}
</button>
{state.query && <div className="DocSearch-Divider" />}
{actionsBeforeClose}
<button
type="button"
title={closeButtonText}
className="DocSearch-Action DocSearch-Close"
aria-label={closeButtonAriaLabel}
onClick={onClose}
>
<CloseIcon />
</button>
</div>
</form>
);
}

View file

@ -1,43 +1,39 @@
import React, { type JSX } from 'react';
import { RecentIcon, CloseIcon, StarIcon, SparklesIcon } from './icons';
import { Results } from './Results';
import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types';
import { CloseIcon, RecentIcon, StarIcon } from '../../icons';
import { Results } from '../../Results';
import type { ScreenStateProps } from '../../ScreenState';
import type { InternalDocSearchHit } from '../../types';
export type StartScreenTranslations = Partial<{
export type StoredSearchesSectionsTranslations = Partial<{
recentSearchesTitle: string;
noRecentSearchesText: string;
saveRecentSearchButtonTitle: string;
removeRecentSearchButtonTitle: string;
favoriteSearchesTitle: string;
removeFavoriteSearchButtonTitle: string;
recentConversationsTitle: string;
removeRecentConversationButtonTitle: string;
}>;
type StartScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
hasCollections: boolean;
translations?: StartScreenTranslations;
type StoredSearchesSectionsProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
translations?: StoredSearchesSectionsTranslations;
};
export function StartScreen({ translations = {}, ...props }: StartScreenProps): JSX.Element | null {
export function StoredSearchesSections({ translations = {}, ...props }: StoredSearchesSectionsProps): JSX.Element {
const {
recentSearchesTitle = 'Recent',
saveRecentSearchButtonTitle = 'Save this search',
removeRecentSearchButtonTitle = 'Remove this search from history',
favoriteSearchesTitle = 'Favorite',
removeFavoriteSearchButtonTitle = 'Remove this search from favorites',
recentConversationsTitle = 'Recent conversations',
removeRecentConversationButtonTitle = 'Remove this conversation from history',
} = translations;
const recentSearchesCollection = props.state.collections[0];
return (
<div className="DocSearch-Dropdown-Container">
<>
<Results
{...props}
title={recentSearchesTitle}
collection={props.state.collections[0]}
collection={recentSearchesCollection}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<RecentIcon />
@ -107,34 +103,6 @@ export function StartScreen({ translations = {}, ...props }: StartScreenProps):
</div>
)}
/>
<Results
{...props}
title={recentConversationsTitle}
collection={props.state.collections[2]}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<SparklesIcon />
</div>
)}
renderAction={({ item }) => (
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={removeRecentConversationButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.conversations.remove(item);
props.refresh();
}}
>
<CloseIcon />
</button>
</div>
)}
/>
</div>
</>
);
}

View file

@ -0,0 +1,27 @@
import type { AlgoliaInsightsHit } from '@algolia/autocomplete-core';
import React from 'react';
import type { DocSearchState, InternalDocSearchHit } from '../types';
export function useSendItemClickEvent(
state: DocSearchState<InternalDocSearchHit>,
): (item: InternalDocSearchHit) => void {
return React.useCallback(
(item: InternalDocSearchHit): void => {
if (!state.context.algoliaInsightsPlugin || !item.__autocomplete_id) {
return;
}
const insightsItem = item as AlgoliaInsightsHit;
state.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch({
eventName: 'Item Selected',
index: insightsItem.__autocomplete_indexName,
items: [insightsItem],
positions: [item.__autocomplete_id],
queryID: insightsItem.__autocomplete_queryID,
});
},
[state.context.algoliaInsightsPlugin],
);
}

View file

@ -0,0 +1,63 @@
import { useTheme } from '@docsearch/core/useTheme';
import React from 'react';
import type { DocSearchTheme } from '../types';
import { manageLocalStorageQuota } from '../utils/storage';
export function useDocSearchModalEffects({
initialScrollY,
modalRef,
snippetLength,
theme,
}: {
initialScrollY: number;
modalRef: React.RefObject<HTMLDivElement | null>;
snippetLength: React.MutableRefObject<number>;
theme?: DocSearchTheme;
}): void {
useTheme({ theme });
React.useEffect(() => {
document.body.classList.add('DocSearch--active');
return (): void => {
document.body.classList.remove('DocSearch--active');
window.scrollTo?.(0, initialScrollY);
};
}, [initialScrollY]);
React.useEffect(() => {
manageLocalStorageQuota();
}, []);
React.useLayoutEffect(() => {
const scrollBarWidth = window.innerWidth - document.body.clientWidth;
document.body.style.marginInlineEnd = `${scrollBarWidth}px`;
return (): void => {
document.body.style.marginInlineEnd = '0px';
};
}, []);
React.useEffect(() => {
if (window.matchMedia('(max-width: 768px)').matches) {
snippetLength.current = 5;
}
}, [snippetLength]);
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);
};
}, [modalRef]);
}

View file

@ -0,0 +1,15 @@
import React from 'react';
import { MAX_QUERY_SIZE } from '../constants';
export function useInitialModalQuery(initialQueryFromProp: string): {
initialQuery: string;
initialQueryFromSelection: string;
} {
const initialQueryFromSelection = React.useRef(
typeof window !== 'undefined' ? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE) : '',
).current;
const initialQuery = React.useRef(initialQueryFromProp || initialQueryFromSelection).current;
return { initialQuery, initialQueryFromSelection };
}

View file

@ -0,0 +1,44 @@
import type { AutocompleteApi } from '@algolia/autocomplete-core';
import type React from 'react';
import type { DocSearchTheme, InternalDocSearchHit } from '../types';
import { useTouchEvents } from '../useTouchEvents';
import { useTrapFocus } from '../useTrapFocus';
import { useDocSearchModalEffects } from './useDocSearchModalEffects';
export function useModalEnvironment({
getEnvironmentProps,
containerRef,
dropdownRef,
formElementRef,
inputRef,
initialScrollY,
modalRef,
snippetLength,
theme,
}: {
getEnvironmentProps: AutocompleteApi<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>['getEnvironmentProps'];
containerRef: React.RefObject<HTMLDivElement | null>;
dropdownRef: React.RefObject<HTMLDivElement | null>;
formElementRef: React.RefObject<HTMLDivElement | null>;
inputRef: React.RefObject<HTMLInputElement | null>;
initialScrollY: number;
modalRef: React.RefObject<HTMLDivElement | null>;
snippetLength: React.MutableRefObject<number>;
theme?: DocSearchTheme;
}): void {
useTouchEvents({
getEnvironmentProps,
panelElement: dropdownRef.current,
formElement: formElementRef.current,
inputElement: inputRef.current,
});
useTrapFocus({ container: containerRef.current });
useDocSearchModalEffects({ initialScrollY, modalRef, snippetLength, theme });
}

View file

@ -0,0 +1,26 @@
import React from 'react';
export function useModalRefs(): {
containerRef: React.RefObject<HTMLDivElement | null>;
modalRef: React.RefObject<HTMLDivElement | null>;
formElementRef: React.RefObject<HTMLDivElement | null>;
dropdownRef: React.RefObject<HTMLDivElement | null>;
inputRef: React.RefObject<HTMLInputElement | null>;
snippetLength: React.MutableRefObject<number>;
} {
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);
return {
containerRef,
modalRef,
formElementRef,
dropdownRef,
inputRef,
snippetLength,
};
}

View file

@ -0,0 +1,21 @@
import React from 'react';
export function useRefreshOnInitialQuery({
initialQuery,
inputRef,
refresh,
}: {
initialQuery: string;
inputRef: React.RefObject<HTMLInputElement | null>;
refresh: () => void;
}): void {
React.useEffect(() => {
if (initialQuery.length > 0) {
refresh();
if (inputRef.current) {
inputRef.current.focus();
}
}
}, [initialQuery, inputRef, refresh]);
}

View file

@ -0,0 +1,41 @@
import React from 'react';
import type { StoredSearchPlugin } from '../stored-searches';
import type { InternalDocSearchHit, StoredDocSearchHit } from '../types';
function createSyntheticParent(item: InternalDocSearchHit): InternalDocSearchHit {
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',
content: null,
};
}
export function useSaveRecentSearch({
favoriteSearches,
recentSearches,
disableUserPersonalization,
}: {
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
disableUserPersonalization: boolean;
}): (item: InternalDocSearchHit) => void {
return React.useCallback(
function saveRecentSearch(item: InternalDocSearchHit): void {
if (disableUserPersonalization) {
return;
}
const search = item.type === 'content' ? item.__docsearch_parent || createSyntheticParent(item) : item;
if (search && favoriteSearches.getAll().findIndex((x) => x.objectID === search.objectID) === -1) {
recentSearches.add(search);
}
},
[favoriteSearches, recentSearches, disableUserPersonalization],
);
}

View file

@ -0,0 +1,33 @@
import React from 'react';
import { createStoredSearches } from '../stored-searches';
import type { StoredDocSearchHit } from '../types';
export function useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
recentSearchesWithFavoritesLimit,
}: {
defaultIndexName: string;
recentSearchesLimit: number;
recentSearchesWithFavoritesLimit: number;
}): {
favoriteSearches: ReturnType<typeof createStoredSearches<StoredDocSearchHit>>;
recentSearches: ReturnType<typeof createStoredSearches<StoredDocSearchHit>>;
} {
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;
return { favoriteSearches, recentSearches };
}

View file

@ -1,4 +1,6 @@
export * from './DocSearch';
export * from './DocSearchAI';
export * from './DocSearchAskAiModal';
export * from './DocSearchButton';
export * from './DocSearchModal';
export * from './useDocSearchKeyboardEvents';

View file

@ -0,0 +1,98 @@
import type { AutocompleteSource } from '@algolia/autocomplete-core';
import type { InternalDocSearchHit } from '../types';
import type { AIMessage } from '../types/AskiAi';
export function buildRecentConversationSources({
conversations,
disableUserPersonalization,
setMessages,
onAskAiToggle,
}: {
conversations: { getAll: () => unknown[] };
disableUserPersonalization: boolean;
setMessages: (messages: AIMessage[]) => void;
onAskAiToggle: (toggle: boolean) => void;
}): Array<AutocompleteSource<InternalDocSearchHit & { messages?: AIMessage[] }>> {
return [
{
sourceId: 'recentConversations',
getItems(): InternalDocSearchHit[] {
if (disableUserPersonalization) {
return [];
}
return conversations.getAll() as unknown as InternalDocSearchHit[];
},
onSelect({ item }): void {
if (item.messages) {
setMessages(item.messages);
onAskAiToggle(true);
}
},
},
];
}
export function buildAskAiActionSources({
query,
handleSelectAskAiQuestion,
}: {
query: string;
handleSelectAskAiQuestion: (toggle: boolean, query: string) => void;
}): Array<AutocompleteSource<InternalDocSearchHit>> {
const emptyHierarchyHighlightResult = {
lvl0: { value: '', matchLevel: 'none', matchedWords: [] },
lvl1: { value: '', matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
} satisfies InternalDocSearchHit['_highlightResult']['hierarchy'];
const emptyHighlightResult: InternalDocSearchHit['_highlightResult'] = {
content: { value: '', matchLevel: 'none', matchedWords: [] },
hierarchy: emptyHierarchyHighlightResult,
hierarchy_camel: [],
};
const emptySnippetResult: InternalDocSearchHit['_snippetResult'] = {
content: { value: '', matchLevel: 'none' },
hierarchy: emptyHierarchyHighlightResult,
hierarchy_camel: [],
};
return [
{
sourceId: 'askAI',
getItems(): InternalDocSearchHit[] {
return [
{
type: 'askAI',
query,
url_without_anchor: '',
objectID: 'ask-ai-button',
content: null,
url: '',
anchor: null,
hierarchy: {
lvl0: 'Ask AI',
lvl1: query,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
_highlightResult: emptyHighlightResult,
_snippetResult: emptySnippetResult,
__docsearch_parent: null,
},
];
},
onSelect({ item }): void {
if (item.type === 'askAI' && item.query) {
handleSelectAskAiQuestion(true, item.query);
}
},
},
];
}

View file

@ -0,0 +1,218 @@
import type { AutocompleteSource, AutocompleteState } from '@algolia/autocomplete-core';
import type { SearchResponse } from 'algoliasearch/lite';
import type React from 'react';
import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
import type { DocSearchHit, DocSearchState, InternalDocSearchHit } from '../types';
import type { useSearchClient } from '../useSearchClient';
import { groupBy } from './groupBy';
import { identity } from './identity';
import { isModifierEvent } from './isModifierEvent';
import { removeHighlightTags } from './removeHighlightTags';
export type BuildQuerySourcesState = Pick<AutocompleteState<InternalDocSearchHit>, 'context'>;
export type StoredSearchesLike<TItem> = {
getAll: () => TItem[];
};
export function buildNoQuerySources({
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
}: {
recentSearches: StoredSearchesLike<unknown>;
favoriteSearches: StoredSearchesLike<unknown>;
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
disableUserPersonalization: boolean;
}): Array<AutocompleteSource<InternalDocSearchHit>> {
if (disableUserPersonalization) {
return [];
}
return [
{
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[];
},
},
];
}
export async function buildQuerySources({
query,
state: sourcesState,
setContext,
setStatus,
searchClient,
indexes,
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: indexes.map((index) => {
const indexName = index.name;
const searchParams = 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);
if ((sourcesState.context.searchSuggestions as unknown[]).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) => ({
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) {
if ((error as Error).name === 'RetryError') {
setStatus('error');
}
throw error;
}
}

View file

@ -0,0 +1,34 @@
import type { SearchParamsObject } from 'algoliasearch/lite';
import type { DocSearchIndex } from '../DocSearch';
export function normalizeDocSearchIndexes({
indexName,
indices = [],
searchParameters,
}: {
indexName?: string;
indices?: Array<DocSearchIndex | string>;
searchParameters?: SearchParamsObject;
}): DocSearchIndex[] {
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');
}
return indexes;
}

View file

@ -25,6 +25,8 @@ export default defineConfig([
{
entry: {
index: 'src/index.ts',
DocSearchAI: 'src/DocSearchAI.tsx',
DocSearchAskAiModal: 'src/DocSearchAskAiModal.tsx',
DocSearchButton: 'src/DocSearchButton.tsx',
DocSearchModal: 'src/DocSearchModal.tsx',
useDocSearchKeyboardEvents: 'src/useDocSearchKeyboardEvents.ts',

View file

@ -4,6 +4,10 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./scripts/setupTests.ts'],
include: ['packages/**/src/**/*.test.ts', 'tests/**/*.test.ts'],
}
include: [
'packages/**/src/**/*.test.ts',
'packages/**/src/**/*.test.tsx',
'tests/**/*.test.ts',
],
},
});