From 0267f554532a754a776f8125d16949ba2d4ff327 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <33367713+8bittitan@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:44:23 -0400 Subject: [PATCH] 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 --- bundlesize.config.json | 4 +- packages/docsearch-css/src/modal.css | 124 +++++++++++++++--- packages/docsearch-react/src/AskAiScreen.tsx | 13 +- .../src/ConversationHistoryScreen.tsx | 58 ++++++++ packages/docsearch-react/src/DocSearch.tsx | 14 +- .../docsearch-react/src/DocSearchModal.tsx | 41 +++++- packages/docsearch-react/src/Menu.tsx | 82 ++++++++++++ packages/docsearch-react/src/ModalHeading.tsx | 11 ++ .../src/NewConversationScreen.tsx | 46 +++++++ packages/docsearch-react/src/Results.tsx | 12 +- .../docsearch-react/src/ResultsScreen.tsx | 1 + packages/docsearch-react/src/ScreenState.tsx | 23 +++- packages/docsearch-react/src/SearchBox.tsx | 120 +++++++++++++++-- .../src/__tests__/api.test.tsx | 4 +- .../src/icons/ConversationHistoryIcon.tsx | 12 ++ .../src/icons/MoreVerticalIcon.tsx | 14 ++ .../src/icons/NewConversationIcon.tsx | 21 +++ .../docsearch-react/src/icons/StopIcon.tsx | 14 ++ packages/docsearch-react/src/icons/index.ts | 4 + .../docsearch-react/src/stored-searches.ts | 8 ++ packages/docsearch-react/src/types/AskiAi.ts | 4 +- .../src/types/StoredDocSearchHit.ts | 3 +- packages/website/docs/api.mdx | 11 ++ 23 files changed, 597 insertions(+), 47 deletions(-) create mode 100644 packages/docsearch-react/src/ConversationHistoryScreen.tsx create mode 100644 packages/docsearch-react/src/Menu.tsx create mode 100644 packages/docsearch-react/src/ModalHeading.tsx create mode 100644 packages/docsearch-react/src/NewConversationScreen.tsx create mode 100644 packages/docsearch-react/src/icons/ConversationHistoryIcon.tsx create mode 100644 packages/docsearch-react/src/icons/MoreVerticalIcon.tsx create mode 100644 packages/docsearch-react/src/icons/NewConversationIcon.tsx create mode 100644 packages/docsearch-react/src/icons/StopIcon.tsx diff --git a/bundlesize.config.json b/bundlesize.config.json index 08bdf615..8c516ec1 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -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" } ] } diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 8e57082e..f699611f 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -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; +} diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index dec79714..19d54299 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -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, '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; })} + + {wasStopped &&

