1
0
Fork 0

feat: Modal and AskAI UX tweaks (#2765)

* feat: UX tweaks for Modal

* Prepare new conversation screen to accept suggested questions

* Add conversation history screen

* Reset basic askai config

* Reset askai api url

* Document new translations

* Remove unused CSS

* Add translation for stopped streaming text

* Add rest of translations

* Bump allowed bundle size

* Bump allowed bundle size
This commit is contained in:
Paul Jankowski 2025-09-23 11:44:23 -04:00 committed by GitHub
parent 2ff6353cb2
commit 0267f55453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 597 additions and 47 deletions

View file

@ -6,11 +6,11 @@
},
{
"path": "packages/docsearch-react/dist/umd/index.js",
"maxSize": "109 kB"
"maxSize": "115 kB"
},
{
"path": "packages/docsearch-js/dist/umd/index.js",
"maxSize": "123 kB"
"maxSize": "128 kB"
}
]
}

View file

@ -81,7 +81,8 @@
width: 100%;
}
.DocSearch-Input {
.DocSearch-Input,
.DocSearch-Modal-heading {
appearance: none;
background: transparent;
border: 0;
@ -119,7 +120,7 @@
align-items: center;
justify-content: flex-end;
padding: 0 2px;
gap: 16px;
gap: 8px;
}
.DocSearch-Divider {
@ -127,11 +128,20 @@
border-left: 1px solid var(--docsearch-subtle-color);
}
.DocSearch-Action {
min-height: 24px;
min-width: 24px;
align-items: center;
color: var(--docsearch-highlight-color);
display: flex;
justify-content: center;
margin: 0;
padding: 0;
}
.DocSearch-LoadingIndicator,
.DocSearch-MagnifierLabel,
.DocSearch-AskAi-Return,
.DocSearch-Close,
.DocSearch-StreamingIndicator {
.DocSearch-AskAi-Return {
margin: 0;
padding: 0;
}
@ -141,9 +151,7 @@
}
.DocSearch-MagnifierLabel,
.DocSearch-AskAi-Return,
.DocSearch-Close,
.DocSearch-StreamingIndicator {
.DocSearch-AskAi-Return {
align-items: center;
color: var(--docsearch-highlight-color);
display: flex;
@ -158,8 +166,7 @@
}
@media screen and (prefers-reduced-motion: reduce) {
.DocSearch-Close,
.DocSearch-StreamingIndicator {
.DocSearch-Action {
animation: none;
appearance: none;
background: none;
@ -172,8 +179,7 @@
}
}
.DocSearch-Close,
.DocSearch-StreamingIndicator,
.DocSearch-Action,
.DocSearch-AskAi-Return {
animation: fade-in 0.1s ease-in forwards;
appearance: none;
@ -189,12 +195,12 @@
.DocSearch-Close[hidden],
.DocSearch-StreamingIndicator[hidden],
.DocSearch-AskAi-Return[hidden] {
.DocSearch-AskAi-Return[hidden],
.DocSearch-Input[hidden] {
display: none;
}
.DocSearch-Close:hover,
.DocSearch-StreamingIndicator:hover,
.DocSearch-Action:hover,
.DocSearch-AskAi-Return:hover {
color: var(--docsearch-highlight-color);
background: var(--docsearch-soft-primary-color);
@ -460,7 +466,11 @@
.DocSearch-Hit[aria-selected='true'] a,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit--AskAI {
background-color: var(--docsearch-hit-highlight-color) !important;
background-color: var(--docsearch-hit-highlight-color);
}
.DocSearch-Conversation-History .DocSearch-Hit[aria-selected='true'] a {
background-color: var(--docsearch-hit-background);
}
.DocSearch-Hit mark {
@ -1268,6 +1278,12 @@ assistive tech users */
color: var(--docsearch-highlight-color);
}
.DocSearck-AskAiScreen-MessageContent-Stopped {
color: var(--docsearch-muted-color);
font-style: italic;
margin-top: 1em;
}
.DocSearch-AskAiScreen-SmallerLoadingIcon {
width: 16px;
height: 16px;
@ -1441,3 +1457,79 @@ assistive tech users */
.DocSearch-Markdown-Content--streaming .DocSearch-CodeSnippet-CopyButton {
display: none;
}
/* NewConversationScreen */
.DocSearch-NewConversationScreen {
padding: 3em var(--docsearch-spacing);
}
.DocSearch-NewConversationScreen-Title {
font-weight: 600;
font-size: 26px;
color: var(--docsearch-text-color);
margin-bottom: 0.15em;
}
.DocSearch-NewConversationScreen-Description {
font-size: 14px;
color: var(--docsearch-muted-color);
}
.DocSearch-NewConversationScreen-SuggestedQuestions {
display: flex;
flex-direction: column;
gap: var(--docsearch-spacing);
align-items: start;
margin-top: 1.5em;
}
.DocSearch-NewConversationScreen-SuggestedQuestion {
border-radius: var(--docsearch-border-radius);
padding: 12px;
border: 1px solid #D6D6E7;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: #fff;
color: var(--docsearch-text-color);
}
.DocSearch-Menu {
position: relative;
}
.DocSearch-Menu-content {
display: none;
position: absolute;
right: 0;
padding: 8px 0;
flex-direction: column;
border-radius: var(--docsearch-border-radius);
background-color: #fff;
box-shadow: 0px 0px 0px 1px #21243D0D, 0px 8px 16px -4px #21243D40;
min-width: 195px;
top: calc(100% + 12px);
z-index: 422;
}
.DocSearch-Menu-content.open {
display: flex;
}
.DocSearch-Menu-item {
padding: 10px 16px;
display: flex;
align-items: center;
color: var(--docsearch-text-color);
cursor: pointer;
font-size: 14px;
gap: 8px;
white-space: nowrap;
border: 0;
background-color: transparent;
}
.DocSearch-Menu-item:hover {
background-color: #F5F5FA;
}

View file

@ -44,6 +44,10 @@ export type AskAiScreenTranslations = Partial<{
lastSeparator?: string;
after?: string;
};
/**
* Message that's shown when user has stopped the streaming of a message.
*/
stoppedStreamingText: string;
}>;
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
@ -91,7 +95,7 @@ function AskAiExchangeCard({
}: AskAiExchangeCardProps): JSX.Element {
const { userMessage, assistantMessage } = exchange;
const showActions = !isLastExchange || (isLastExchange && loadingStatus === 'ready' && Boolean(assistantMessage));
const { stoppedStreamingText = 'You stopped this response' } = translations;
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
@ -102,6 +106,11 @@ function AskAiExchangeCard({
return groupConsecutiveToolResults(assistantMessage?.parts || []);
}, [assistantMessage]);
const wasStopped = userMessage.metadata?.stopped || assistantMessage?.metadata?.stopped;
const showActions =
!wasStopped && (!isLastExchange || (isLastExchange && loadingStatus === 'ready' && Boolean(assistantMessage)));
const isThinking =
['submitted', 'streaming'].includes(loadingStatus) &&
isLastExchange &&
@ -228,6 +237,8 @@ function AskAiExchangeCard({
return null;
})}
</div>
{wasStopped && <p className="DocSearck-AskAiScreen-MessageContent-Stopped">{stoppedStreamingText}</p>}
</div>
<div className="DocSearch-AskAiScreen-Answer-Footer">
<AskAiScreenFooterActions

View file

@ -0,0 +1,58 @@
import type { JSX } from 'react';
import React from 'react';
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'> & {
translations?: ResultsScreenTranslations;
};
export function ConversationHistoryScreen({ onAskAiToggle, ...props }: ConversationHistoryScreenProps): JSX.Element {
const collection = React.useMemo(() => props.state.collections[2], [props.state]);
React.useEffect(() => {
if (!collection || collection.items.length === 0) {
onAskAiToggle(true);
}
}, [collection, onAskAiToggle]);
return (
<div className="DocSearch-Dropdown-Container DocSearch-Conversation-History">
<Results
{...props}
key={collection.source.sourceId}
title=""
translations={props.translations}
collection={collection}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<SparklesIcon />
</div>
)}
renderAction={({ item, runDeleteTransition }) => (
<div className="DocSearch-Hit-action">
<button
type="button"
className="DocSearch-Hit-action-button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
runDeleteTransition(() => {
props.conversations.remove(item);
props.refresh();
});
}}
>
<CloseIcon />
</button>
</div>
)}
/>
</div>
);
}

View file

@ -12,6 +12,7 @@ import type {
KeyboardShortcuts,
StoredDocSearchHit,
} from './types';
import type { AskAiState } from './types/AskiAi';
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
import { useTheme } from './useTheme';
@ -171,7 +172,8 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
const searchButtonRef = React.useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const [initialQuery, setInitialQuery] = React.useState<string | undefined>(props?.initialQuery || undefined);
const [isAskAiActive, setIsAskAiActive] = React.useState(false);
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const isAskAiActive = askAiState !== 'initial';
let currentPlaceholder =
props?.translations?.modal?.searchBox?.placeholderText || props?.placeholder || 'Search docs';
@ -189,9 +191,9 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
const onAskAiToggle = React.useCallback(
(askAitoggle: boolean) => {
setIsAskAiActive(askAitoggle);
setAskAiState(askAitoggle ? 'conversation' : 'initial');
},
[setIsAskAiActive],
[setAskAiState],
);
const onOpen = React.useCallback(() => {
@ -202,9 +204,9 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
setIsOpen(false);
setInitialQuery(props?.initialQuery);
if (isAskAiActive) {
setIsAskAiActive(false);
setAskAiState('initial');
}
}, [setIsOpen, props.initialQuery, isAskAiActive, setIsAskAiActive]);
}, [setIsOpen, props.initialQuery, isAskAiActive, setAskAiState]);
const onInput = React.useCallback(
(event: KeyboardEvent) => {
@ -245,6 +247,8 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
translations={props?.translations?.modal}
isAskAiActive={isAskAiActive}
canHandleAskAi={canHandleAskAi}
askAiState={askAiState}
setAskAiState={setAskAiState}
onAskAiToggle={onAskAiToggle}
onClose={onClose}
/>,

View file

@ -15,13 +15,14 @@ import type { DocSearchIndex, DocSearchProps } from './DocSearch';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import type { NewConversationTranslations } from './NewConversationScreen';
import type { ScreenStateTranslations } from './ScreenState';
import { ScreenState } from './ScreenState';
import type { SearchBoxTranslations } from './SearchBox';
import { SearchBox } from './SearchBox';
import { createStoredConversations, createStoredSearches } from './stored-searches';
import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types';
import type { AIMessage } from './types/AskiAi';
import type { AIMessage, AskAiState } from './types/AskiAi';
import { useSearchClient } from './useSearchClient';
import { useTheme } from './useTheme';
import { useTouchEvents } from './useTouchEvents';
@ -32,6 +33,7 @@ import { manageLocalStorageQuota } from './utils/storage';
export type ModalTranslations = Partial<{
searchBox: SearchBoxTranslations;
newConversation: NewConversationTranslations;
footer: FooterTranslations;
}> &
ScreenStateTranslations;
@ -43,6 +45,8 @@ export type DocSearchModalProps = DocSearchProps & {
isAskAiActive?: boolean;
canHandleAskAi?: boolean;
translations?: ModalTranslations;
askAiState: AskAiState;
setAskAiState: (state: AskAiState) => void;
};
/**
@ -298,6 +302,8 @@ export function DocSearchModal({
indices = [],
indexName,
searchParameters,
askAiState,
setAskAiState,
}: DocSearchModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
@ -370,6 +376,7 @@ export function DocSearchModal({
).current;
const [askAiStreamError, setAskAiStreamError] = React.useState<Error | null>(null);
const [stoppedStream, setStoppedStream] = React.useState(false);
const {
messages,
@ -377,6 +384,7 @@ export function DocSearchModal({
status,
setMessages,
error: askAiFetchError,
stop: stopAskAiStreaming,
} = useChat<AIMessage>({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
transport: new DefaultChatTransport({
@ -418,6 +426,13 @@ export function DocSearchModal({
}
// 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));
@ -425,7 +440,7 @@ export function DocSearchModal({
}
}
prevStatus.current = status;
}, [status, messages, conversations, disableUserPersonalization]);
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
const createSyntheticParent = React.useCallback(function createSyntheticParent(
item: InternalDocSearchHit,
@ -495,6 +510,7 @@ export function DocSearchModal({
const handleAskAiToggle = React.useCallback(
(toggle: boolean, query: string) => {
onAskAiToggle(toggle);
setStoppedStream(false);
sendMessage({
role: 'user',
parts: [
@ -759,6 +775,21 @@ export function DocSearchModal({
}
}, [isAskAiActive, autocomplete, setMessages]);
const onStopAskAiStreaming = async (): Promise<void> => {
setStoppedStream(true);
await stopAskAiStreaming();
};
const handleNewConversation = (): void => {
setMessages([]);
setAskAiState('new-conversation');
};
const handleViewConversationHistory = (): void => {
setAskAiState('conversation-history');
};
// hide the dropdown on idle and no collections
let showDocsearchDropdown = true;
const hasCollections = state.collections.some((collection) => collection.items.length > 0);
@ -798,11 +829,15 @@ export function DocSearchModal({
translations={searchBoxTranslations}
isAskAiActive={isAskAiActive}
askAiStatus={status}
askAiState={askAiState}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {
handleAskAiToggle(true, query);
}}
onStopAskAiStreaming={onStopAskAiStreaming}
onNewConversation={handleNewConversation}
onViewConversationHistory={handleViewConversationHistory}
/>
</header>
@ -828,6 +863,8 @@ export function DocSearchModal({
askAiFetchError={askAiFetchError}
status={status}
hasCollections={hasCollections}
askAiState={askAiState}
selectAskAiQuestion={handleAskAiToggle}
onAskAiToggle={onAskAiToggle}
onItemClick={(item, event) => {
// if the item is askAI toggle the screen

View file

@ -0,0 +1,82 @@
import type { PropsWithChildren, JSX, ButtonHTMLAttributes } from 'react';
import React from 'react';
const MenuContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
});
export function Menu({ children }: PropsWithChildren): JSX.Element {
const [open, setOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function close(e): void {
if (!menuRef.current?.contains(e.target)) {
setOpen(false);
}
}
if (open) {
window.addEventListener('click', close);
}
return function removeListener(): void {
window.removeEventListener('click', close);
};
}, [open]);
return (
<MenuContext.Provider value={{ open, setOpen }}>
<div ref={menuRef} className="DocSearch-Menu">
{children}
</div>
</MenuContext.Provider>
);
}
type MenuTriggerProps = ButtonHTMLAttributes<HTMLButtonElement>;
function MenuTrigger({ children, className = '' }: PropsWithChildren<MenuTriggerProps>): JSX.Element {
const { open, setOpen } = React.useContext(MenuContext);
function toggleOpen(): void {
setOpen(!open);
}
return (
<button type="button" className={`DocSearch-Menu-trigger ${className}`} onClick={() => toggleOpen()}>
{children}
</button>
);
}
Menu.Trigger = MenuTrigger;
function MenuContent({ children }: PropsWithChildren): JSX.Element {
const { open } = React.useContext(MenuContext);
return <div className={`DocSearch-Menu-content${open ? ' open' : ''}`}>{children}</div>;
}
Menu.Content = MenuContent;
type MenuItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
function MenuItem({ children, className = '', onClick, ...props }: PropsWithChildren<MenuItemProps>): JSX.Element {
const { setOpen } = React.useContext(MenuContext);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e): void => {
if (onClick) {
onClick(e);
setOpen(false);
}
};
return (
<button type="button" className={`DocSearch-Menu-item ${className}`} onClick={handleClick} {...props}>
{children}
</button>
);
}
Menu.Item = MenuItem;

View file

@ -0,0 +1,11 @@
import React from 'react';
import type { JSX } from 'react';
type ModalHeadingProps = {
heading: string;
shimmer?: boolean;
};
export function ModalHeading({ heading, shimmer = false }: ModalHeadingProps): JSX.Element {
return <span className={`DocSearch-Modal-heading${shimmer ? ' shimmer' : ''}`}>{heading}</span>;
}

View file

@ -0,0 +1,46 @@
import type { Hit } from 'algoliasearch/lite';
import React, { type JSX } from 'react';
export type NewConversationTranslations = Partial<{
newConversationTitle: string;
newConversationDescription: string;
}>;
type SuggestedQuestion = Hit<{ question: string }>;
interface NewConversationProps {
suggestedQuestions?: SuggestedQuestion[];
selectSuggestedQuestion: (query: string) => void;
translations?: NewConversationTranslations;
}
export function NewConversationScreen({
translations = {},
suggestedQuestions = [],
selectSuggestedQuestion,
}: NewConversationProps): JSX.Element {
const {
newConversationTitle = 'How can I help you today?',
newConversationDescription = 'I search through your documentation to help you find setup guides, feature details and troubleshooting tips, fast.',
} = translations;
return (
<div className="DocSearch-NewConversationScreen">
<h3 className="DocSearch-NewConversationScreen-Title">{newConversationTitle}</h3>
<p className="DocSearch-NewConversationScreen-Description">{newConversationDescription}</p>
<div className="DocSearch-NewConversationScreen-SuggestedQuestions">
{suggestedQuestions.map((question) => (
<button
key={question.objectID}
type="button"
className="DocSearch-NewConversationScreen-SuggestedQuestion"
onClick={() => selectSuggestedQuestion(question.question)}
>
{question.question}
</button>
))}
</div>
</div>
);
}

View file

@ -8,6 +8,7 @@ import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
export type ResultsTranslations = Partial<{
askAiPlaceholder: string;
noResultsAskAiPlaceholder: string;
}>;
interface ResultsProps<TItem extends BaseItem>
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
@ -22,6 +23,7 @@ interface ResultsProps<TItem extends BaseItem>
}) => React.ReactNode;
onItemClick: (item: TItem, event: KeyboardEvent | MouseEvent) => void;
hitComponent: DocSearchProps['hitComponent'];
state: AutocompleteState<TItem>;
}
export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TItem>): JSX.Element | null {
@ -165,6 +167,7 @@ function Result<TItem extends StoredDocSearchHit>({
interface AskAiButtonProps<TItem extends BaseItem> extends ResultsProps<TItem> {
item: TItem;
translations?: ResultsTranslations;
state: AutocompleteState<TItem>;
}
function AskAiButton<TItem extends StoredDocSearchHit>({
@ -173,8 +176,13 @@ function AskAiButton<TItem extends StoredDocSearchHit>({
onItemClick,
translations,
collection,
...props
}: AskAiButtonProps<TItem>): JSX.Element {
const { askAiPlaceholder = 'Ask AI: ' } = translations || {};
const { askAiPlaceholder = 'Ask AI: ', noResultsAskAiPlaceholder = "Didn't find it in the docs? Ask AI to help: " } =
translations || {};
const noKeywordResults = props.state.collections.length === 1;
const placeholder = noKeywordResults ? noResultsAskAiPlaceholder : askAiPlaceholder;
return (
<li
@ -193,7 +201,7 @@ function AskAiButton<TItem extends StoredDocSearchHit>({
<SparklesIcon />
</div>
<div className="DocSearch-Hit-AskAIButton-title">
<span className="DocSearch-Hit-AskAIButton-title-highlight">{askAiPlaceholder}</span>
<span className="DocSearch-Hit-AskAIButton-title-highlight">{placeholder}</span>
<mark className="DocSearch-Hit-AskAIButton-title-query">{item.query || ''}</mark>
</div>
</div>

View file

@ -8,6 +8,7 @@ import { removeHighlightTags } from './utils';
export type ResultsScreenTranslations = Partial<{
askAiPlaceholder: string;
noResultsAskAiPlaceholder: string;
}>;
type ResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {

View file

@ -4,9 +4,12 @@ import React from 'react';
import type { AskAiScreenTranslations } from './AskAiScreen';
import { AskAiScreen } from './AskAiScreen';
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';
@ -15,7 +18,7 @@ import type { StartScreenTranslations } from './StartScreen';
import { StartScreen } from './StartScreen';
import type { StoredSearchPlugin } from './stored-searches';
import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types';
import type { AIMessage } from './types/AskiAi';
import type { AIMessage, AskAiState } from './types/AskiAi';
export type ScreenStateTranslations = Partial<{
errorScreen: ErrorScreenTranslations;
@ -23,6 +26,7 @@ export type ScreenStateTranslations = Partial<{
noResultsScreen: NoResultsScreenTranslations;
resultsScreen: ResultsScreenTranslations;
askAiScreen: AskAiScreenTranslations;
newConversation: NewConversationTranslations;
}>;
export interface ScreenStateProps<TItem extends BaseItem>
@ -48,10 +52,27 @@ export interface ScreenStateProps<TItem extends BaseItem>
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
hasCollections: boolean;
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
askAiState: AskAiState;
selectAskAiQuestion: (toggle: boolean, query: string) => void;
}
export const ScreenState = React.memo(
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
if (props.canHandleAskAi && props.askAiState === 'conversation-history') {
return <ConversationHistoryScreen {...props} />;
}
if (props.canHandleAskAi && props.askAiState === 'new-conversation') {
return (
<NewConversationScreen
translations={translations?.newConversation}
selectSuggestedQuestion={(query: string) => {
props.selectAskAiQuestion(true, query);
}}
/>
);
}
if (props.isAskAiActive && props.canHandleAskAi) {
return (
<AskAiScreen

View file

@ -3,10 +3,20 @@ import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-c
import React, { type JSX, type RefObject } from 'react';
import { MAX_QUERY_SIZE } from './constants';
import { LoadingIcon, CloseIcon, SearchIcon, SparklesIcon } from './icons';
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 } from './types/AskiAi';
import type { AIMessage, AskAiState } from './types/AskiAi';
export type SearchBoxTranslations = Partial<{
clearButtonTitle: string;
@ -21,6 +31,10 @@ export type SearchBoxTranslations = Partial<{
searchInputLabel: string;
backToKeywordSearchButtonText: string;
backToKeywordSearchButtonAriaLabel: string;
newConversationPlaceholder: string;
conversationHistoryTitle: string;
startNewConversationText: string;
viewConversationHistoryText: string;
}>;
interface SearchBoxProps
@ -31,14 +45,18 @@ interface SearchBoxProps
onClose: () => void;
onAskAiToggle: (toggle: boolean) => void;
onAskAgain: (query: string) => void;
onStopAskAiStreaming: () => Promise<void>;
placeholder: string;
isAskAiActive: boolean;
askAiStatus: UseChatHelpers<AIMessage>['status'];
isFromSelection: boolean;
translations?: SearchBoxTranslations;
askAiState: AskAiState;
onNewConversation: () => void;
onViewConversationHistory: () => void;
}
export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.Element {
export function SearchBox({ translations = {}, askAiState, onAskAiToggle, ...props }: SearchBoxProps): JSX.Element {
const {
clearButtonTitle = 'Clear',
clearButtonAriaLabel = 'Clear the query',
@ -48,6 +66,10 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
backToKeywordSearchButtonText = 'Back to keyword search',
backToKeywordSearchButtonAriaLabel = 'Back to keyword search',
placeholderTextAskAiStreaming = 'Answering...',
newConversationPlaceholder = 'Ask a question',
conversationHistoryTitle = 'My conversation history',
startNewConversationText = 'Start a new conversation',
viewConversationHistoryText = 'Conversation history',
} = translations;
const { onReset } = props.getFormProps({
inputElement: props.inputRef.current,
@ -65,6 +87,16 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
}
}, [props.isFromSelection, props.inputRef]);
const hasRecentConversations = React.useMemo(() => {
const askAiSource = props.state.collections[2];
if (!askAiSource) {
return false;
}
return askAiSource.items.length > 0;
}, [props.state.collections]);
const baseInputProps = props.getInputProps({
inputElement: props.inputRef.current!,
autoFocus: props.autoFocus,
@ -76,6 +108,22 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
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';
let searchPlaceholder = props.placeholder;
if (askAiState === 'new-conversation') {
searchPlaceholder = newConversationPlaceholder;
}
let heading: string | null = null;
if (isAskAiStreaming) {
heading = placeholderTextAskAiStreaming;
}
if (askAiState === 'conversation-history') {
heading = conversationHistoryTitle;
}
// when returning to another status than streaming or submitted, we focus on the input
React.useEffect(() => {
@ -122,6 +170,15 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
disabled: isAskAiStreaming,
};
const handleAskAiBackClick = React.useCallback((): void => {
if (askAiState === 'conversation-history') {
onAskAiToggle(true);
return;
}
onAskAiToggle(false);
}, [askAiState, onAskAiToggle]);
return (
<>
<form
@ -136,10 +193,10 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
<button
type="button"
tabIndex={0}
className="DocSearch-AskAi-Return"
className="DocSearch-Action DocSearch-AskAi-Return"
title={backToKeywordSearchButtonText}
aria-label={backToKeywordSearchButtonAriaLabel}
onClick={() => props.onAskAiToggle(false)}
onClick={handleAskAiBackClick}
>
<BackIcon />
</button>
@ -160,20 +217,17 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
</>
)}
{heading && <ModalHeading heading={heading} shimmer={isAskAiStreaming} />}
<input
className="DocSearch-Input"
ref={props.inputRef}
{...inputProps}
placeholder={isAskAiStreaming ? placeholderTextAskAiStreaming : props.placeholder}
placeholder={searchPlaceholder}
hidden={Boolean(heading)}
/>
<div className="DocSearch-Actions">
{isAskAiStreaming && (
<button type="button" className="DocSearch-StreamingIndicator" onClick={() => props.onAskAiToggle(true)}>
<SparklesIcon />
</button>
)}
<button
className="DocSearch-Clear"
type="reset"
@ -185,12 +239,50 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
{clearButtonTitle}
</button>
{(isAskAiStreaming || props.state.query) && <div className="DocSearch-Divider" />}
{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" />
</>
)}
<button
type="button"
title={closeButtonText}
className="DocSearch-Close"
className="DocSearch-Action DocSearch-Close"
aria-label={closeButtonAriaLabel}
onClick={props.onClose}
>

View file

@ -295,13 +295,13 @@ describe('api', () => {
});
await act(async () => {
fireEvent.click(await screen.findByText(/Ask AI:/));
fireEvent.click(await screen.findByText(/Ask AI to help:/));
});
expect(document.querySelector('.DocSearch-AskAiScreen')).toBeInTheDocument();
// could be "Answering..." or "Ask another question..."
expect(screen.getByPlaceholderText('Answering...')).toBeInTheDocument();
expect(screen.getByText('Answering...')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,12 @@
import React, { type JSX } from 'react';
export function ConversationHistoryIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M9 0.875C10.6068 0.875 12.1776 1.35149 13.5137 2.24414C14.8498 3.13693 15.8919 4.40598 16.5068 5.89062C17.1218 7.37522 17.2822 9.00892 16.9688 10.585C16.6552 12.1611 15.8814 13.6088 14.7451 14.7451C13.6088 15.8814 12.1611 16.6552 10.585 16.9688C9.00892 17.2822 7.37523 17.1218 5.89062 16.5068C4.40598 15.8919 3.13693 14.8498 2.24414 13.5137C1.35149 12.1776 0.875 10.6068 0.875 9C0.875 8.65482 1.15482 8.375 1.5 8.375C1.84518 8.375 2.125 8.65482 2.125 9C2.125 10.3596 2.52792 11.6888 3.2832 12.8193C4.03864 13.9499 5.1129 14.8312 6.36914 15.3516C7.62523 15.8718 9.00736 16.0083 10.3408 15.7432C11.6744 15.4779 12.8998 14.8228 13.8613 13.8613C14.8228 12.8998 15.4779 11.6744 15.7432 10.3408C16.0083 9.00736 15.8718 7.62523 15.3516 6.36914C14.8312 5.1129 13.9499 4.03864 12.8193 3.2832C11.6888 2.52792 10.3596 2.125 9 2.125C7.06829 2.125 5.21604 2.89096 3.82129 4.22949L3.00879 5.04199H5.66699C6.01202 5.04217 6.29199 5.32192 6.29199 5.66699C6.29182 6.01191 6.01191 6.29182 5.66699 6.29199H1.5C1.45939 6.29199 1.41889 6.28716 1.37891 6.2793C1.36148 6.27587 1.345 6.26949 1.32812 6.26465C1.267 6.24714 1.20782 6.22205 1.15332 6.18555C1.08536 6.14005 1.02604 6.08256 0.980469 6.01465C0.970244 5.99942 0.962841 5.98268 0.954102 5.9668C0.904937 5.87756 0.875056 5.77606 0.875 5.66699V1.5C0.875 1.15482 1.15482 0.875002 1.5 0.875C1.84518 0.875 2.125 1.15482 2.125 1.5V4.15723L2.94141 3.3418L2.9502 3.33301C4.57158 1.77433 6.73388 0.875 9 0.875ZM9.33301 4.20801C9.67808 4.20801 9.95783 4.48798 9.95801 4.83301V9.44727L12.9463 10.9414C13.2548 11.0958 13.3798 11.4707 13.2256 11.7793C13.0712 12.088 12.6955 12.213 12.3867 12.0586L9.05371 10.3926C8.84198 10.2867 8.70801 10.0697 8.70801 9.83301V4.83301C8.70818 4.48809 8.98809 4.20819 9.33301 4.20801Z"
fill="currentcolor"
/>
</svg>
);
}

View file

@ -0,0 +1,14 @@
import React, { type JSX } from 'react';
export function MoreVerticalIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={4} height={16} viewBox="0 0 4 16" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.99984 1.95898C1.88478 1.95898 1.7915 2.05226 1.7915 2.16732C1.7915 2.28238 1.88478 2.37565 1.99984 2.37565C2.1149 2.37565 2.20817 2.28238 2.20817 2.16732C2.20817 2.05226 2.1149 1.95898 1.99984 1.95898ZM0.541504 2.16732C0.541504 1.3619 1.19442 0.708984 1.99984 0.708984C2.80525 0.708984 3.45817 1.3619 3.45817 2.16732C3.45817 2.97273 2.80525 3.62565 1.99984 3.62565C1.19442 3.62565 0.541504 2.97273 0.541504 2.16732ZM1.99984 7.79232C1.88478 7.79232 1.7915 7.88559 1.7915 8.00065C1.7915 8.11571 1.88478 8.20898 1.99984 8.20898C2.1149 8.20898 2.20817 8.11571 2.20817 8.00065C2.20817 7.88559 2.1149 7.79232 1.99984 7.79232ZM0.541504 8.00065C0.541504 7.19524 1.19442 6.54232 1.99984 6.54232C2.80525 6.54232 3.45817 7.19524 3.45817 8.00065C3.45817 8.80607 2.80525 9.45898 1.99984 9.45898C1.19442 9.45898 0.541504 8.80607 0.541504 8.00065ZM1.99984 13.6257C1.88478 13.6257 1.7915 13.7189 1.7915 13.834C1.7915 13.949 1.88478 14.0423 1.99984 14.0423C2.1149 14.0423 2.20817 13.949 2.20817 13.834C2.20817 13.7189 2.1149 13.6257 1.99984 13.6257ZM0.541504 13.834C0.541504 13.0286 1.19442 12.3757 1.99984 12.3757C2.80525 12.3757 3.45817 13.0286 3.45817 13.834C3.45817 14.6394 2.80525 15.2923 1.99984 15.2923C1.19442 15.2923 0.541504 14.6394 0.541504 13.834Z"
fill="currentcolor"
/>
</svg>
);
}

View file

@ -0,0 +1,21 @@
import type { JSX } from 'react';
import React from 'react';
export function NewConversationIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 19 19" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.712717 3.38039C1.14249 2.95062 1.72538 2.70917 2.33317 2.70917H8.1665C8.51168 2.70917 8.7915 2.989 8.7915 3.33417C8.7915 3.67935 8.51168 3.95917 8.1665 3.95917H2.33317C2.0569 3.95917 1.79195 4.06892 1.5966 4.26427C1.40125 4.45962 1.2915 4.72457 1.2915 5.00084V16.6675C1.2915 16.9438 1.40125 17.2087 1.5966 17.4041C1.79195 17.5994 2.0569 17.7092 2.33317 17.7092H13.9998C14.2761 17.7092 14.5411 17.5994 14.7364 17.4041C14.9318 17.2087 15.0415 16.9438 15.0415 16.6675V10.8342C15.0415 10.489 15.3213 10.2092 15.6665 10.2092C16.0117 10.2092 16.2915 10.489 16.2915 10.8342V16.6675C16.2915 17.2753 16.0501 17.8582 15.6203 18.288C15.1905 18.7177 14.6076 18.9592 13.9998 18.9592H2.33317C1.72538 18.9592 1.14249 18.7177 0.712717 18.288C0.282947 17.8582 0.0415039 17.2753 0.0415039 16.6675V5.00084C0.0415039 4.39305 0.282947 3.81016 0.712717 3.38039Z"
fill="currentcolor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.6665 2.19141C15.3634 2.19141 15.0728 2.3118 14.8584 2.52611L7.06419 10.3204L6.52548 12.4752L8.68031 11.9365L16.4746 4.14223C16.6889 3.92792 16.8093 3.63725 16.8093 3.33417C16.8093 3.03109 16.6889 2.74043 16.4746 2.52611C16.2603 2.3118 15.9696 2.19141 15.6665 2.19141ZM13.9746 1.64223C14.4233 1.1935 15.0319 0.941406 15.6665 0.941406C16.3011 0.941406 16.9097 1.1935 17.3584 1.64223C17.8072 2.09096 18.0593 2.69957 18.0593 3.33417C18.0593 3.96877 17.8072 4.57738 17.3584 5.02611L9.44178 12.9428C9.36168 13.0229 9.26132 13.0797 9.15142 13.1072L5.81809 13.9405C5.6051 13.9938 5.3798 13.9314 5.22456 13.7761C5.06932 13.6209 5.00692 13.3956 5.06016 13.1826L5.8935 9.84925C5.92097 9.73936 5.9778 9.639 6.0579 9.5589L13.9746 1.64223Z"
fill="currentcolor"
/>
</svg>
);
}

View file

@ -0,0 +1,14 @@
import React, { type JSX } from 'react';
export function StopIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={18} height={18} viewBox="0 0 18 18" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.99984 1.29102C4.74264 1.29102 1.2915 4.74215 1.2915 8.99935C1.2915 13.2565 4.74264 16.7077 8.99984 16.7077C13.257 16.7077 16.7082 13.2565 16.7082 8.99935C16.7082 4.74215 13.257 1.29102 8.99984 1.29102ZM0.0415039 8.99935C0.0415039 4.0518 4.05229 0.0410156 8.99984 0.0410156C13.9474 0.0410156 17.9582 4.0518 17.9582 8.99935C17.9582 13.9469 13.9474 17.9577 8.99984 17.9577C4.05229 17.9577 0.0415039 13.9469 0.0415039 8.99935ZM5.87484 6.49935C5.87484 6.15417 6.15466 5.87435 6.49984 5.87435H11.4998C11.845 5.87435 12.1248 6.15417 12.1248 6.49935V11.4993C12.1248 11.8445 11.845 12.1243 11.4998 12.1243H6.49984C6.15466 12.1243 5.87484 11.8445 5.87484 11.4993V6.49935ZM7.12484 7.12435V10.8743H10.8748V7.12435H7.12484Z"
fill="currentcolor"
/>
</svg>
);
}

View file

@ -8,5 +8,9 @@ export * from './AlertIcon';
export * from './SelectIcon';
export * from './SourceIcon';
export * from './StarIcon';
export * from './StopIcon';
export * from './ErrorIcon';
export * from './NoResultsIcon';
export * from './MoreVerticalIcon';
export * from './ConversationHistoryIcon';
export * from './NewConversationIcon';

View file

@ -12,6 +12,7 @@ export type StoredSearchPlugin<TItem> = {
getAll: () => TItem[];
addFeedback?: (messageId: string, feedback: 'dislike' | 'like') => void;
getOne?: (messageId: string) => StoredAskAiMessage | undefined;
getConversation?: (messageId: string) => TItem | undefined;
};
export function createStoredSearches<TItem extends StoredDocSearchHit>({
@ -94,5 +95,12 @@ export function createStoredConversations<TItem extends StoredAskAiState>({
storage.setItem(items);
},
getConversation(messageId: string): TItem | undefined {
const conv = items.find((c) => c.messages?.some((m) => m.id === messageId));
if (!conv || !conv.messages) return undefined;
return conv;
},
};
}

View file

@ -1,6 +1,8 @@
import type { UIMessage } from '@ai-sdk/react';
import type { UIDataTypes, UIMessagePart } from 'ai';
export type AskAiState = 'conversation-history' | 'conversation' | 'initial' | 'new-conversation';
export interface SearchIndexTool {
input: {
query: string;
@ -12,7 +14,7 @@ export interface SearchIndexTool {
}
export type AIMessage = UIMessage<
unknown,
{ stopped?: boolean },
UIDataTypes,
{
searchIndex: SearchIndexTool;

View file

@ -8,6 +8,7 @@ export type StoredAskAiMessage = AIMessage & {
feedback?: 'dislike' | 'like';
};
export type StoredAskAiState = Omit<DocSearchHit, '_highlightResult' | '_snippetResult'> & {
export type StoredAskAiState = StoredDocSearchHit & {
stopped?: boolean;
messages?: StoredAskAiMessage[];
};

View file

@ -350,6 +350,10 @@ const translations: DocSearchTranslations = {
searchInputLabel: 'Search',
backToKeywordSearchButtonText: 'Back to keyword search',
backToKeywordSearchButtonAriaLabel: 'Back to keyword search',
newConversationPlaceholder: 'Ask a question',
conversationHistoryTitle: 'My conversation history',
startNewConversationText: 'Start a new conversation',
viewConversationHistoryText: 'Conversation history'
},
startScreen: {
recentSearchesTitle: 'Recent',
@ -374,6 +378,7 @@ const translations: DocSearchTranslations = {
},
resultsScreen: {
askAiPlaceholder: 'Ask AI: ',
noResultsAskAiPlaceholder: 'Didn't find it in the docs? Ask AI to help: ',
},
askAiScreen: {
disclaimerText:
@ -392,6 +397,8 @@ const translations: DocSearchTranslations = {
// If provided, these override the default rendering of aggregated tool calls:
aggregatedToolCallNode: undefined, // (queries: string[], onSearchQueryClick: (query: string) => void) => React.ReactNode
aggregatedToolCallText: undefined, // (queries: string[]) => { before?: string; separator?: string; lastSeparator?: string; after?: string }
// Text to show when user has stopped streaming a message
stoppedStreamingText: 'You stopped this response',
},
footer: {
selectText: 'Select',
@ -405,6 +412,10 @@ const translations: DocSearchTranslations = {
closeKeyAriaLabel: 'Escape key',
poweredByText: 'Powered by',
},
newConversation: {
newConversationTitle: 'How can I help you today?',
newConversationDescription: 'I search through your documentation to help you find setup guides, feature details and troubleshooting tips, fast.'
}
},
};
```