{answer.body ?? t(language, 'search_ask_no_answer')}
{answer.followupQuestions.length > 0 ? (
@@ -212,8 +211,7 @@ function AnswerSources(props: {
'gap-2',
'mt-4',
'sm:mt-6',
- 'py-4',
- 'px-4',
+ 'pt-4',
'border-t',
'border-subtle'
)}
diff --git a/packages/gitbook/src/components/Search/SearchButton.tsx b/packages/gitbook/src/components/Search/SearchButton.tsx
deleted file mode 100644
index 9b1734f4c6..0000000000
--- a/packages/gitbook/src/components/Search/SearchButton.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-'use client';
-
-import { Icon } from '@gitbook/icons';
-
-import { tString, useLanguage } from '@/intl/client';
-import { type ClassValue, tcls } from '@/lib/tailwind';
-import { useTrackEvent } from '../Insights';
-import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
-import { useSearch } from './useSearch';
-
-/**
- * Button to open the search modal.
- */
-export function SearchButton(props: { children?: React.ReactNode; style?: ClassValue }) {
- const { style, children } = props;
-
- const language = useLanguage();
- const [, setSearchState] = useSearch();
- const trackEvent = useTrackEvent();
-
- const onClick = () => {
- setSearchState({
- ask: false,
- global: false,
- query: '',
- });
-
- trackEvent({
- type: 'search_open',
- });
- };
-
- return (
-
- );
-}
diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx
new file mode 100644
index 0000000000..ccb5109b3e
--- /dev/null
+++ b/packages/gitbook/src/components/Search/SearchContainer.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { CustomizationAIMode } from '@gitbook/api';
+import { useRouter } from 'next/navigation';
+import React, { useRef } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { useTrackEvent } from '../Insights';
+import { useIsMobile } from '../hooks/useIsMobile';
+import { Popover } from '../primitives';
+import { SearchAskAnswer } from './SearchAskAnswer';
+import { useSearchAskState } from './SearchAskContext';
+import { SearchAskProvider } from './SearchAskContext';
+import { SearchInput } from './SearchInput';
+import { SearchResults, type SearchResultsRef } from './SearchResults';
+import { SearchScopeToggle } from './SearchScopeToggle';
+import { useSearch } from './useSearch';
+
+interface SearchContainerProps {
+ spaceTitle: string;
+ isMultiVariants: boolean;
+ aiMode: CustomizationAIMode;
+ className?: string;
+}
+
+/**
+ * Client component to render the search input and results.
+ */
+export function SearchContainer(props: SearchContainerProps) {
+ const { spaceTitle, isMultiVariants, aiMode, className } = props;
+ const withAI =
+ aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant;
+
+ const [state, setSearchState] = useSearch();
+ const searchAsk = useSearchAskState();
+ const router = useRouter();
+ const trackEvent = useTrackEvent();
+ const resultsRef = useRef(null);
+ const searchInputRef = useRef(null);
+
+ const isMobile = useIsMobile();
+
+ // Derive open state from search state
+ const open = state?.open ?? false;
+
+ const onClose = async (to?: string) => {
+ if (state?.query === '') {
+ await setSearchState(null);
+ } else if (state) {
+ await setSearchState({ ...state, open: false });
+ }
+
+ if (to) {
+ router.push(to);
+ }
+ };
+
+ useHotkeys(
+ 'mod+k',
+ (e) => {
+ e.preventDefault();
+ onOpen();
+ },
+ []
+ );
+
+ const onOpen = () => {
+ if (open) {
+ return;
+ }
+ setSearchState((prev) => ({
+ ask: prev?.ask ?? false,
+ global: prev?.global ?? false,
+ query: prev?.query ?? '',
+ open: true,
+ }));
+
+ trackEvent({
+ type: 'search_open',
+ });
+ };
+
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [onClose]);
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ resultsRef.current?.moveUp();
+ } else if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ resultsRef.current?.moveDown();
+ } else if (event.key === 'Enter') {
+ event.preventDefault();
+ resultsRef.current?.select();
+ }
+ };
+
+ const onChange = (value: string) => {
+ setSearchState((prev) => ({
+ ask: false, // When typing, we go back to the default search mode
+ query: value,
+ global: prev?.global ?? false,
+ open: true,
+ }));
+ };
+
+ // We trim the query to avoid invalidating the search when the user is typing between words.
+ const normalizedQuery = state?.query.trim() ?? '';
+
+ return (
+
+
+ {isMultiVariants && !state?.ask ? (
+
+ ) : null}
+ {state !== null && !state.ask ? (
+
+ ) : null}
+ {state?.ask ? : null}
+
+ ) : null
+ }
+ rootProps={{
+ open: open,
+ modal: isMobile,
+ }}
+ contentProps={{
+ onOpenAutoFocus: (event) => event.preventDefault(),
+ align: 'start',
+ className:
+ 'bg-tint-base has-[.empty]:hidden scroll-py-2 w-[32rem] p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
+ onInteractOutside: (event) => {
+ // Don't close if clicking on the search input itself
+ if (searchInputRef.current?.contains(event.target as Node)) {
+ return;
+ }
+ onClose();
+ },
+ sideOffset: 8,
+ collisionPadding: {
+ top: 16,
+ right: 16,
+ bottom: 32,
+ left: 16,
+ },
+ hideWhenDetached: true,
+ }}
+ triggerProps={{
+ asChild: true,
+ }}
+ >
+
+
+
+ );
+}
diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx
new file mode 100644
index 0000000000..4d2b6c52c0
--- /dev/null
+++ b/packages/gitbook/src/components/Search/SearchInput.tsx
@@ -0,0 +1,143 @@
+'use client';
+import React from 'react';
+import { useEffect, useRef, useState } from 'react';
+
+import { tString, useLanguage } from '@/intl/client';
+import { tcls } from '@/lib/tailwind';
+import { Icon } from '@gitbook/icons';
+import { Button, variantClasses } from '../primitives';
+import { useClassnames } from '../primitives/StyleProvider';
+
+interface SearchInputProps {
+ onChange: (value: string) => void;
+ onKeyDown: (event: React.KeyboardEvent) => void;
+ onFocus: () => void;
+ value: string;
+ withAI?: boolean;
+ isOpen: boolean;
+ className?: string;
+}
+
+// Size classes for medium size button
+const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4'];
+
+/**
+ * Input to trigger search.
+ */
+export const SearchInput = React.forwardRef(
+ function SearchInput(props, ref) {
+ const { onChange, onKeyDown, onFocus, value, withAI = false, isOpen, className } = props;
+ const inputRef = useRef(null);
+
+ const language = useLanguage();
+ const buttonStyles = useClassnames(['ButtonStyles']);
+
+ useEffect(() => {
+ if (isOpen) {
+ if (document.activeElement !== inputRef.current) {
+ // Refocus the input and move the caret to the end – do this only once to avoid scroll jumps on every keystroke
+ inputRef.current?.focus({ preventScroll: true });
+ // Place cursor at the end of the input
+ inputRef.current?.setSelectionRange(value.length, value.length);
+ }
+ } else {
+ inputRef.current?.blur();
+ }
+ }, [isOpen, value]);
+
+ return (
+
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: this div needs an onClick to show the input on mobile, where it's normally hidden.
+ Normally you'd also need to add a keyboard trigger to do the same without a pointer, but in this case the input already be focused on its own. */}
+
-
+
);
});
@@ -92,7 +49,7 @@ function highlightQueryInBody(body: string, query: string) {
// Ensure the query to be highlighted is visible in the body.
return (
-
+
);
diff --git a/packages/gitbook/src/components/Search/index.ts b/packages/gitbook/src/components/Search/index.ts
index a316dad375..73977f05ae 100644
--- a/packages/gitbook/src/components/Search/index.ts
+++ b/packages/gitbook/src/components/Search/index.ts
@@ -1,2 +1,2 @@
-export * from './SearchButton';
-export * from './SearchModal';
+export * from './SearchInput';
+export * from './SearchContainer';
diff --git a/packages/gitbook/src/components/Search/useSearch.ts b/packages/gitbook/src/components/Search/useSearch.ts
index 8f3ecfaec5..70bb1a34fb 100644
--- a/packages/gitbook/src/components/Search/useSearch.ts
+++ b/packages/gitbook/src/components/Search/useSearch.ts
@@ -4,9 +4,13 @@ import React from 'react';
import type { LinkProps } from '../primitives';
export interface SearchState {
+ // URL-backed state
query: string;
ask: boolean;
global: boolean;
+
+ // Local UI state
+ open: boolean;
}
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
@@ -28,13 +32,24 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
history: 'replace',
});
+ // Separate local state for open (not synchronized with URL)
+ // Default to true if there's already a query in the URL
+ const [open, setIsOpen] = React.useState(() => {
+ return rawState?.q !== null;
+ });
+
const state = React.useMemo(() => {
if (rawState === null || rawState.q === null) {
return null;
}
- return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
- }, [rawState]);
+ return {
+ query: rawState.q,
+ ask: !!rawState.ask,
+ global: !!rawState.global,
+ open: open,
+ };
+ }, [rawState, open]);
const stateRef = React.useRef(state);
React.useLayoutEffect(() => {
@@ -50,12 +65,17 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
}
if (update === null) {
+ setIsOpen(false);
return setRawState({
q: null,
ask: null,
global: null,
});
}
+
+ // Update the local state
+ setIsOpen(update.open);
+
return setRawState({
q: update.query,
ask: update.ask ? true : null,
@@ -78,8 +98,8 @@ export function useSearchLink(): (query: Partial) => LinkProps {
(query) => {
const searchParams = new URLSearchParams();
searchParams.set('q', query.query ?? '');
- query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
- query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
+ query.ask ? searchParams.set('ask', 'true') : searchParams.delete('ask');
+ query.global ? searchParams.set('global', 'true') : searchParams.delete('global');
return {
href: `?${searchParams.toString()}`,
prefetch: false,
@@ -89,6 +109,7 @@ export function useSearchLink(): (query: Partial) => LinkProps {
query: '',
ask: false,
global: false,
+ open: true,
...(prev ?? {}),
...query,
}));
diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
index 5f56bf2db1..e667aa187b 100644
--- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
+++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx
@@ -95,6 +95,9 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
? 'dark light'
: 'light dark'
: customization.themes.default,
+ width: 'device-width',
+ initialScale: 1,
+ maximumScale: 1,
};
}
diff --git a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx
index b078fd94d2..d4eb32653f 100644
--- a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx
+++ b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
export function PageClientLayout(props: { withSections?: boolean }) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
- useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
+ useScrollPage({ scrollMarginTop: props.withSections ? 108 : 64 });
useStripFallbackQueryParam();
return null;
diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
index 4f7e76a37a..0c0efdb1ed 100644
--- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
+++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
@@ -4,20 +4,18 @@ import React from 'react';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
-import { SearchButton, SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents';
import { CONTAINER_STYLE } from '@/components/layout';
-import { getSpaceLanguage } from '@/intl/server';
-import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
-import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env';
import { AIChat } from '../AIChat';
+import { AIChatButton } from '../AIChat';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
+import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
@@ -51,7 +49,22 @@ export function SpaceLayout(props: {
customization.footer.logo ||
customization.footer.groups?.length;
- const withAIChat = isAIChatEnabled(context);
+ const aiMode = customization.ai?.mode;
+
+ const searchAndAI = (
+
@@ -103,7 +112,7 @@ export function SpaceLayout(props: {
className={tcls(
'hidden',
'pr-4',
- 'lg:flex',
+ 'md:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1',
@@ -117,27 +126,7 @@ export function SpaceLayout(props: {
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
- {!withTopHeader && (
-
-
-
-
- {t(
- getSpaceLanguage(customization),
- // TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025)
- customization.aiSearch
- ?.enabled ||
- customization.ai?.mode !==
- CustomizationAIMode.None
- ? 'search_or_ask'
- : 'search'
- )}
- ...
-
-
-
-