Add AI chat reference button on text selection (#4326)

This commit is contained in:
Nolann B.
2026-06-19 10:18:07 +02:00
committed by GitHub
parent 1616028426
commit 91b325ca08
8 changed files with 386 additions and 16 deletions
@@ -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;
@@ -33,7 +33,7 @@ export function AIChatReferenceChips(props: {
ref.type === 'code-block' && 'font-mono'
)}
>
{ref.label}
{ref.type === 'text' ? ref.content : ref.label}
</span>
</>
);
@@ -55,6 +55,10 @@ export function AIChatReferenceChips(props: {
>
{content}
</Link>
) : ref.type === 'text' ? (
// A text selection has no persistent DOM anchor to navigate to, so the
// excerpt is shown as plain (non-interactive) content.
<span className={triggerClassName}>{content}</span>
) : (
<button
type="button"
@@ -94,6 +98,8 @@ function getReferenceIcon(ref: AIChatReference): IconName {
return 'code';
case 'page':
return 'memo';
case 'text':
return 'quote-left';
default:
assertNever(ref);
}
@@ -0,0 +1,129 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import fnv1a from '@sindresorhus/fnv1a';
import { AnimatePresence, motion } from 'motion/react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { useIsMobile } from '@/components/hooks/useIsMobile';
import { useIsMounted } from '@/components/hooks/useIsMounted';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { AIChatIcon } from '../AIChatIcon';
import { useStableTextSelection } from './useStableTextSelection';
/** Gap between the selection and the button. */
const GAP = 8;
/** Minimum distance to the viewport edges. */
const MARGIN = 8;
/**
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
*/
export function AskAITextSelection() {
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
const toolbarRef = React.useRef<HTMLDivElement>(null);
const { selection, clear } = useStableTextSelection({
rootSelector: '[data-content-ref-root]',
enabled,
ignoreRef: toolbarRef,
});
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
// Position once the button has been measured, so it can be centered and clamped to the viewport.
React.useLayoutEffect(() => {
if (!selection) {
return;
}
const el = toolbarRef.current;
if (!el) {
return;
}
const width = el.offsetWidth;
const height = el.offsetHeight;
const { anchor } = selection;
let top = anchor.top - GAP - height;
if (top < MARGIN) {
// Not enough room above the selection: drop below it.
top = anchor.bottom + GAP;
}
top = Math.min(top, window.innerHeight - height - MARGIN);
const left = Math.min(
Math.max(anchor.centerX - width / 2, MARGIN),
window.innerWidth - width - MARGIN
);
setCoords({ top, left });
}, [selection]);
const onClick = () => {
if (!selection) {
return;
}
const content = selection.text;
if (!content.trim()) {
return;
}
chatController.addReference({
type: 'text',
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.focus();
clear();
};
if (!enabled || !isMounted) {
return null;
}
return createPortal(
<AnimatePresence>
{selection ? (
<motion.div
ref={toolbarRef}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
zIndex: 40,
}}
// Keep the selection alive: prevent the button from stealing focus on click.
onMouseDown={(event) => event.preventDefault()}
>
<Button
size="small"
variant="primary"
icon={<AIChatIcon state="default" trademark={config.trademark} />}
onClick={onClick}
className="shadow-sm"
>
{t(language, 'ask')}
</Button>
</motion.div>
) : null}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1 @@
export * from './AskAITextSelection';
@@ -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<HTMLElement | null>;
};
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<StableTextSelection | null>(null);
const clear = React.useCallback(() => {
window.getSelection()?.removeAllRanges();
setSelection(null);
}, []);
React.useEffect(() => {
if (!enabled) {
setSelection(null);
return;
}
let pointerDown = false;
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
let finalizeTimer: ReturnType<typeof setTimeout> | 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 };
}
@@ -3,3 +3,4 @@ export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
export * from './AskAITextSelection';
@@ -106,18 +106,20 @@ export async function PageBody(props: {
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
>
<SuspenseLoadedHint />
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
context={{
mode: 'default',
contentContext: {
...context,
page,
},
withLinkPreviews,
}}
/>
<div className="contents" data-content-ref-root="">
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
context={{
mode: 'default',
contentContext: {
...context,
page,
},
withLinkPreviews,
}}
/>
</div>
</OptionalSuspense>
) : (
<PageBodyBlankslate page={page} context={context} />
@@ -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) {
<Announcement context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<NavigationLoader />
{customization.ai?.mode === CustomizationAIMode.Assistant ? <AIChat /> : null}
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<>
<AIChat />
<AskAITextSelection />
</>
) : null}
{/* Chat panel shifts content left when open */}
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">