Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ask-ai-paragraph-margin-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Add a hover affordance in the document margin to ask the AI Assistant about a paragraph. On devices with a fine pointer, hovering a top-level paragraph reveals a small button that stages the paragraph's text as context and opens the assistant — making the existing text-selection "Ask" flow more discoverable.
15 changes: 15 additions & 0 deletions packages/gitbook/src/components/AI/useAIChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export type AIChatState = {
* References staged on the next user message.
*/
references: AIChatReference[];

/**
* Draft text to pre-fill the chat input with, without sending it. The chat input consumes
* this value (seeding its editable content) and then clears it back to an empty string.
*/
draft: string;
};

export type AIChatEvent =
Expand Down Expand Up @@ -150,6 +156,8 @@ export type AIChatController = {
clearReferences: () => void;
/** Focus the chat input */
focus: () => void;
/** Pre-fill the chat input with draft text, without sending it. */
setDraft: (draft: string) => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
Expand All @@ -173,6 +181,7 @@ const globalState = zustand.create<AIChatState>(() => {
error: false,
initialQuery: null,
references: [],
draft: '',
};
});

Expand Down Expand Up @@ -661,6 +670,10 @@ export function AIChatProvider(props: {
notify(eventsRef.current.get('focus'), {});
}, []);

const onSetDraft = React.useCallback((draft: string) => {
globalState.setState({ draft });
}, []);

