import React, { useState, useRef, RefObject, useEffect, SetStateAction, useMemo } from 'react' import cx from 'classnames' import { useRouter } from 'next/router' import { ActionList, IconButton, Overlay, Spinner, Stack, TextInput, Banner } from '@primer/react' import { SearchIcon, XCircleFillIcon, CommentIcon, CopilotIcon, FileIcon, ArrowRightIcon, ArrowLeftIcon, } from '@primer/octicons-react' import { focusTrap } from '@primer/behaviors' import { useTranslation } from '@/languages/components/useTranslation' import { useVersion } from '@/versions/components/useVersion' import { AI_SEARCH_CONTEXT, executeGeneralSearch, GENERAL_SEARCH_CONTEXT, } from '../helpers/execute-search-actions' import { useCombinedSearchResults } from '@/search/components/hooks/useAISearchAutocomplete' import { AskAIResults } from './AskAIResults' import { sendEvent, uuidv4 } from '@/events/components/events' import { EventType } from '@/events/types' import { ASK_AI_EVENT_GROUP, SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups' import { useSharedUIContext } from '@/frame/components/context/SharedUIContext' import type { AIReference } from '../types' import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types' import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query' import styles from './SearchOverlay.module.scss' type Props = { searchOverlayOpen: boolean parentRef: RefObject debug: boolean onClose: () => void params: { 'search-overlay-input': string 'search-overlay-ask-ai': string } updateParams: ( updates: Partial<{ 'search-overlay-input': string 'search-overlay-ask-ai': string }>, ) => void } // Upon clicking the SearchInput component this overlay will be displayed export function SearchOverlay({ searchOverlayOpen, parentRef, debug, onClose, params, updateParams, }: Props) { const { t } = useTranslation('search') const { currentVersion } = useVersion() const router = useRouter() // Map props from multi query state const urlSearchInputQuery = params['search-overlay-input'] const isAskAIState = params['search-overlay-ask-ai'] === 'true' const inputRef = useRef(null) const suggestionsListHeightRef = useRef(null) // We need an array of refs to the list elements so we can focus them when the user uses the arrow keys const listElementsRef = React.useRef>([]) const [selectedIndex, setSelectedIndex] = useState(-1) const [aiQuery, setAIQuery] = useState(urlSearchInputQuery) const [aiSearchError, setAISearchError] = useState(false) const [aiReferences, setAIReferences] = useState([] as AIReference[]) const [aiCouldNotAnswer, setAICouldNotAnswer] = useState(false) const [showSpinner, setShowSpinner] = useState(false) const [scrollPos, setScrollPos] = useState(0) const [announcement, setAnnouncement] = useState('') const { hasOpenHeaderNotifications } = useSharedUIContext() // Group all events between open / close of the overlay together const searchEventGroupId = useRef('') const overlayRef = useRef(null) useEffect(() => { if (searchOverlayOpen && overlayRef.current) { focusTrap(overlayRef.current, inputRef.current || undefined) } }, [searchOverlayOpen]) useEffect(() => { searchEventGroupId.current = uuidv4() }, [searchOverlayOpen]) // Group all events within an "Ask AI" session together const askAIEventGroupId = useRef('') // When there is a notification above the header, we need to adjust the top position of the overlay to account for it useEffect(() => { if (hasOpenHeaderNotifications) { const handleScroll = () => { setScrollPos(window.scrollY) } window.addEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll) } }, [hasOpenHeaderNotifications]) const overlayTopValue = scrollPos > 72 ? '0px' : `${88 - scrollPos}px !important` const { autoCompleteOptions, searchLoading, setSearchLoading, searchError: autoCompleteSearchError, updateAutocompleteResults, clearAutocompleteResults, } = useCombinedSearchResults({ router, currentVersion, debug, }) const { aiAutocompleteOptions, generalSearchResults, totalGeneralSearchResults } = autoCompleteOptions // Whenever "searchLoading" changes, decide whether to show the spinner after 1s. useEffect(() => { let timer: ReturnType if (autoCompleteSearchError) { return setShowSpinner(false) } // If it's the initial fetch, show the spinner immediately if (!aiAutocompleteOptions.length && !generalSearchResults.length && searchLoading) { return setShowSpinner(true) } if (searchLoading) { timer = setTimeout(() => setShowSpinner(true), 1000) } else { setShowSpinner(false) } return () => { clearTimeout(timer) } }, [ searchLoading, aiAutocompleteOptions.length, generalSearchResults.length, autoCompleteSearchError, ]) // Filter out any options that match the local query and replace them with a custom user query option that include isUserQuery: true const filteredAIOptions = aiAutocompleteOptions.filter( (option) => option.term !== urlSearchInputQuery, ) // Create new arrays that prepend the user input const userInputOptions = urlSearchInputQuery.trim() !== '' ? [ { term: urlSearchInputQuery, title: urlSearchInputQuery, highlights: [], isUserQuery: true, }, ] : [] // Combine options for key navigation const [combinedOptions, generalOptionsWithViewStatus, aiOptionsWithUserInput] = useMemo(() => { setAnnouncement('') let generalWithView = [...generalSearchResults] const aiWithUser = [...userInputOptions, ...filteredAIOptions] const combined = [] as Array<{ group: 'general' | 'ai' | string url?: string option: AutocompleteSearchHitWithUserQuery | GeneralSearchHitWithOptions }> if (generalSearchResults.length > 0) { generalWithView.push({ title: t('search.overlay.view_all_search_results'), isViewAllResults: true, } as any) } else if (autoCompleteSearchError) { if (urlSearchInputQuery.trim() !== '') { generalWithView.push({ ...(userInputOptions[0] || {}), isSearchDocsOption: true, } as unknown as GeneralSearchHit) } } else if (urlSearchInputQuery.trim() !== '' && !searchLoading) { setAnnouncement(t('search.overlay.no_results_found_announcement')) generalWithView.push({ title: t('search.overlay.no_results_found'), isNoResultsFound: true, } as any) } else { generalWithView = [] } // NOTE: Order of combinedOptions is important, since 'selectedIndex' is used to navigate the combinedOptions array // Add general options _before_ AI options combined.push(...generalWithView.map((option) => ({ group: 'general', option }))) // On AI Error, don't include AI suggestions, only user input if (!aiSearchError && !isAskAIState) { combined.push(...aiWithUser.map((option) => ({ group: 'ai', option }))) } else if (isAskAIState && !aiCouldNotAnswer) { // When "ask ai" state is reached, we have references that are ActionList items. // We want to navigate these items via the keyboard, so include them in the combinedOptions array combined.push( ...aiReferences.map((option) => ({ group: 'reference', // The references are actually article URLs that we want to navigate to url: option.url, option: { term: option.title, highlights: [], isUserQuery: false, }, })), ) } return [combined, generalWithView, aiWithUser] }, [ generalSearchResults, totalGeneralSearchResults, urlSearchInputQuery, aiSearchError, aiReferences, isAskAIState, autoCompleteSearchError, ]) // Rather than use `initialFocusRef` to have our Primer component auto-focus our input // We manually focus on open using a useEffect so we can focus _without_ scrolling since we don't want // to scroll to the top of the page each time the SearchOverlay is opened useEffect(() => { if (searchOverlayOpen) { inputRef.current?.focus({ preventScroll: true, }) } }, [searchOverlayOpen]) // Fetch initial search results on open useEffect(() => { if (searchOverlayOpen) { if (!searchEventGroupId.current) { searchEventGroupId.current = uuidv4() } updateAutocompleteResults(urlSearchInputQuery) } else { // When opening the overlay via query params, we don't need to fetch autocomplete results // However, on initial open, we need to clear the loading state setSearchLoading(false) } return () => { clearAutocompleteResults() } // We need to update when isAskAIState changes, because we might start a session in the "Ask AI" state, and then switch to the "Search" state // In this scenario we don't have pre-existing autocomplete results to show, so we need to fetch them // Additionally, the query may change in the "Ask AI" state, so we need to update the results when we switch back to the "Search" state }, [ searchOverlayOpen, updateAutocompleteResults, clearAutocompleteResults, isAskAIState, aiCouldNotAnswer, ]) // For keyboard controls, we need to use a ref for the list elements that updates when the options change useEffect(() => { listElementsRef.current = listElementsRef.current.slice( 0, generalOptionsWithViewStatus.length + aiOptionsWithUserInput.length, ) }, [generalOptionsWithViewStatus, aiOptionsWithUserInput]) // When loading, capture the last height of the suggestions list so we can use it for the loading div const previousSuggestionsListHeight = useMemo(() => { if (generalSearchResults.length || aiAutocompleteOptions.length) { return `${7 * (generalSearchResults.length + aiAutocompleteOptions.length)}` } else { return '150' // Default height for just 2 suggestions } }, [searchLoading]) // When the user types in the search input, update the local query and fetch autocomplete results const handleSearchQueryChange = (event: React.ChangeEvent) => { event.preventDefault() const newQuery = event.target.value setSelectedIndex(-1) // Reset selected index when query changes // Whenever the query changes, we want to leave the Ask AI state setSearchLoading(true) updateAutocompleteResults(newQuery) if (isAskAIState) { updateParams({ 'search-overlay-ask-ai': '', 'search-overlay-input': newQuery, }) } else { updateParams({ 'search-overlay-input': newQuery, }) } } // When a general option is selected, open the article in the current window const generalSearchResultOnSelect = (selectedOption: GeneralSearchHit) => { sendEvent({ type: EventType.search, search_query: sanitizeSearchQuery(urlSearchInputQuery), search_context: GENERAL_SEARCH_CONTEXT, eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP, eventGroupId: searchEventGroupId.current, }) sendEvent({ type: EventType.searchResult, search_result_query: sanitizeSearchQuery(urlSearchInputQuery), search_result_index: selectedIndex, search_result_total: totalGeneralSearchResults, search_result_url: selectedOption.url || '', search_result_rank: (totalGeneralSearchResults - selectedIndex) / totalGeneralSearchResults, eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP, eventGroupId: searchEventGroupId.current, }) const searchParams = new URLSearchParams((router.query as Record) || {}) if (searchParams.has('search-overlay-open')) { searchParams.delete('search-overlay-open') } if (searchParams.has('search-overlay-input')) { searchParams.delete('search-overlay-input') } if (searchParams.has('search-overlay-ask-ai')) { searchParams.delete('search-overlay-ask-ai') } if (searchParams.has('query')) { searchParams.delete('query') } router.push(`${selectedOption.url}?${searchParams.toString()}`) onClose() } // When an AI option is selected, set the AI query and focus the input since ask AI results replace the suggestions const aiSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => { if (selectedOption.term) { askAIEventGroupId.current = uuidv4() // Fire event from onSelect instead of inside the API request function (executeAISearch), because the result could be cached and not trigger an event sendEvent({ type: EventType.search, search_query: 'REDACTED', search_context: AI_SEARCH_CONTEXT, eventGroupKey: ASK_AI_EVENT_GROUP, eventGroupId: askAIEventGroupId.current, }) setSelectedIndex(-1) setSearchLoading(true) updateParams({ 'search-overlay-ask-ai': 'true', 'search-overlay-input': selectedOption.term, }) setAIQuery(selectedOption.term) inputRef.current?.focus() } } const performGeneralSearch = () => { executeGeneralSearch(router, currentVersion, urlSearchInputQuery, debug) onClose() } // When a reference from an "Ask AI" result is selected, navigate to the reference const referenceOnSelect = (url: string) => { sendEvent({ type: EventType.link, link_url: url || '', eventGroupKey: ASK_AI_EVENT_GROUP, eventGroupId: askAIEventGroupId.current, }) setSelectedIndex(-1) const searchParams = new URLSearchParams((router.query as Record) || {}) if (searchParams.has('search-overlay-open')) { searchParams.delete('search-overlay-open') } if (searchParams.has('search-overlay-input')) { searchParams.delete('search-overlay-input') } if (searchParams.has('search-overlay-ask-ai')) { searchParams.delete('search-overlay-ask-ai') } if (searchParams.has('query')) { searchParams.delete('query') } window.open(`${url}?${searchParams.toString()}`, '_blank') } // Handle keyboard navigation of suggestions const handleKeyDown = (event: React.KeyboardEvent) => { const optionsLength = listElementsRef.current?.length ?? 0 if (event.key === 'ArrowDown') { event.preventDefault() if (optionsLength > 0) { let newIndex = 0 // If no item is selected, select the first item if (selectedIndex === -1) { newIndex = 0 } else { newIndex = (selectedIndex + 1) % optionsLength // If we go "out of bounds" (i.e. the index is less than the selected index), unselect the item if (newIndex < selectedIndex) { newIndex = -1 } } // If it's the "no results found" option, skip it if ( newIndex >= selectedIndex && (combinedOptions[newIndex]?.option as any)?.isNoResultsFound ) { newIndex += 1 } setSelectedIndex(newIndex) if (newIndex !== -1 && listElementsRef.current[newIndex]) { listElementsRef.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', }) } } } else if (event.key === 'ArrowUp') { event.preventDefault() if (optionsLength > 0) { let newIndex = 0 // If no item is selected, select the last item if (selectedIndex === -1) { newIndex = optionsLength - 1 } else { // Otherwise, select the previous item newIndex = (selectedIndex - 1 + optionsLength) % optionsLength // If we go "out of bounds" (i.e. the index is greater than the selected index), unselect the item if (newIndex > selectedIndex) { newIndex = -1 // If it's the "no results found" option, skip it } } // If it's the "no results found" option, skip it if ( newIndex <= selectedIndex && (combinedOptions[newIndex]?.option as any)?.isNoResultsFound ) { newIndex -= 1 } setSelectedIndex(newIndex) if (newIndex !== -1 && listElementsRef.current[newIndex]) { listElementsRef.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', }) } } } else if (event.key === 'Enter') { event.preventDefault() let pressedGroupKey = SEARCH_OVERLAY_EVENT_GROUP let pressedGroupId = searchEventGroupId let pressedOnContext = '' // When enter is pressed and no option is manually selected (-1), perform an AI search with the user input if (selectedIndex === -1) { pressedOnContext = AI_SEARCH_CONTEXT pressedGroupKey = ASK_AI_EVENT_GROUP pressedGroupId = askAIEventGroupId sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey) aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit) } else if ( combinedOptions.length > 0 && selectedIndex >= 0 && selectedIndex < combinedOptions.length ) { const selectedItem = combinedOptions[selectedIndex] if (!selectedItem) { return } let action = () => {} // Execute the action after we send the event if (selectedItem?.group === 'general') { if ( (selectedItem.option as GeneralSearchHitWithOptions).isViewAllResults || (selectedItem.option as GeneralSearchHitWithOptions).isSearchDocsOption ) { pressedOnContext = 'view-all' action = performGeneralSearch } else { pressedOnContext = 'general-option' action = () => generalSearchResultOnSelect(selectedItem.option as GeneralSearchHit) } } else if (selectedItem?.group === 'ai') { pressedOnContext = 'ai-option' action = () => aiSearchOptionOnSelect(selectedItem.option as AutocompleteSearchHit) } else if (selectedItem?.group === 'reference') { // On a reference select, we are in the Ask AI State / Screen pressedGroupKey = ASK_AI_EVENT_GROUP pressedGroupId = askAIEventGroupId pressedOnContext = 'reference-option' action = () => referenceOnSelect(selectedItem.url || '') } sendKeyboardEvent(event.key, pressedOnContext, pressedGroupId, pressedGroupKey) return action() } } else if (event.key === 'Escape') { event.preventDefault() onClose() // Close the input overlay when Escape is pressed } } const onBackButton = () => { // Leave the Ask AI state when the user clicks the back button setSelectedIndex(-1) updateParams({ 'search-overlay-ask-ai': '', 'search-overlay-input': urlSearchInputQuery, }) // Focus the search input inputRef.current?.focus() } // We render the AI Result in the searchGroups call, so we pass the props down via an object // TODO: Move stateful logic to Context since we now have so many props: const askAIState = { isAskAIState, aiQuery, debug, currentVersion, setAISearchError: (isError = true) => { setAISearchError(isError) if (isError) { updateParams({ 'search-overlay-ask-ai': '', }) } }, references: aiReferences, setReferences: setAIReferences, referencesIndexOffset: generalOptionsWithViewStatus.length, referenceOnSelect, askAIEventGroupId, aiSearchError, aiCouldNotAnswer, setAICouldNotAnswer, } // We display different content in the overlay based: // 1. If either search (autocomplete results or ask AI) has an error // 2. The user has selected an AI query and we are showing the ask AI results // 3. The search is loading // 4. Otherwise, we show the autocomplete suggestions let OverlayContents = null // We can still ask AI if there is an autocomplete search error const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState) if (inErrorState) { OverlayContents = ( <> {/* Always show the AI Search UI error message when it is needed */} {aiSearchError && ( <>
  • {t('search.overlay.ai_autocomplete_list_heading')}
  • {/* If there are general results, show bottom divider */} {generalOptionsWithViewStatus.length > 0 && ( )} )} {renderSearchGroups( t, generalOptionsWithViewStatus, aiSearchError ? [] : aiOptionsWithUserInput, generalSearchResultOnSelect, aiSearchOptionOnSelect, performGeneralSearch, selectedIndex, listElementsRef, askAIState, showSpinner, searchLoading, previousSuggestionsListHeight, )}
    ) } else { OverlayContents = ( {renderSearchGroups( t, generalOptionsWithViewStatus, aiOptionsWithUserInput, generalSearchResultOnSelect, aiSearchOptionOnSelect, performGeneralSearch, selectedIndex, listElementsRef, askAIState, showSpinner, searchLoading, previousSuggestionsListHeight, )} ) } return ( <>
    } role="combobox" // In AskAI the search input not longer "controls" the suggestions list, because there is no list, so we remove the aria-controls attribute aria-controls={isAskAIState ? 'ask-ai-result-container' : 'search-suggestions-list'} aria-expanded={combinedOptions.length > 0} aria-label={t('search.overlay.input_aria_label')} aria-activedescendant={ selectedIndex >= 0 ? `search-option-${combinedOptions[selectedIndex]?.group}-${selectedIndex}` : undefined } onKeyDown={handleKeyDown} placeholder={t('search.input.placeholder_no_icon')} trailingAction={ { setSelectedIndex(-1) if (isAskAIState) { updateParams({ 'search-overlay-ask-ai': '', 'search-overlay-input': '', }) } else { updateParams({ 'search-overlay-input': '', }) } if (!isAskAIState) { updateAutocompleteResults('') } }} icon={XCircleFillIcon} aria-label={t('search.overlay.clear_search_query')} /> } />
    ) } interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit { isUserQuery?: boolean } interface GeneralSearchHitWithOptions extends GeneralSearchHit { isViewAllResults?: boolean isNoResultsFound?: boolean isSearchDocsOption?: boolean } // Render the autocomplete suggestions with AI suggestions first, headings, and a divider between the two function renderSearchGroups( t: any, generalSearchOptions: GeneralSearchHitWithOptions[], aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[], generalSearchResultOnSelect: (selectedOption: GeneralSearchHit) => void, aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void, performGeneralSearch: () => void, selectedIndex: number, listElementsRef: RefObject>, askAIState: { isAskAIState: boolean aiQuery: string debug: boolean currentVersion: string setAISearchError: () => void references: AIReference[] setReferences: (value: SetStateAction) => void referencesIndexOffset: number referenceOnSelect: (url: string) => void askAIEventGroupId: React.MutableRefObject aiSearchError: boolean aiCouldNotAnswer: boolean setAICouldNotAnswer: (value: boolean) => void }, showSpinner: boolean, searchLoading: boolean, previousSuggestionsListHeight: number | string, ) { const groups = [] const isInAskAIState = askAIState?.isAskAIState && !askAIState.aiSearchError const isInAskAIStateButNoAnswer = isInAskAIState && askAIState.aiCouldNotAnswer // This spinner is for both the AI search and the general search results. // We already show a spinner when streaming AI response, so don't want to show 2 here if (showSpinner && !isInAskAIState) { groups.push(
    , ) return groups } // We want to show general search suggestions above the AI Response section if the AI could not answer if (generalSearchOptions.length || isInAskAIStateButNoAnswer) { const items = [] for (let index = 0; index < generalSearchOptions.length; index++) { const option = generalSearchOptions[index] if (option.isNoResultsFound) { items.push( {option.title} , ) // There should be no more items after the no results found item break // This is a special case where there is an error loading search results and we want to be able to search the docs using the user's query } else if (option.isSearchDocsOption) { const isActive = selectedIndex === index items.push( performGeneralSearch()} aria-label={t('search.overlay.search_docs_with_query').replace('{query}', option.title)} ref={(element: HTMLLIElement | null) => { if (listElementsRef.current) { listElementsRef.current[index] = element } }} > {option.title} , ) } else if (option.title) { const isActive = selectedIndex === index items.push( option.isViewAllResults ? performGeneralSearch() : generalSearchResultOnSelect(option) } className={option.isViewAllResults ? styles.viewAllSearchResults : ''} active={isActive} tabIndex={-1} ref={(element: HTMLLIElement | null) => { if (listElementsRef.current) { listElementsRef.current[index] = element } }} > {!option.isNoResultsFound && ( )} {option.title} , ) } } groups.push( {t('search.overlay.general_suggestions_list_heading')} {searchLoading && isInAskAIState ? (
    ) : ( items )}
    , ) if (isInAskAIState || isInAskAIStateButNoAnswer) { groups.push() } if (isInAskAIState) { groups.push(
  • , ) } // Don't show the bottom divider if: // 1. We are in the AI could not answer state // 2. We are in the AI Search error state // 3. There are no AI suggestions to show in suggestions state if ( !isInAskAIState && !askAIState.aiSearchError && generalSearchOptions.filter((option) => !option.isViewAllResults && !option.isNoResultsFound) .length && aiOptionsWithUserInput.length ) { groups.push() } } if (aiOptionsWithUserInput.length && !isInAskAIState) { groups.push( {t('search.overlay.ai_autocomplete_list_heading')} {aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => { // Since general search comes first, we need to add an offset for AI suggestions const indexWithOffset = generalSearchOptions.length + index const isActive = selectedIndex === indexWithOffset const item = ( aiAutocompleteOnSelect(option)} active={isActive} tabIndex={-1} ref={(element: HTMLLIElement | null) => { if (listElementsRef.current) { listElementsRef.current[index] = element } }} > {option.term} ) return item })} , ) } return groups } function sendKeyboardEvent( pressedKey: string, pressedOn: string, searchEventGroupId: React.MutableRefObject, searchEventGroupKey = SEARCH_OVERLAY_EVENT_GROUP, ) { sendEvent({ type: EventType.keyboard, pressed_key: pressedKey, pressed_on: pressedOn, eventGroupKey: searchEventGroupKey, eventGroupId: searchEventGroupId.current, }) }