From 91b325ca08f3a55517b96fbe57dc50f862fc0950 Mon Sep 17 00:00:00 2001 From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:18:07 +0200 Subject: [PATCH] Add AI chat reference button on text selection (#4326) --- .../gitbook/src/components/AI/references.ts | 26 ++- .../AIChat/AIChatReferenceChips.tsx | 8 +- .../AskAITextSelection/AskAITextSelection.tsx | 129 +++++++++++ .../AIChat/AskAITextSelection/index.ts | 1 + .../useStableTextSelection.ts | 202 ++++++++++++++++++ .../gitbook/src/components/AIChat/index.ts | 1 + .../src/components/PageBody/PageBody.tsx | 26 +-- .../components/SpaceLayout/SpaceLayout.tsx | 9 +- 8 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx create mode 100644 packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts create mode 100644 packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts 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 */}