Add recent search (and chat) queries (#4225)

This commit is contained in:
Zeno Kapitein
2026-06-10 18:45:07 +02:00
committed by GitHub
parent 7036b67307
commit ed80bd9e7a
9 changed files with 346 additions and 57 deletions
@@ -2,6 +2,7 @@
import * as zustand from 'zustand';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
@@ -15,6 +16,7 @@ import assertNever from 'assert-never';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type AIChatReference, serializeReferences } from './references';
@@ -187,6 +189,7 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Event listeners storage
@@ -491,6 +494,10 @@ export function AIChatProvider(props: {
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
@@ -537,7 +544,7 @@ export function AIChatProvider(props: {
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, trackEvent, streamResponse]
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Clear the conversation and reset ask parameter
@@ -1,5 +1,7 @@
import { useCurrentContent } from '@/components/hooks';
import { tString, useLanguage } from '@/intl/client';
import type { AIChatController } from '../AI';
import { useRecentSearchQueries } from '../Search/recent-queries';
import { Button } from '../primitives';
export default function AIChatSuggestedQuestions(props: {
@@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: {
suggestions?: string[];
}) {
const language = useLanguage();
const { chatController, suggestions: _suggestions } = props;
const { siteSpaceId } = useCurrentContent();
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
const { chatController, suggestions: configuredSuggestions } = props;
const suggestions =
_suggestions && _suggestions.length > 0
? _suggestions
: [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
const defaultSuggestions = [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
const baseSuggestions =
configuredSuggestions && configuredSuggestions.length > 0
? configuredSuggestions
: defaultSuggestions;
const suggestions = [
...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query),
...baseSuggestions,
].reduce<string[]>((acc, suggestion) => {
if (acc.includes(suggestion)) {
return acc;
}
acc.push(suggestion);
return acc;
}, []);
return (
<div
@@ -1,9 +1,11 @@
'use client';
import type { Assistant } from '@/components/AI';
import { useCurrentContent } from '@/components/hooks';
import { t, tString, useLanguage } from '@/intl/client';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SearchResultItem } from './SearchResultItem';
import { addRecentSearchQuery } from './recent-queries';
import { useSearchLink } from './useSearch';
/**
@@ -19,6 +21,7 @@ export function SearchAskBar(props: {
}) {
const { query, assistant, active = false, withShortcut = false, onSelect } = props;
const language = useLanguage();
const { siteSpaceId } = useCurrentContent();
const getSearchLinkProps = useSearchLink();
const linkProps = getSearchLinkProps(
@@ -28,6 +31,9 @@ export function SearchAskBar(props: {
open: assistant.mode === 'search',
},
() => {
if (assistant.mode === 'search' && siteSpaceId) {
addRecentSearchQuery(siteSpaceId, query, 'ask');
}
onSelect?.();
assistant.open(query);
}
@@ -1,43 +1,57 @@
import React from 'react';
import type { Assistant } from '@/components/AI';
import { useCurrentContent } from '@/components/hooks';
import { tString, useLanguage } from '@/intl/client';
import { SearchResultItem } from './SearchResultItem';
import { addRecentSearchQuery } from './recent-queries';
import { useSearchLink } from './useSearch';
export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem(
props: {
question: string;
action: 'ask' | 'search';
active: boolean;
recommended?: boolean;
assistant: Assistant;
style?: React.CSSProperties;
},
ref: React.Ref<HTMLAnchorElement>
) {
const { question, recommended = false, active, assistant, style, ...rest } = props;
const { question, action, active, assistant, ...rest } = props;
const language = useLanguage();
const { siteSpaceId } = useCurrentContent();
const getLinkProp = useSearchLink();
const shouldAsk = action === 'ask';
return (
<SearchResultItem
size="small"
action={tString(language, 'ask', '')}
action={tString(language, shouldAsk ? 'ask' : 'search')}
ref={ref}
data-testid="search-recommended-question"
scroll={false}
{...getLinkProp(
{
ask: question,
query: null,
open: assistant.mode === 'search',
},
() => {
assistant.open(question);
}
shouldAsk
? {
ask: question,
query: null,
open: assistant.mode === 'search',
}
: {
ask: null,
query: question,
open: true,
},
shouldAsk
? () => {
if (assistant.mode === 'search' && siteSpaceId) {
addRecentSearchQuery(siteSpaceId, question, 'ask');
}
assistant.open(question);
}
: undefined
)}
active={active}
leadingIcon="search"
leadingIcon={shouldAsk ? assistant.icon : 'search'}
{...rest}
>
{question}
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import React from 'react';
import { useAI } from '@/components/AI';
import { useCurrentContent } from '@/components/hooks';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
@@ -13,18 +14,13 @@ import { SearchPageResultItem } from './SearchPageResultItem';
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
import { SearchRecordResultItem } from './SearchRecordResultItem';
import { SearchResultItem } from './SearchResultItem';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
import { addRecentSearchQuery } from './recent-queries';
import type { ResultType } from './useSearchResults';
export interface SearchResultsRef {
select(): boolean;
}
type ResultType =
| OrderedComputedResult
| LocalPageResult
| { type: 'recommended-question'; id: string; question: string };
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
@@ -61,6 +57,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { children, id, query, results, fetching, cursor, error, onResultSelect } = props;
const language = useLanguage();
const { siteSpaceId } = useCurrentContent();
const shouldAnimateResults = !query || fetching;
const previousCursor = React.useRef<number | null>(cursor);
const seenResultKeys = React.useRef(new Set<string>());
@@ -183,11 +180,24 @@ export const SearchResults = React.forwardRef(function SearchResults(
const itemKey = getResultKey(item);
const shouldAnimateItem =
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
const handleResultSelect = () => {
if (
query &&
siteSpaceId &&
(item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record')
) {
addRecentSearchQuery(siteSpaceId, query, 'search');
}
onResultSelect?.();
};
const resultItemProps = {
'aria-posinset': index + 1,
'aria-setsize': results.length,
id: `${id}-${index}`,
onClickCapture: () => onResultSelect?.(),
onClickCapture: handleResultSelect,
};
switch (item.type) {
case 'local-page':
@@ -253,12 +263,9 @@ export const SearchResults = React.forwardRef(function SearchResults(
refs.current[index] = ref;
}}
question={item.question}
action={item.action}
active={index === cursor}
assistant={primaryAssistant}
recommended
style={{
animationDelay: `${index * 25}ms,${100 + index * 25}ms`,
}}
{...resultItemProps}
/>
</motion.div>
@@ -0,0 +1,62 @@
import type { RecentSearchQueryEntry } from './recent-queries';
export type RecommendedQuestionResult = {
type: 'recommended-question';
id: string;
question: string;
action: 'ask' | 'search';
};
export function createRecommendedQuestionResult(
id: string,
question: string,
action: 'ask' | 'search' = 'ask'
): RecommendedQuestionResult {
return {
type: 'recommended-question',
id,
question,
action,
};
}
export function getEmptySearchResults(props: {
withAI: boolean;
recentQueries: RecentSearchQueryEntry[];
recommendedQuestions: RecommendedQuestionResult[];
}): RecommendedQuestionResult[] {
const { withAI, recentQueries, recommendedQuestions } = props;
if (!withAI) {
return [];
}
const seenQuestions = new Set<string>();
const results: RecommendedQuestionResult[] = [];
for (const recentQuery of recentQueries) {
if (seenQuestions.has(recentQuery.query)) {
continue;
}
seenQuestions.add(recentQuery.query);
results.push(
createRecommendedQuestionResult(
`recent-query-${recentQuery.action}-${recentQuery.query}`,
recentQuery.query,
recentQuery.action
)
);
}
for (const question of recommendedQuestions) {
if (seenQuestions.has(question.question)) {
continue;
}
seenQuestions.add(question.question);
results.push(question);
}
return results;
}
@@ -0,0 +1,168 @@
'use client';
import React from 'react';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
const STORAGE_KEY = '@gitbook/searchRecentQueries';
const MAX_RECENT_QUERIES = 5;
const EMPTY_RECENT_QUERIES: RecentSearchQueryEntry[] = [];
export type RecentSearchQueryAction = 'ask' | 'search';
export type RecentSearchQueryEntry = {
query: string;
action: RecentSearchQueryAction;
};
type RecentQueriesState = Record<string, RecentSearchQueryEntry[]>;
const listeners = new Set<() => void>();
const EMPTY_RECENT_QUERIES_STATE: RecentQueriesState = {};
let globalRecentQueriesState = EMPTY_RECENT_QUERIES_STATE;
let hasStorageListener = false;
let hasLoadedRecentQueriesState = false;
function parseRecentSearchQueryEntry(value: unknown): RecentSearchQueryEntry | null {
if (
!value ||
typeof value !== 'object' ||
!('query' in value) ||
typeof value.query !== 'string' ||
!('action' in value) ||
(value.action !== 'ask' && value.action !== 'search')
) {
return null;
}
const query = value.query.trim();
if (!query) {
return null;
}
return {
query,
action: value.action,
};
}
function sanitizeQueries(value: unknown): RecentSearchQueryEntry[] {
if (!Array.isArray(value)) {
return EMPTY_RECENT_QUERIES;
}
const seen = new Set<string>();
return value.reduce<RecentSearchQueryEntry[]>((acc, entry) => {
const normalizedEntry = parseRecentSearchQueryEntry(entry);
if (!normalizedEntry || seen.has(normalizedEntry.query)) {
return acc;
}
seen.add(normalizedEntry.query);
if (acc.length < MAX_RECENT_QUERIES) {
acc.push(normalizedEntry);
}
return acc;
}, []);
}
function sanitizeState(value: unknown): RecentQueriesState {
if (!value || typeof value !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(value).flatMap(([siteSpaceId, queries]) => {
if (typeof siteSpaceId !== 'string') {
return [];
}
const sanitized = sanitizeQueries(queries);
return sanitized.length > 0 ? [[siteSpaceId, sanitized]] : [];
})
);
}
function readRecentQueriesState(): RecentQueriesState {
return sanitizeState(getLocalStorageItem<unknown>(STORAGE_KEY, {}));
}
function ensureRecentQueriesStateLoaded() {
if (typeof window === 'undefined' || hasLoadedRecentQueriesState) {
return;
}
globalRecentQueriesState = readRecentQueriesState();
hasLoadedRecentQueriesState = true;
}
function emitChange() {
listeners.forEach((listener) => listener());
}
function ensureStorageListener() {
if (typeof window === 'undefined' || hasStorageListener) {
return;
}
ensureRecentQueriesStateLoaded();
window.addEventListener('storage', () => {
globalRecentQueriesState = readRecentQueriesState();
hasLoadedRecentQueriesState = true;
emitChange();
});
hasStorageListener = true;
}
function writeRecentQueriesState(nextState: RecentQueriesState) {
globalRecentQueriesState = nextState;
setLocalStorageItem(STORAGE_KEY, nextState);
emitChange();
}
export function addRecentSearchQuery(
siteSpaceId: string,
query: string,
action: RecentSearchQueryAction
) {
const normalizedQuery = query.trim();
if (!siteSpaceId || !normalizedQuery) {
return;
}
ensureRecentQueriesStateLoaded();
const nextQueries: RecentSearchQueryEntry[] = [
{
query: normalizedQuery,
action,
},
...(globalRecentQueriesState[siteSpaceId] ?? []).filter(
(existingQuery) => existingQuery.query !== normalizedQuery
),
].slice(0, MAX_RECENT_QUERIES);
writeRecentQueriesState({
...globalRecentQueriesState,
[siteSpaceId]: nextQueries,
});
}
export function useRecentSearchQueries(siteSpaceId: string): RecentSearchQueryEntry[] {
const subscribe = React.useCallback((listener: () => void) => {
ensureStorageListener();
listeners.add(listener);
listener();
return () => listeners.delete(listener);
}, []);
const getSnapshot = React.useCallback(
() => globalRecentQueriesState[siteSpaceId] ?? EMPTY_RECENT_QUERIES,
[siteSpaceId]
);
return React.useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_RECENT_QUERIES);
}
@@ -7,6 +7,7 @@ import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useBodyLoaded } from '../primitives';
import type { SearchResultsRef } from './SearchResults';
import { addRecentSearchQuery } from './recent-queries';
import type { SearchBaseProps } from './search-props';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
@@ -220,6 +221,10 @@ export function useSearchController(props: SearchBaseProps) {
return;
}
if (assistant.mode === 'search') {
addRecentSearchQuery(siteSpace.id, normalizedQuery, 'ask');
}
abort();
assistant.open(normalizedQuery);
setSearchState({
@@ -229,7 +234,7 @@ export function useSearchController(props: SearchBaseProps) {
open: assistant.mode === 'search',
});
},
[abort, assistants, normalizedQuery, setSearchState, state?.scope]
[abort, assistants, normalizedQuery, setSearchState, siteSpace.id, state?.scope]
);
const askCount = normalizedQuery && !showAsk ? assistants.length : 0;
@@ -3,6 +3,11 @@ import React from 'react';
import { assert } from 'ts-essentials';
import {
type RecommendedQuestionResult,
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import type { OrderedComputedResult } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
@@ -10,6 +15,7 @@ import { useAI } from '@/components/AI';
import assertNever from 'assert-never';
import { useTrackEvent } from '../Insights';
import { computeFilterSiteSpaceIds } from './filter';
import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, reciprocalRankFusion } from './reciprocalRankFusion';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
import type { SearchScope } from './useSearch';
@@ -18,7 +24,7 @@ export type ResultType =
| OrderedComputedResult
| LocalPageResult
| MergedPageResult
| { type: 'recommended-question'; id: string; question: string };
| RecommendedQuestionResult;
export type { LocalPageResult, MergedPageResult };
@@ -31,7 +37,7 @@ export type { LocalPageResult, MergedPageResult };
* have different recommended questions for different spaces of the same site.
* It should not be used outside of an useEffect.
*/
const cachedRecommendedQuestions: Map<string, ResultType[]> = new Map();
const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new Map();
export function useSearchResults(props: {
asEmbeddable?: boolean;
@@ -91,6 +97,7 @@ export function useSearchResults(props: {
const { assistants } = useAI();
const withAI = assistants.length > 0;
const recentQueries = useRecentSearchQueries(siteSpaceId);
React.useEffect(() => {
if (disabled) {
@@ -120,7 +127,7 @@ export function useSearchResults(props: {
// We currently have a bug where the same question can be returned multiple times.
// This is a workaround to avoid that.
const questions = new Set<string>();
const recommendedQuestions: ResultType[] = [];
const recommendedQuestions: RecommendedQuestionResult[] = [];
if (suggestions && suggestions.length > 0) {
suggestions.forEach((question) => {
@@ -147,11 +154,7 @@ export function useSearchResults(props: {
}
questions.add(question);
recommendedQuestions.push({
type: 'recommended-question',
id: question,
question,
});
recommendedQuestions.push(createRecommendedQuestionResult(question, question));
cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions);
if (!cancelled) {
@@ -267,24 +270,24 @@ export function useSearchResults(props: {
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
if (!query) {
// No query: show recommended questions (AI-only path) or nothing.
if (withAI && cachedRecommendedQuestions.has(siteSpaceId)) {
return cachedRecommendedQuestions.get(siteSpaceId) ?? [];
}
if (suggestions && suggestions.length > 0) {
return suggestions.map((question, index) => ({
type: 'recommended-question' as const,
id: `recommended-question-${index}`,
question,
}));
}
return [];
const recommendedQuestions =
cachedRecommendedQuestions.get(siteSpaceId) ??
suggestions?.map((question, index) =>
createRecommendedQuestionResult(`recommended-question-${index}`, question)
) ??
[];
return getEmptySearchResults({
withAI,
recentQueries,
recommendedQuestions,
});
}
const merged = reciprocalRankFusion(localResults, remoteState.results, query);
return merged;
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions]);
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]);
return {
results,