diff --git a/packages/gitbook/src/components/AI/references.ts b/packages/gitbook/src/components/AI/references.ts
index d649b0611..76b162ff9 100644
--- a/packages/gitbook/src/components/AI/references.ts
+++ b/packages/gitbook/src/components/AI/references.ts
@@ -19,7 +19,13 @@ export type PageReference = BaseAIChatReference & {
href?: string;
};
-export type AIChatReference = CodeBlockReference | PageReference;
+export type TextReference = BaseAIChatReference & {
+ type: 'text';
+ /** The selected text content. */
+ content: string;
+};
+
+export type AIChatReference = CodeBlockReference | PageReference | TextReference;
/**
* Serialize the staged references into a preamble prepended to the user's message,
@@ -42,6 +48,11 @@ export function serializeReferences(refs: AIChatReference[]): string {
sections.push(serializeCodeBlockReferences(codeRefs));
}
+ const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
+ if (textRefs.length > 0) {
+ sections.push(serializeTextReferences(textRefs));
+ }
+
if (sections.length === 0) {
return '';
}
@@ -66,6 +77,19 @@ function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
+function serializeTextReferences(refs: TextReference[]): string {
+ const plural = refs.length > 1;
+ const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
+ return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
+}
+
+function quoteText(content: string): string {
+ return content
+ .split('\n')
+ .map((line) => `> ${line}`)
+ .join('\n');
+}
+
function buildCodeBlockFence(ref: CodeBlockReference): string {
const { label, content, syntax } = ref;
let max = 2;
diff --git a/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx b/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx
index daa91886a..d7db9df2f 100644
--- a/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx
+++ b/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx
@@ -33,7 +33,7 @@ export function AIChatReferenceChips(props: {
ref.type === 'code-block' && 'font-mono'
)}
>
- {ref.label}
+ {ref.type === 'text' ? ref.content : ref.label}
>
);
@@ -55,6 +55,10 @@ export function AIChatReferenceChips(props: {
>
{content}
+ ) : ref.type === 'text' ? (
+ // A text selection has no persistent DOM anchor to navigate to, so the
+ // excerpt is shown as plain (non-interactive) content.
+ {content}
) : (
+
+ ) : null}
+ ,
+ document.body
+ );
+}
diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts b/packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts
new file mode 100644
index 000000000..5e6f83c85
--- /dev/null
+++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts
@@ -0,0 +1 @@
+export * from './AskAITextSelection';
diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts b/packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts
new file mode 100644
index 000000000..ae0e92f3c
--- /dev/null
+++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts
@@ -0,0 +1,202 @@
+'use client';
+
+import * as React from 'react';
+
+type SelectionAnchor = {
+ top: number;
+ bottom: number;
+ centerX: number;
+};
+
+export type StableTextSelection = {
+ anchor: SelectionAnchor;
+ text: string;
+};
+
+type Options = {
+ /** Selector of the content root; both selection endpoints must be inside it. */
+ rootSelector: string;
+ enabled: boolean;
+ /** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
+ ignoreRef: React.RefObject;
+};
+
+const KEYBOARD_DEBOUNCE_MS = 250;
+const SCROLL_SETTLE_MS = 200;
+// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
+const POINTER_SETTLE_MS = 120;
+
+/**
+ * Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
+ * doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
+ * when a new gesture starts.
+ */
+export function useStableTextSelection(options: Options): {
+ selection: StableTextSelection | null;
+ clear: () => void;
+} {
+ const { rootSelector, enabled, ignoreRef } = options;
+ const [selection, setSelection] = React.useState(null);
+
+ const clear = React.useCallback(() => {
+ window.getSelection()?.removeAllRanges();
+ setSelection(null);
+ }, []);
+
+ React.useEffect(() => {
+ if (!enabled) {
+ setSelection(null);
+ return;
+ }
+
+ let pointerDown = false;
+ let keyboardTimer: ReturnType | null = null;
+ let scrollTimer: ReturnType | null = null;
+ let finalizeTimer: ReturnType | null = null;
+
+ const clearKeyboardTimer = () => {
+ if (keyboardTimer) {
+ clearTimeout(keyboardTimer);
+ keyboardTimer = null;
+ }
+ };
+
+ const clearFinalizeTimer = () => {
+ if (finalizeTimer) {
+ clearTimeout(finalizeTimer);
+ finalizeTimer = null;
+ }
+ };
+
+ const finalize = () => {
+ // A new gesture started; it will reschedule its own finalize.
+ if (pointerDown) {
+ return;
+ }
+ setSelection(readStableSelection(rootSelector));
+ };
+
+ const schedulePointerFinalize = () => {
+ clearKeyboardTimer();
+ clearFinalizeTimer();
+ finalizeTimer = setTimeout(() => {
+ finalizeTimer = null;
+ finalize();
+ }, POINTER_SETTLE_MS);
+ };
+
+ const isIgnored = (target: EventTarget | null) =>
+ target instanceof Node && !!ignoreRef.current?.contains(target);
+
+ const onSelectionChange = () => {
+ const sel = window.getSelection();
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
+ clearKeyboardTimer();
+ setSelection(null);
+ return;
+ }
+ // Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
+ if (pointerDown || finalizeTimer) {
+ return;
+ }
+ clearKeyboardTimer();
+ keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
+ };
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (isIgnored(event.target)) {
+ return;
+ }
+ // New gesture: hide and cancel a pending show so it can't fire mid double-click.
+ pointerDown = true;
+ clearFinalizeTimer();
+ setSelection(null);
+ };
+
+ const onPointerUp = (event: PointerEvent) => {
+ if (isIgnored(event.target)) {
+ return;
+ }
+ pointerDown = false;
+ schedulePointerFinalize();
+ };
+
+ // pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
+ const onPointerCancel = () => {
+ pointerDown = false;
+ schedulePointerFinalize();
+ };
+
+ const onScroll = () => {
+ setSelection(null);
+ if (scrollTimer) {
+ clearTimeout(scrollTimer);
+ }
+ scrollTimer = setTimeout(() => {
+ if (!pointerDown) {
+ finalize();
+ }
+ }, SCROLL_SETTLE_MS);
+ };
+
+ const onWindowBlur = () => {
+ clearKeyboardTimer();
+ setSelection(null);
+ };
+
+ document.addEventListener('selectionchange', onSelectionChange);
+ document.addEventListener('pointerdown', onPointerDown, true);
+ document.addEventListener('pointerup', onPointerUp, true);
+ document.addEventListener('pointercancel', onPointerCancel, true);
+ window.addEventListener('scroll', onScroll, true);
+ window.addEventListener('blur', onWindowBlur);
+
+ return () => {
+ document.removeEventListener('selectionchange', onSelectionChange);
+ document.removeEventListener('pointerdown', onPointerDown, true);
+ document.removeEventListener('pointerup', onPointerUp, true);
+ document.removeEventListener('pointercancel', onPointerCancel, true);
+ window.removeEventListener('scroll', onScroll, true);
+ window.removeEventListener('blur', onWindowBlur);
+ clearKeyboardTimer();
+ clearFinalizeTimer();
+ if (scrollTimer) {
+ clearTimeout(scrollTimer);
+ }
+ };
+ }, [enabled, rootSelector, ignoreRef]);
+
+ return { selection, clear };
+}
+
+function readStableSelection(rootSelector: string): StableTextSelection | null {
+ const sel = window.getSelection();
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
+ return null;
+ }
+
+ const text = sel.toString().trim();
+ if (!text) {
+ return null;
+ }
+
+ const root = document.querySelector(rootSelector);
+ if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
+ return null;
+ }
+
+ const anchor = getSelectionAnchor(sel);
+ return anchor ? { anchor, text } : null;
+}
+
+/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
+function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
+ const rect = sel.getRangeAt(0).getBoundingClientRect();
+ if (!rect.width && !rect.height) {
+ return null;
+ }
+ if (rect.bottom < 0 || rect.top > window.innerHeight) {
+ return null;
+ }
+ return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
+}
diff --git a/packages/gitbook/src/components/AIChat/index.ts b/packages/gitbook/src/components/AIChat/index.ts
index 12eaa899e..57307673a 100644
--- a/packages/gitbook/src/components/AIChat/index.ts
+++ b/packages/gitbook/src/components/AIChat/index.ts
@@ -3,3 +3,4 @@ export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
+export * from './AskAITextSelection';
diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx
index a24d73140..adb97b94d 100644
--- a/packages/gitbook/src/components/PageBody/PageBody.tsx
+++ b/packages/gitbook/src/components/PageBody/PageBody.tsx
@@ -106,18 +106,20 @@ export async function PageBody(props: {
fallback={}
>
-
+
+
+
) : (
diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
index 20673d49f..9b0a26133 100644
--- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
+++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
@@ -14,7 +14,7 @@ import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
-import { AIChat } from '../AIChat';
+import { AIChat, AskAITextSelection } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
@@ -127,7 +127,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
- {customization.ai?.mode === CustomizationAIMode.Assistant ? : null}
+ {customization.ai?.mode === CustomizationAIMode.Assistant ? (
+ <>
+
+
+ >
+ ) : null}
{/* Chat panel shifts content left when open */}