const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
Expand Down Expand Up @@ -690,6 +703,7 @@ export function AIChatProvider(props: {
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
setDraft: onSetDraft,
on: onEvent,
};
}, [
Expand All @@ -701,6 +715,7 @@ export function AIChatProvider(props: {
onRemoveReference,
onClearReferences,
onFocus,
onSetDraft,
onEvent,
]);

Expand Down
27 changes: 26 additions & 1 deletion packages/gitbook/src/components/AIChat/AIChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
Expand All @@ -24,6 +24,29 @@ export function AIChatInput(props: {

const inputRef = useRef<HTMLTextAreaElement>(null);

// Controlled value so a pre-filled draft can be injected without sending it.
const [value, setValue] = useState('');

// Consume a draft staged via the controller (e.g. the per-paragraph "Ask" button): seed the
// input, focus it with the cursor at the end, then clear the pending draft so it is applied
// once and not re-injected on a later mount.
useEffect(() => {
if (!chat.draft) {
return;
}
setValue(chat.draft);
chatController.setDraft('');
const raf = requestAnimationFrame(() => {
const el = inputRef.current;
if (el) {
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
}
});
return () => cancelAnimationFrame(raf);
}, [chat.draft, chatController]);

useEffect(() => {
if (chat.opened && !disabled && !responding) {
// Add a small delay to ensure the input is rendered before focusing
Expand Down Expand Up @@ -66,6 +89,8 @@ export function AIChatInput(props: {
sizing="large"
label="Assistant chat input"
placeholder={tString(language, 'ai_chat_input_placeholder')}
value={value}
onValueChange={setValue}
onSubmit={(val) => onSubmit(val as string)}
submitButton={{
size: 'small',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import fnv1a from '@sindresorhus/fnv1a';

import { useAIChatController, useAIConfig } from '@/components/AI';
import { Button } from '@/components/primitives';
import { t, tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';

import { getAIChatName } from '../AIChat';
import { AIChatIcon } from '../AIChatIcon';

/**
* A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on
* devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens
* the AI chat — a more discoverable variant of the text-selection "Ask" affordance.
*
* The button is absolutely positioned so it never affects the document flow, and its visibility is
* driven purely by the `group/ask-ai` hover state of its paragraph wrapper.
*/
export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) {
const { content, className } = props;
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();

const onClick = () => {
const text = content.trim();
if (!text) {
return;
}

chatController.addReference({
type: 'text',
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open();
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};

return (
<div
className={tcls(
// Sit in the left margin, flush against the paragraph so there is no hover gap.
'absolute top-0 right-full z-10 pr-1',
// Per-block nudges: `in-[…]` matches an ancestor (tag or class) with no markup
// changes elsewhere — add a self-contained rule per block type to clear its gutter.
'in-[.hint]:-top-0.5 in-[.hint]:pr-2',
'in-[blockquote]:pr-0',
// Hover affordance only: hidden until the paragraph (or the button) is hovered.
'invisible opacity-0 transition-opacity duration-150',
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
// Never shown on touch / hover-less contexts.
'not-pointer-fine:hidden',
'in-[[role=table]]:hidden',
className
)}
>
<Button
variant="blank"
size="xsmall"
iconOnly
icon={<AIChatIcon state="default" trademark={config.trademark} />}
label={t(
language,
'ai_chat_ask_about_this',
config.assistantName ?? getAIChatName(language, config.trademark)
)}
onClick={onClick}
// Don't steal focus (and shift the scroll position) when clicked with the mouse.
onMouseDown={(event) => event.preventDefault()}
className={tcls(
'bg-tint-base',
'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning'
)}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './AskAIParagraphButton';
1 change: 1 addition & 0 deletions packages/gitbook/src/components/AIChat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
export * from './AskAITextSelection';
export * from './AskAIParagraphButton';
26 changes: 25 additions & 1 deletion packages/gitbook/src/components/DocumentView/Paragraph.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { DocumentBlockParagraph } from '@gitbook/api';
import { CustomizationAIMode } from '@gitbook/api';

import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton';
import { getNodeText } from '@/lib/document';
import { tcls } from '@/lib/tailwind';

import type { BlockProps } from './Block';
Expand All @@ -8,14 +11,35 @@ import { getTextAlignment } from './utils';

export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
const { context } = contextProps;

// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
const inlineButtonStyle =
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';

return (
const paragraph = (
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
</p>
);

// Offer to ask the assistant about any paragraph, in Assistant mode, on screen.
const contentContext = context.contentContext;
const aiAssistantEnabled =
context.mode !== 'print' &&
contentContext != null &&
'customization' in contentContext &&
contentContext.customization.ai.mode === CustomizationAIMode.Assistant;

const text = aiAssistantEnabled ? getNodeText(block) : '';
if (aiAssistantEnabled && text.trim()) {
return (
<div className={tcls('group/ask-ai relative', style)}>
{paragraph}
<AskAIParagraphButton content={text} />
</div>
);
}

return paragraph;
}
14 changes: 12 additions & 2 deletions packages/gitbook/src/components/PageActions/PageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export function ActionOpenAssistant(props: {
icon={assistant.icon}
label={assistant.label}
shortLabel={tString(language, 'ask')}
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
description={tString(
language,
'ai_chat_ask_about',
assistant.label,
tString(language, 'this_page')
)}
disabled={chat.responding}
onClick={() => {
// Stage a reference to the current page so the assistant is informed about
Expand Down Expand Up @@ -213,7 +218,12 @@ export function ActionOpenInLLM(props: {
icon={provider}
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
description={tString(
language,
'ai_chat_ask_about',
providerLabel,
tString(language, 'this_page')
)}
href={getURLForLLM(provider, prompt)}
/>
);
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ export const ar: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'تعذّر فتح الصفحة',
ai_chat_tools_mcp_tool: 'تم استدعاء ${1}',
ai_chat_ask: 'اسأل ${1}',
ai_chat_ask_about_page: 'اسأل ${1} عن هذه الصفحة',
ai_chat_ask_about: 'اسأل ${1} عن ${2}',
ai_chat_ask_about_this: 'اسأل ${1} عن هذا',
this_page: 'هذه الصفحة',
ai_chat_paragraph_draft: 'أخبرني المزيد عن هذا',
ai_chat_ask_query: 'اسأل ${1} "${2}"',
copy_for_llms: 'نسخ للنماذج اللغوية الكبيرة',
copy_page_markdown: 'نسخ الصفحة بصيغة Markdown للنماذج اللغوية الكبيرة',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ export const bg: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Страницата не може да бъде отворена',
ai_chat_tools_mcp_tool: 'Извика ${1}',
ai_chat_ask: 'Попитайте ${1}',
ai_chat_ask_about_page: 'Попитайте ${1} за тази страница',
ai_chat_ask_about: 'Попитайте ${1} за ${2}',
ai_chat_ask_about_this: 'Попитайте ${1} за това',
this_page: 'тази страница',
ai_chat_paragraph_draft: 'Кажи ми повече за това',
ai_chat_ask_query: 'Попитайте ${1} "${2}"',
copy_for_llms: 'Копиране за LLM',
copy_page_markdown: 'Копиране на страницата като Markdown за LLM',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ export const cs: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Stránku se nepodařilo otevřít',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Zeptat se ${1}',
ai_chat_ask_about_page: 'Zeptat se ${1} na tuto stránku',
ai_chat_ask_about: 'Zeptat se ${1} na ${2}',
ai_chat_ask_about_this: 'Zeptat se ${1} na to',
this_page: 'tuto stránku',
ai_chat_paragraph_draft: 'Řekni mi o tom více',
ai_chat_ask_query: 'Zeptat se ${1} "${2}"',
copy_for_llms: 'Kopírovat pro LLM',
copy_page_markdown: 'Kopírovat stránku jako Markdown pro LLM',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ export const da: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Kunne ikke åbne siden',
ai_chat_tools_mcp_tool: 'Kaldte ${1}',
ai_chat_ask: 'Spørg ${1}',
ai_chat_ask_about_page: 'Spørg ${1} om denne side',
ai_chat_ask_about: 'Spørg ${1} om ${2}',
ai_chat_ask_about_this: 'Spørg ${1} om dette',
this_page: 'denne side',
ai_chat_paragraph_draft: 'Fortæl mig mere om dette',
ai_chat_ask_query: 'Spørg ${1} "${2}"',
copy_for_llms: 'Kopiér til LLMer',
copy_page_markdown: 'Kopiér siden som Markdown til LLMer',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ export const de: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Seite konnte nicht geöffnet werden',
ai_chat_tools_mcp_tool: '${1} aufgerufen',
ai_chat_ask: '${1} fragen',
ai_chat_ask_about_page: '${1} zu dieser Seite befragen',
ai_chat_ask_about: '${1} zu ${2} befragen',
ai_chat_ask_about_this: '${1} dazu befragen',
this_page: 'dieser Seite',
ai_chat_paragraph_draft: 'Erzähl mir mehr darüber',
copy_for_llms: 'Für LLMs kopieren',
copy_page_markdown: 'Seite als Markdown für LLMs kopieren',
copy_page: 'Seite kopieren',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ export const el: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Αποτυχία ανοίγματος της σελίδας',
ai_chat_tools_mcp_tool: 'Κλήθηκε ${1}',
ai_chat_ask: 'Ρωτήστε ${1}',
ai_chat_ask_about_page: 'Ρωτήστε ${1} για αυτήν τη σελίδα',
ai_chat_ask_about: 'Ρωτήστε ${1} για ${2}',
ai_chat_ask_about_this: 'Ρωτήστε ${1} για αυτό',
this_page: 'αυτή τη σελίδα',
ai_chat_paragraph_draft: 'Πες μου περισσότερα για αυτό',
ai_chat_ask_query: 'Ρωτήστε ${1} "${2}"',
copy_for_llms: 'Αντιγραφή για LLM',
copy_page_markdown: 'Αντιγραφή σελίδας ως Markdown για LLM',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,11 @@ export const en = {
ai_chat_tools_navigate_failed: 'Failed to open the page',
ai_chat_tools_mcp_tool: 'Called ${1}',
ai_chat_ask: 'Ask ${1}',
ai_chat_ask_about_page: 'Ask ${1} about this page',
ai_chat_ask_about: 'Ask ${1} about ${2}',
ai_chat_ask_about_this: 'Ask ${1} about this',
ai_chat_ask_query: 'Ask ${1} "${2}"',
this_page: 'this page',
ai_chat_paragraph_draft: 'Tell me more about this',
copy_for_llms: 'Copy for LLMs',
copy_page_markdown: 'Copy page as Markdown for LLMs',
copy_page: 'Copy page',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ export const es: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'No se pudo abrir la página',
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
ai_chat_ask: 'Preguntar a ${1}',
ai_chat_ask_about_page: 'Preguntar a ${1} sobre esta página',
ai_chat_ask_about: 'Preguntar a ${1} sobre ${2}',
ai_chat_ask_about_this: 'Preguntar a ${1} sobre esto',
this_page: 'esta página',
ai_chat_paragraph_draft: 'Cuéntame más sobre esto',
copy_for_llms: 'Copiar para LLMs',
copy_page_markdown: 'Copiar página como Markdown para LLMs',
copy_page: 'Copiar página',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/et.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ export const et: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Lehe avamine ebaõnnestus',
ai_chat_tools_mcp_tool: 'Kutsus ${1}',
ai_chat_ask: 'Küsi ${1}',
ai_chat_ask_about_page: 'Küsi ${1} selle lehe kohta',
ai_chat_ask_about: 'Küsi ${1}: ${2}',
ai_chat_ask_about_this: 'Küsi ${1} käest selle kohta',
this_page: 'seda lehte',
ai_chat_paragraph_draft: 'Räägi mulle sellest lähemalt',
ai_chat_ask_query: 'Küsi ${1} "${2}"',
copy_for_llms: 'Kopeeri LLM-idele',
copy_page_markdown: 'Kopeeri leht Markdownina LLM-idele',
Expand Down
5 changes: 4 additions & 1 deletion packages/gitbook/src/intl/translations/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@ export const fi: TranslationLanguage = {
ai_chat_tools_navigate_failed: 'Sivun avaaminen epäonnistui',
ai_chat_tools_mcp_tool: 'Kutsuttiin ${1}',
ai_chat_ask: 'Kysy ${1}',
ai_chat_ask_about_page: 'Kysy ${1} tältä sivulta',
ai_chat_ask_about: 'Kysy ${1}: ${2}',
ai_chat_ask_about_this: 'Kysy ${1}:lta tästä',
this_page: 'tätä sivua',
ai_chat_paragraph_draft: 'Kerro minulle tästä lisää',
ai_chat_ask_query: 'Kysy ${1}: "${2}"',
copy_for_llms: 'Kopioi LLM:ille',
copy_page_markdown: 'Kopioi sivu Markdownina LLM:ille',
Expand Down
Loading
Loading