{stoppedStreamingText}

}
, '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 ( +
+ ( +
+ +
+ )} + renderAction={({ item, runDeleteTransition }) => ( +
+ +
+ )} + /> +
+ ); +} diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index 4c6d1b6f..72bd4ed6 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -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(null); const [isOpen, setIsOpen] = React.useState(false); const [initialQuery, setInitialQuery] = React.useState(props?.initialQuery || undefined); - const [isAskAiActive, setIsAskAiActive] = React.useState(false); + const [askAiState, setAskAiState] = React.useState('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} />, diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index 8c267bc3..a0ffe51c 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -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>({ @@ -370,6 +376,7 @@ export function DocSearchModal({ ).current; const [askAiStreamError, setAskAiStreamError] = React.useState(null); + const [stoppedStream, setStoppedStream] = React.useState(false); const { messages, @@ -377,6 +384,7 @@ export function DocSearchModal({ status, setMessages, error: askAiFetchError, + stop: stopAskAiStreaming, } = useChat({ 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 => { + 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} /> @@ -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 diff --git a/packages/docsearch-react/src/Menu.tsx b/packages/docsearch-react/src/Menu.tsx new file mode 100644 index 00000000..2a25db88 --- /dev/null +++ b/packages/docsearch-react/src/Menu.tsx @@ -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(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 ( + +
+ {children} +
+
+ ); +} + +type MenuTriggerProps = ButtonHTMLAttributes; + +function MenuTrigger({ children, className = '' }: PropsWithChildren): JSX.Element { + const { open, setOpen } = React.useContext(MenuContext); + + function toggleOpen(): void { + setOpen(!open); + } + + return ( + + ); +} + +Menu.Trigger = MenuTrigger; + +function MenuContent({ children }: PropsWithChildren): JSX.Element { + const { open } = React.useContext(MenuContext); + + return
{children}
; +} + +Menu.Content = MenuContent; + +type MenuItemProps = ButtonHTMLAttributes; + +function MenuItem({ children, className = '', onClick, ...props }: PropsWithChildren): JSX.Element { + const { setOpen } = React.useContext(MenuContext); + + const handleClick: React.MouseEventHandler = (e): void => { + if (onClick) { + onClick(e); + setOpen(false); + } + }; + return ( + + ); +} + +Menu.Item = MenuItem; diff --git a/packages/docsearch-react/src/ModalHeading.tsx b/packages/docsearch-react/src/ModalHeading.tsx new file mode 100644 index 00000000..caf2cec2 --- /dev/null +++ b/packages/docsearch-react/src/ModalHeading.tsx @@ -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 {heading}; +} diff --git a/packages/docsearch-react/src/NewConversationScreen.tsx b/packages/docsearch-react/src/NewConversationScreen.tsx new file mode 100644 index 00000000..bf26b8dd --- /dev/null +++ b/packages/docsearch-react/src/NewConversationScreen.tsx @@ -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 ( +
+

{newConversationTitle}

+

{newConversationDescription}

+ +
+ {suggestedQuestions.map((question) => ( + + ))} +
+
+ ); +} diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index 3d7c64b8..1ceb3c58 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -8,6 +8,7 @@ import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; export type ResultsTranslations = Partial<{ askAiPlaceholder: string; + noResultsAskAiPlaceholder: string; }>; interface ResultsProps extends AutocompleteApi { @@ -22,6 +23,7 @@ interface ResultsProps }) => React.ReactNode; onItemClick: (item: TItem, event: KeyboardEvent | MouseEvent) => void; hitComponent: DocSearchProps['hitComponent']; + state: AutocompleteState; } export function Results(props: ResultsProps): JSX.Element | null { @@ -165,6 +167,7 @@ function Result({ interface AskAiButtonProps extends ResultsProps { item: TItem; translations?: ResultsTranslations; + state: AutocompleteState; } function AskAiButton({ @@ -173,8 +176,13 @@ function AskAiButton({ onItemClick, translations, collection, + ...props }: AskAiButtonProps): 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 (
  • ({
  • - {askAiPlaceholder} + {placeholder} {item.query || ''}
    diff --git a/packages/docsearch-react/src/ResultsScreen.tsx b/packages/docsearch-react/src/ResultsScreen.tsx index b16ff6bc..5a6bbd5a 100644 --- a/packages/docsearch-react/src/ResultsScreen.tsx +++ b/packages/docsearch-react/src/ResultsScreen.tsx @@ -8,6 +8,7 @@ import { removeHighlightTags } from './utils'; export type ResultsScreenTranslations = Partial<{ askAiPlaceholder: string; + noResultsAskAiPlaceholder: string; }>; type ResultsScreenProps = Omit, 'translations'> & { diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index 0669e17a..4f659506 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -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 @@ -48,10 +52,27 @@ export interface ScreenStateProps getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl']; hasCollections: boolean; onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise; + askAiState: AskAiState; + selectAskAiQuestion: (toggle: boolean, query: string) => void; } export const ScreenState = React.memo( ({ translations = {}, ...props }: ScreenStateProps) => { + if (props.canHandleAskAi && props.askAiState === 'conversation-history') { + return ; + } + + if (props.canHandleAskAi && props.askAiState === 'new-conversation') { + return ( + { + props.selectAskAiQuestion(true, query); + }} + /> + ); + } + if (props.isAskAiActive && props.canHandleAskAi) { return ( ; interface SearchBoxProps @@ -31,14 +45,18 @@ interface SearchBoxProps onClose: () => void; onAskAiToggle: (toggle: boolean) => void; onAskAgain: (query: string) => void; + onStopAskAiStreaming: () => Promise; placeholder: string; isAskAiActive: boolean; askAiStatus: UseChatHelpers['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 ( <>
    props.onAskAiToggle(false)} + onClick={handleAskAiBackClick} > @@ -160,20 +217,17 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX. )} + {heading && } +
    - {isAskAiStreaming && ( - - )} - + +
    + + )} + + {renderMoreOptions && ( + <> + + + + + + + + {startNewConversationText} + + {hasRecentConversations && ( + + + {viewConversationHistoryText} + + )} + + + +
    + + )}