diff --git a/packages/gitbook-v2/src/lib/data/types.ts b/packages/gitbook-v2/src/lib/data/types.ts index 178a0ba77d..0b6920523d 100644 --- a/packages/gitbook-v2/src/lib/data/types.ts +++ b/packages/gitbook-v2/src/lib/data/types.ts @@ -189,5 +189,7 @@ export interface GitBookDataFetcher { input: api.AIMessageInput[]; output: api.AIOutputFormat; model: api.AIModel; + tools?: api.AIToolCapabilities; + previousResponseId?: string; }): AsyncGenerator; } diff --git a/packages/gitbook/src/components/Adaptive/server-actions/api.ts b/packages/gitbook/src/components/Adaptive/server-actions/api.ts index a1396987d7..1ea43e8a55 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/api.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/api.ts @@ -1,5 +1,10 @@ 'use server'; -import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api'; +import { + type AIMessageInput, + AIModel, + type AIStreamResponse, + type AIToolCapabilities, +} from '@gitbook/api'; import type { GitBookBaseContext } from '@v2/lib/context'; import { EventIterator } from 'event-iterator'; import type { MaybePromise } from 'p-map'; @@ -51,6 +56,7 @@ export async function streamGenerateObject( schema: z.ZodSchema; messages: AIMessageInput[]; model?: AIModel; + tools?: AIToolCapabilities; previousResponseId?: string; } ) { diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts new file mode 100644 index 0000000000..88abfe0198 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts @@ -0,0 +1,166 @@ +'use server'; +import { filterOutNullable } from '@/lib/typescript'; +import { getV1BaseContext } from '@/lib/v1'; +import { isV2 } from '@/lib/v2'; +import { AIMessageRole } from '@gitbook/api'; +import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; +import { getServerActionBaseContext } from '@v2/lib/server-actions'; +import { z } from 'zod'; +import { streamGenerateObject } from './api'; + +/** + * Get a summary of a page, in the context of another page + */ +export async function* streamLinkPageSummary({ + currentSpaceId, + currentPageId, + targetSpaceId, + targetPageId, + linkPreview, + linkTitle, + visitedPages, +}: { + currentSpaceId: string; + currentPageId: string; + currentPageTitle: string; + targetSpaceId: string; + targetPageId: string; + linkPreview?: string; + linkTitle?: string; + visitedPages?: Array<{ spaceId: string; pageId: string }>; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const { stream } = await streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + highlight: z + .string() + .describe('The reason why the user should read the target page.'), + // questions: z.array(z.string().describe('The questions to sea')).max(3), + }), + messages: [ + { + role: AIMessageRole.Developer, + content: `# 1. Role +You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph. + +# 2. Task +Extract a contextually-relevant fact that: +- Directly answers the specific need or question implied by the link's placement +- States a capability, limitation, or specification from the target page +- Connects precisely to the user's current paragraph or sentence +- Completes the user's understanding based on what they're currently reading + +# 3. Instructions +1. First, identify the exact need, question, or gap in the current paragraph where the link appears +2. Find the specific fact in the target page that addresses this exact contextual need +3. Ensure the fact relates directly to the context of the paragraph containing the link +4. Avoid ALL instructional language including words like "use", "click", "select", "create" +5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`, + }, + { + role: AIMessageRole.Developer, + content: `# 4. Current page +The content of the current page is:`, + attachments: [ + { + type: 'page' as const, + spaceId: currentSpaceId, + pageId: currentPageId, + }, + ], + }, + ...(visitedPages + ? [ + { + role: AIMessageRole.Developer, + content: '# 5. Previous pages', + }, + ...visitedPages.map(({ spaceId, pageId }) => ({ + role: AIMessageRole.Developer, + content: `## Page ${pageId}`, + attachments: [ + { + type: 'page' as const, + spaceId, + pageId, + }, + ], + })), + ] + : []), + { + role: AIMessageRole.Developer, + content: `# 6. Target page +The content of the target page is:`, + attachments: [ + { + type: 'page' as const, + spaceId: targetSpaceId, + pageId: targetPageId, + }, + ], + }, + { + role: AIMessageRole.Developer, + content: `# 7. Link preview +The content of the link preview is: +> ${linkPreview} +> Page ID: ${targetPageId}`, + }, + { + role: AIMessageRole.Developer, + content: `# 8. Guidelines & Examples +ALWAYS: +- ALWAYS choose facts that directly fulfill the contextual need where the link appears +- ALWAYS connect target page information specifically to the current paragraph context +- ALWAYS focus on the gap in knowledge that the link is meant to fill +- ALWAYS consider user's navigation history to ensure contextual continuity +- ALWAYS use action verbs like "click", "select", "use", "create", "enable" + +NEVER: +- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly. +- NEVER select general facts unrelated to the specific link context +- NEVER ignore the specific context where the link appears +- NEVER repeat the same fact in different words + +## Examples +Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once." +Preview: "Multiple Select: Select multiple content blocks at once." +✓ "Shift selects content between two points, useful for reorganizing your current heading structure." +✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks." + +Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]." +Preview: "Change Requests: Collaborative content editing workflow." +✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions." +✗ "Each reviewer receives an email notification and can approve or request changes." + +Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]." +Preview: "Live Edit: Real-time collaborative editing." +✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations." +✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`, + }, + { + role: AIMessageRole.User, + content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`, + }, + ].filter(filterOutNullable), + } + ); + + for await (const value of stream) { + const highlight = value.highlight; + if (!highlight) { + continue; + } + + yield highlight; + } +} diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index 15c09614e3..7ef60eb269 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -39,7 +39,7 @@ /* Light mode */ ::-webkit-scrollbar { - @apply bg-tint-subtle; + @apply bg-tint-subtle z-50; width: 8px; height: 8px; } diff --git a/packages/gitbook/src/components/Search/HighlightQuery.tsx b/packages/gitbook/src/components/Search/HighlightQuery.tsx index f0b32d74b0..4ad4140033 100644 --- a/packages/gitbook/src/components/Search/HighlightQuery.tsx +++ b/packages/gitbook/src/components/Search/HighlightQuery.tsx @@ -18,8 +18,7 @@ export function HighlightQuery(props: { 'text-bold', 'bg-primary', 'text-contrast-primary', - 'px-0.5', - '-mx-0.5', + 'px-1', 'py-0.5', 'rounded', 'straight-corners:rounded-sm', diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index 8a7dce493d..81380d2dfc 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -1,21 +1,19 @@ 'use client'; -import { Icon } from '@gitbook/icons'; -import { readStreamableValue } from 'ai/rsc'; -import React from 'react'; - -import { Loading } from '@/components/primitives'; import { useLanguage } from '@/intl/client'; import { t } from '@/intl/translate'; import type { TranslationLanguage } from '@/intl/translations'; import { tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; +import { readStreamableValue } from 'ai/rsc'; +import React from 'react'; +import { motion } from 'framer-motion'; import { useTrackEvent } from '../Insights'; import { Link } from '../primitives'; import { useSearchAskContext } from './SearchAskContext'; import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions'; import { useSearch, useSearchLink } from './useSearch'; - export type SearchAskState = | { type: 'answer'; @@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) { }, [setAskState]); const loading = ( -
- +
+ {[...Array(9)].map((_, index) => ( +
+ ))}
); return ( -
+ {askState?.type === 'answer' ? ( @@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
{t(language, 'search_ask_error')}
) : null} {askState?.type === 'loading' ? loading : null} -
+ ); } @@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) { return ( <> -
+
{answer.body ?? t(language, 'search_ask_no_answer')} {answer.followupQuestions.length > 0 ? ( @@ -182,7 +186,6 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) { )} {...getSearchLinkProps({ query: question, - ask: true, })} > { setSearchState({ - ask: false, + mode: 'both', global: false, query: '', }); @@ -99,7 +99,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV ); } -function Shortcut() { +export function Shortcut() { const [operatingSystem, setOperatingSystem] = useState(null); useEffect(() => { diff --git a/packages/gitbook/src/components/Search/SearchChat.tsx b/packages/gitbook/src/components/Search/SearchChat.tsx new file mode 100644 index 0000000000..6289dcfea4 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchChat.tsx @@ -0,0 +1,502 @@ +'use client'; +import { useLanguage } from '@/intl/client'; +import { t } from '@/intl/translate'; +import { tcls } from '@/lib/tailwind'; +import { filterOutNullable } from '@/lib/typescript'; +import { Icon } from '@gitbook/icons'; +import { useEffect, useRef, useState } from 'react'; +import { useVisitedPages } from '../Insights/useVisitedPages'; +import { Button } from '../primitives'; +import { Shortcut } from './SearchButton'; +import { isQuestion } from './isQuestion'; +import { streamAISearchAnswer, streamAISearchSummary } from './server-actions'; +import { useSearch } from './useSearch'; + +// Types +type Message = { + role: 'assistant' | 'user'; + content?: string; + context?: string; + fetching?: boolean; +}; + +// Loading animation component +function LoadingAnimation() { + return ( +
+ {[...Array(9)].map((_, index) => ( +
+ ))} +
+ ); +} + +// Followup questions component +function FollowupQuestions({ + questions, + onQuestionClick, +}: { + questions: string[]; + onQuestionClick: (question: string) => void; +}) { + if (!questions || questions.length === 0) return null; + + return ( +
+ {questions.map((question) => ( + + ))} +
+ ); +} + +// Individual chat message component +function ChatMessage({ + message, +}: { + message: Message; +}) { + const language = useLanguage(); + const isUser = message.role === 'user'; + + return ( +
+
+ {isUser ? ( + (message.context ?? + `You asked ${isQuestion(message.content ?? '') ? '' : 'about'}`) + ) : ( + <> + + {message.context ?? 'AI Answer'} + + )} +
+ + {message.fetching ? ( + + ) : !message.content ? ( +
{t(language, 'search_ask_no_answer')}
+ ) : ( +
+ {message.content} +
+ )} +
+ ); +} + +// Chat input component +function ChatInput({ + onSendMessage, + disabled, + inputRef, +}: { + onSendMessage: (message: string) => void; + disabled: boolean; + inputRef?: React.RefObject; +}) { + const [inputValue, setInputValue] = useState(''); + + const handleSend = () => { + if (!inputValue.trim()) return; + onSendMessage(inputValue); + setInputValue(''); + }; + + return ( +
+
+ setInputValue(e.target.value)} + disabled={disabled} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + {!disabled && ( +
+ +
+ )} +
+
+ ); +} + +// Custom hook for AI streaming +function useAIStream({ + question, + previousResponseId, +}: { + question?: string; + previousResponseId?: string; +}) { + const [response, setResponse] = useState<{ + content?: string; + responseId?: string; + followupQuestions?: string[]; + fetching: boolean; + }>({ + fetching: false, + }); + + useEffect(() => { + if (!question) return; + + let cancelled = false; + setResponse({ fetching: true }); + + (async () => { + try { + const stream = await streamAISearchAnswer({ + question, + previousResponseId, + }); + + for await (const rawData of stream) { + if (cancelled) break; + if (!rawData) continue; + + // Use type assertion to handle the data + const data = rawData as any; + + setResponse((prev) => { + const updated = { ...prev, fetching: false }; + + if (data.responseId) { + updated.responseId = String(data.responseId); + } + + if (data.answer) { + updated.content = String(data.answer); + } + + if (data.followupQuestions) { + updated.followupQuestions = + data.followupQuestions.filter(filterOutNullable); + } + + return updated; + }); + } + } catch (error) { + console.error('Error in AI stream:', error); + setResponse((prev) => ({ ...prev, fetching: false })); + } + })(); + + return () => { + cancelled = true; + }; + }, [question, previousResponseId]); + + return response; +} + +// Summary hook +function useSummary(visitedPages: any[]) { + const [summary, setSummary] = useState(''); + const [summaryResponseId, setSummaryResponseId] = useState(undefined); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const stream = await streamAISearchSummary({ visitedPages }); + + for await (const rawData of stream) { + if (cancelled) break; + if (!rawData) continue; + + // Use type assertion + const data = rawData as any; + + if (data.responseId) { + setSummaryResponseId(String(data.responseId)); + } + + if (data.summary) { + setSummary(String(data.summary)); + } + } + } catch (error) { + console.error('Error in summary stream:', error); + } + })(); + + return () => { + cancelled = true; + }; + }, [visitedPages]); + + return { summary, summaryResponseId }; +} + +// Main component +export function SearchChat(props: { + query: string; + chatInputRef?: React.RefObject; +}) { + const { query, chatInputRef } = props; + const visitedPages = useVisitedPages((state) => state.pages); + const [messages, setMessages] = useState([]); + const [followupQuestions, setFollowupQuestions] = useState([]); + const [conversationResponseId, setConversationResponseId] = useState(); + const [searchState, setSearchState] = useSearch(); + const latestMessageRef = useRef(null); + + const isExpanded = searchState?.mode === 'chat'; + + // Get summary of visited pages + const { summary, summaryResponseId } = useSummary(visitedPages); + + // Handle initial query + const initialResponse = useAIStream({ + question: query, + previousResponseId: summaryResponseId, + }); + + // Set up initial query effect + useEffect(() => { + if (!query) return; + + // Add initial assistant message + setMessages([ + { + role: 'assistant', + context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`, + fetching: true, + }, + ]); + + setFollowupQuestions([]); + setConversationResponseId(undefined); + }, [query]); + + // Update message when initial response changes + useEffect(() => { + if (!query || !initialResponse) return; + + if (initialResponse.content !== undefined) { + setMessages([ + { + role: 'assistant', + context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`, + content: initialResponse.content, + fetching: initialResponse.fetching, + }, + ]); + } + + if (initialResponse.followupQuestions) { + setFollowupQuestions(initialResponse.followupQuestions); + } + + if (initialResponse.responseId) { + setConversationResponseId(initialResponse.responseId); + } + }, [initialResponse, query]); + + // Handle follow-up messages + const handleSendMessage = (message: string) => { + // Add user message + const newMessages: Message[] = [ + ...messages, + { role: 'user', content: message, fetching: false }, + { role: 'assistant', fetching: true }, + ]; + + setMessages(newMessages); + setFollowupQuestions([]); + if (!searchState?.manual) { + setSearchState((state) => (state ? { ...state, mode: 'chat' } : null)); + } + + // Get AI response + const cancelled = false; + + (async () => { + try { + const stream = await streamAISearchAnswer({ + question: message, + previousResponseId: conversationResponseId, + }); + + for await (const rawData of stream) { + if (cancelled) break; + if (!rawData) continue; + + // Use type assertion + const data = rawData as any; + + if (data.responseId) { + setConversationResponseId(String(data.responseId)); + } + + if (data.answer !== undefined) { + setMessages((prev) => [ + ...prev.slice(0, -1), + { role: 'assistant', content: data.answer, fetching: false }, + ]); + } + + if (data.followupQuestions && Array.isArray(data.followupQuestions)) { + setFollowupQuestions(data.followupQuestions.filter(filterOutNullable)); + } + } + } catch (error) { + console.error('Error in follow-up stream:', error); + // Update the message to show an error state + setMessages((prev) => [ + ...prev.slice(0, -1), + { role: 'assistant', fetching: false }, + ]); + } + })(); + }; + + // Handle followup question click + const handleFollowupClick = (question: string) => { + handleSendMessage(question); + }; + + // Auto-scroll to latest message + useEffect(() => { + if (latestMessageRef.current) { + latestMessageRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + }, [messages]); + + return ( +
+ {/* Toggle button for showing search results */} + {searchState?.mode === 'chat' && ( +
+
+ )} + + {/* Main chat area */} +
+ {/* Summary section */} +
+
+ Summary of what + you've read +
+ {summary ? summary : } +
+ + {/* Messages */} + {messages.map((message, index) => { + const isLast = index === messages.length - 1; + return ( +
+ + {isLast && followupQuestions && followupQuestions.length > 0 && ( + + )} +
+ ); + })} +
+ + {/* Input area */} + {query && ( +
+
+ +
+
+ )} +
+ ); +} diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index bdc747241a..5bf3a409a1 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -1,6 +1,4 @@ 'use client'; - -import { Icon } from '@gitbook/icons'; import { AnimatePresence, motion } from 'framer-motion'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -8,10 +6,9 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; - import { LoadingPane } from '../primitives/LoadingPane'; -import { SearchAskAnswer } from './SearchAskAnswer'; import { SearchAskProvider, useSearchAskState } from './SearchAskContext'; +import { SearchChat } from './SearchChat'; import { SearchResults, type SearchResultsRef } from './SearchResults'; import { SearchScopeToggle } from './SearchScopeToggle'; import { type SearchState, type UpdateSearchState, useSearch } from './useSearch'; @@ -30,14 +27,21 @@ export function SearchModal(props: SearchModalProps) { const searchAsk = useSearchAskState(); const [askState] = searchAsk; const router = useRouter(); + const chatInputRef = React.useRef(null); useHotkeys( 'mod+k', (e) => { e.preventDefault(); - setSearchState({ ask: false, query: '', global: false }); + if (state !== null) { + // If search is already open, focus the chat input + chatInputRef.current?.focus(); + } else { + // Otherwise open the search modal + setSearchState({ mode: 'both', query: '', global: false }); + } }, - [] + [state] ); // Add a global class on the body when the search modal is open @@ -125,6 +129,7 @@ export function SearchModal(props: SearchModalProps) { state={state} setSearchState={setSearchState} onClose={onClose} + chatInputRef={chatInputRef} />
@@ -139,9 +144,11 @@ function SearchModalBody( state: SearchState; setSearchState: UpdateSearchState; onClose: (to?: string) => void; + chatInputRef: React.RefObject; } ) { - const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose } = props; + const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose, chatInputRef } = + props; const language = useLanguage(); const resultsRef = React.useRef(null); @@ -165,6 +172,12 @@ function SearchModalBody( }, [onClose]); const onKeyDown = (event: React.KeyboardEvent) => { + // Handle second Cmd+K + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + event.preventDefault(); + chatInputRef.current?.focus(); + return; + } if (event.key === 'ArrowUp') { event.preventDefault(); resultsRef.current?.moveUp(); @@ -179,7 +192,7 @@ function SearchModalBody( const onChange = (event: React.ChangeEvent) => { setSearchState({ - ask: false, // When typing, we go back to the default search mode + mode: 'both', // When typing, we go back to the default search mode query: event.target.value, global: state.global, }); @@ -219,9 +232,10 @@ function SearchModalBody( 'flex', 'flex-col', 'bg-tint-base', - 'max-w-prose', + 'max-w-screen-lg', 'mx-auto', - 'max-h-[70dvh]', + // 'min-h-[50dvh]', + 'h-[70dvh]', 'w-full', 'rounded-lg', 'straight-corners:rounded-sm', @@ -242,12 +256,10 @@ function SearchModalBody( 'flex-row', 'items-start', state.query !== null ? 'border-b' : null, - 'border-tint-subtle' + 'border-tint-subtle', + 'col-span-full' )} > -
- -
: null}
- {!state.ask || !withAsk ? ( - - ) : null} - {normalizedQuery && state.ask && withAsk ? ( - - ) : null} +
+
+ +
+ +
+ +
+
); } diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 6b4d0e1c0e..bc7b165513 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -2,7 +2,9 @@ import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import React from 'react'; -import { Link } from '../primitives'; +import { useLanguage } from '@/intl/client'; +import { tString } from '@/intl/translate'; +import { Button, Link } from '../primitives'; import { HighlightQuery } from './HighlightQuery'; import type { ComputedPageResult } from './server-actions'; @@ -14,6 +16,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt }, ref: React.Ref ) { + const language = useLanguage(); const { query, item, active } = props; const breadcrumbs = @@ -34,16 +37,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt 'flex-row', 'items-center', 'p-4', - 'border-t', - 'border-tint-subtle', - 'first:border-none', + 'rounded-lg', + 'straight-corners:rounded-none', 'text-base', 'font-medium', + 'text-tint-strong', 'hover:bg-tint-hover', 'group', - active - ? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover'] - : null + active && [ + 'is-active', + 'bg-primary', + 'text-primary-strong', + 'hover:bg-primary-hover', + ] )} insights={{ type: 'search_open_result', @@ -56,8 +62,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt >
@@ -65,7 +71,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
-
- -
+ ) : ( + + )} ); }); diff --git a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx index 83f585c82b..8ff63b997d 100644 --- a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx @@ -1,10 +1,10 @@ import { Icon } from '@gitbook/icons'; import React from 'react'; -import { t, useLanguage } from '@/intl/client'; +import { t, tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -import { Link } from '../primitives'; +import { Button, Link } from '../primitives'; import { useSearchLink } from './useSearch'; export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem( @@ -28,19 +28,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion className={tcls( 'flex', 'px-4', - recommended ? ['py-2', 'text-tint'] : 'py-4', + 'py-2', + 'text-tint', + 'rounded-lg', + 'straight-corners:rounded-none', 'hover:bg-tint-hover', - 'first:mt-0', - 'last:pb-3', + 'gap-4', active && [ 'is-active', 'bg-primary', - 'text-contrast-primary', + 'text-primary-strong', 'hover:bg-primary-hover', ] )} {...getLinkProp({ - ask: true, query: question, })} > @@ -50,7 +51,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion 'size-4', 'shrink-0', 'mt-1.5', - 'mr-4', active ? ['text-primary'] : ['text-tint-subtle'] )} /> @@ -66,19 +66,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion )}
-
+ {active ? ( +
); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 1eca294d48..5a31ca2627 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; +import { AnimatePresence, motion } from 'framer-motion'; import { useTrackEvent } from '../Insights'; import { Loading } from '../primitives'; import { SearchPageResultItem } from './SearchPageResultItem'; @@ -18,6 +19,7 @@ import { searchSiteSpaceContent, streamRecommendedQuestions, } from './server-actions'; +import { type SearchState, useSearch } from './useSearch'; export interface SearchResultsRef { moveUp(): void; @@ -44,7 +46,6 @@ let cachedRecommendedQuestions: null | ResultType[] = null; */ export const SearchResults = React.forwardRef(function SearchResults( props: { - children?: React.ReactNode; query: string; global: boolean; withAsk: boolean; @@ -52,7 +53,7 @@ export const SearchResults = React.forwardRef(function SearchResults( }, ref: React.Ref ) { - const { children, query, withAsk, global, onSwitchToAsk } = props; + const { query, withAsk, global, onSwitchToAsk } = props; const language = useLanguage(); const trackEvent = useTrackEvent(); @@ -62,6 +63,39 @@ export const SearchResults = React.forwardRef(function SearchResults( }>({ results: [], fetching: true }); const [cursor, setCursor] = React.useState(null); const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); + const [searchState, setSearchState] = useSearch(); + const manualStateRef = React.useRef(false); + + React.useEffect(() => { + if (searchState?.manual !== undefined) { + manualStateRef.current = searchState.manual; + } + }, [searchState?.manual]); + + const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]); + + React.useEffect(() => { + if (!query) { + // Reset the cursor when there's no query + setCursor(null); + } else if (!searchState?.manual && !resultsState.fetching && results.length === 0) { + setSearchState((prev) => { + const newState: SearchState | null = prev + ? { ...prev, mode: 'chat' as const } + : null; + return newState; + }); + } else if (results.length > 0) { + // Auto-focus the first result + setSearchState((prev) => { + const newState: SearchState | null = prev + ? { ...prev, mode: 'both' as const } + : null; + return newState; + }); + setCursor(0); + } + }, [results, query, setSearchState, resultsState.fetching, searchState?.manual]); React.useEffect(() => { if (!query) { @@ -78,7 +112,7 @@ export const SearchResults = React.forwardRef(function SearchResults( let cancelled = false; // Silently fetch the recommended questions, instead of showing a spinner - setResultsState({ results: [], fetching: false }); + // setResultsState({ results: [], fetching: false }); // We currently have a bug where the same question can be returned multiple times. // This is a workaround to avoid that. @@ -150,23 +184,6 @@ export const SearchResults = React.forwardRef(function SearchResults( }; }, [query, global, withAsk, trackEvent]); - const results: ResultType[] = React.useMemo(() => { - if (!withAsk) { - return resultsState.results; - } - return withQuestionResult(resultsState.results, query); - }, [resultsState.results, query, withAsk]); - - React.useEffect(() => { - if (!query) { - // Reset the cursor when there's no query - setCursor(null); - } else if (results.length > 0) { - // Auto-focus the first result - setCursor(0); - } - }, [results, query]); - // Scroll to the active result. React.useEffect(() => { if (cursor === null || !refs.current[cursor]) { @@ -214,106 +231,93 @@ export const SearchResults = React.forwardRef(function SearchResults( [moveBy, select] ); - if (resultsState.fetching) { - return ( -
- -
- ); - } + const loading = ( +
+ +
+ ); const noResults = ( -
- {t(language, 'search_no_results', query)} +
+
+ {t(language, 'search_no_results', query)} +
); return ( -
- {children} - {results.length === 0 ? ( - query ? ( - noResults - ) : null + + {resultsState.fetching ? ( + loading + ) : query && results.length === 0 ? ( + noResults ) : ( - <> -
- {results.map((item, index) => { - switch (item.type) { - case 'page': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - /> - ); - } - case 'question': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - question={query} - active={index === cursor} - onClick={onSwitchToAsk} - /> - ); - } - case 'recommended-question': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - question={item.question} - active={index === cursor} - onClick={onSwitchToAsk} - recommended - /> - ); - } - case 'section': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - /> - ); - } - default: - assertNever(item); + + {results.map((item, index) => { + switch (item.type) { + case 'page': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + query={query} + item={item} + active={index === cursor} + /> + ); } - })} -
- {!results.some((result) => result.type !== 'question') && noResults} - + case 'question': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + question={query} + active={index === cursor} + onClick={onSwitchToAsk} + /> + ); + } + case 'recommended-question': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + question={item.question} + active={index === cursor} + onClick={onSwitchToAsk} + recommended + /> + ); + } + case 'section': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + query={query} + item={item} + active={index === cursor} + /> + ); + } + default: + assertNever(item); + } + })} + )} -
+ ); }); - -/** - * Add a "Ask " item at the top of the results list. - */ -function withQuestionResult(results: ResultType[], query: string): ResultType[] { - const without = results.filter((result) => result.type !== 'question'); - - if (query.length === 0) { - return without; - } - - return [{ type: 'question', id: 'question', query }, ...(without ?? [])]; -} diff --git a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index ee2daa1ffb..b754ff93b3 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -3,7 +3,8 @@ import React from 'react'; import { tcls } from '@/lib/tailwind'; -import { Link } from '../primitives'; +import { tString, useLanguage } from '@/intl/client'; +import { Button, Link } from '../primitives'; import { HighlightQuery } from './HighlightQuery'; import type { ComputedSectionResult } from './server-actions'; @@ -16,13 +17,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe ref: React.Ref ) { const { query, item, active } = props; + const language = useLanguage(); return ( ) : null}
-
- -
+ {active ? ( +