From ea1c86d966a8bbd1abdd79a507193cdea7597ec9 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Thu, 3 Jul 2025 18:38:37 +0200 Subject: [PATCH 01/22] Add AIActionsDropdown --- .../AIActions/AIActionsDropdown.tsx | 174 ++++++++++++++++++ .../src/components/PageBody/PageHeader.tsx | 27 ++- .../src/components/primitives/Button.tsx | 8 +- .../components/primitives/DropdownMenu.tsx | 5 +- .../src/components/primitives/styles.ts | 12 +- 5 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx new file mode 100644 index 0000000000..ebfe999118 --- /dev/null +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -0,0 +1,174 @@ +'use client'; +import { useAIChatController, useAIChatState } from '@/components/AI/useAIChat'; +import AIChatIcon from '@/components/AIChat/AIChatIcon'; +import { Button } from '@/components/primitives/Button'; +import { DropdownMenu, DropdownMenuItem } from '@/components/primitives/DropdownMenu'; +import { tString, useLanguage } from '@/intl/client'; +import { Icon, type IconName, IconStyle } from '@gitbook/icons'; +import { useEffect, useRef } from 'react'; + +type Action = { + icon?: IconName | React.ReactNode; + label: string; + description?: string; + /** + * Whether the action is an external link. + */ + isExternal?: boolean; + onClick?: () => void; +}; + +export function AIActionsDropdown() { + const chatController = useAIChatController(); + const chat = useAIChatState(); + const language = useLanguage(); + const ref = useRef(null); + + const handleOpenAIAssistant = () => { + // Open the chat if it's not already open + if (!chat.opened) { + chatController.open(); + } + + // Send the "What is this page about?" message + chatController.postMessage({ + message: tString(language, 'ai_chat_suggested_questions_about_this_page'), + }); + }; + + const handleCopyPage = async () => { + const markdownUrl = `${window.location.href}.md`; + + // Get the page content + const markdown = await fetch(markdownUrl).then((res) => res.text()); + + // Copy the markdown to the clipboard + navigator.clipboard.writeText(markdown); + }; + + const handleViewAsMarkdown = () => { + // Open the page in Markdown format + const currentUrl = window.location.href; + const markdownUrl = `${currentUrl}.md`; + window.open(markdownUrl, '_blank'); + }; + + const actions: Action[] = [ + { + icon: 'copy', + label: 'Copy page', + description: 'Copy the page content', + onClick: handleCopyPage, + }, + { + icon: 'markdown', + label: 'View as Markdown', + description: 'Open a Markdown version of this page', + isExternal: true, + onClick: handleViewAsMarkdown, + }, + ]; + + // Get the header width with title and check if there is enough space to show the dropdown + useEffect(() => { + const getHeaderAvailableSpace = () => { + const header = document.getElementById('page-header'); + const headerTitle = header?.getElementsByTagName('h1')[0]; + + return ( + (header?.getBoundingClientRect().width ?? 0) - + (headerTitle?.getBoundingClientRect().width ?? 0) + ); + }; + + const dropdownWidth = 202; + + window.addEventListener('resize', () => { + const headerAvailableSpace = getHeaderAvailableSpace(); + if (ref.current) { + if (headerAvailableSpace <= dropdownWidth) { + ref.current.classList.add('-mt-3'); + ref.current.classList.remove('mt-3'); + } else { + ref.current.classList.remove('-mt-3'); + ref.current.classList.add('mt-3'); + } + } + }); + + window.addEventListener('load', () => { + const headerAvailableSpace = getHeaderAvailableSpace(); + if (ref.current) { + if (headerAvailableSpace <= dropdownWidth) { + ref.current.classList.add('-mt-3'); + ref.current.classList.remove('mt-3'); + } else { + ref.current.classList.remove('-mt-3'); + ref.current.classList.add('mt-3'); + } + } + }); + }, []); + + return ( +
+
+ ); +} diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 89390ce3fe..9f7c7cd831 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -1,11 +1,10 @@ -import type { RevisionPageDocument } from '@gitbook/api'; -import { Icon } from '@gitbook/icons'; -import { Fragment } from 'react'; - +import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown'; import type { GitBookSiteContext } from '@/lib/context'; import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; - +import type { RevisionPageDocument } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; +import { Fragment } from 'react'; import { PageIcon } from '../PageIcon'; import { StyledLink } from '../primitives'; @@ -23,13 +22,15 @@ export async function PageHeader(props: { return ( ); } diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 0627202169..3a87f92059 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -26,13 +26,13 @@ export const variantClasses = { 'text-contrast-primary-solid', 'hover:bg-primary-solid-hover', 'hover:text-contrast-primary-solid-hover', - 'ring-0', - 'contrast-more:ring-1', + 'border-0', + 'contrast-more:border-1', ], blank: [ 'bg-transparent', 'text-tint', - 'ring-0', + 'border-0', 'shadow-none', 'hover:bg-primary-hover', 'hover:text-primary', @@ -115,5 +115,5 @@ export function Button({ ); - return iconOnly ? {button} : button; + return iconOnly && label ? {button} : button; } diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index be4a12a4c3..2e5dd431fb 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -27,8 +27,10 @@ export function DropdownMenu(props: { className?: ClassValue; /** Open the dropdown on hover */ openOnHover?: boolean; + /** Props to pass to the content */ + contentProps?: RadixDropdownMenu.DropdownMenuContentProps; }) { - const { button, children, className, openOnHover = false } = props; + const { button, children, className, openOnHover = false, contentProps } = props; const [hovered, setHovered] = useState(false); const [clicked, setClicked] = useState(false); @@ -57,6 +59,7 @@ export function DropdownMenu(props: { onMouseLeave={() => setHovered(false)} align="start" className="z-40 animate-scaleIn pt-2" + {...contentProps} >
Date: Fri, 4 Jul 2025 10:33:38 +0200 Subject: [PATCH 02/22] Update --- .../AIActions/AIActionsDropdown.tsx | 121 +++++-------- .../src/components/PageBody/PageHeader.tsx | 49 ++--- .../src/components/primitives/Button.tsx | 31 ++-- packages/gitbook/src/lib/markdownPage.ts | 168 ++++++++++++++++++ packages/gitbook/src/routes/markdownPage.ts | 141 +-------------- 5 files changed, 263 insertions(+), 247 deletions(-) create mode 100644 packages/gitbook/src/lib/markdownPage.ts diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index ebfe999118..dd6b3904ea 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -1,13 +1,14 @@ 'use client'; + import { useAIChatController, useAIChatState } from '@/components/AI/useAIChat'; import AIChatIcon from '@/components/AIChat/AIChatIcon'; import { Button } from '@/components/primitives/Button'; import { DropdownMenu, DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; -type Action = { +type AIAction = { icon?: IconName | React.ReactNode; label: string; description?: string; @@ -18,7 +19,12 @@ type Action = { onClick?: () => void; }; -export function AIActionsDropdown() { +export function AIActionsDropdown(props: { + markdown?: string; + markdownUrl: string; +}) { + const { markdown, markdownUrl } = props; + const chatController = useAIChatController(); const chat = useAIChatState(); const language = useLanguage(); @@ -36,82 +42,45 @@ export function AIActionsDropdown() { }); }; - const handleCopyPage = async () => { - const markdownUrl = `${window.location.href}.md`; - - // Get the page content - const markdown = await fetch(markdownUrl).then((res) => res.text()); - - // Copy the markdown to the clipboard - navigator.clipboard.writeText(markdown); - }; - - const handleViewAsMarkdown = () => { - // Open the page in Markdown format - const currentUrl = window.location.href; - const markdownUrl = `${currentUrl}.md`; - window.open(markdownUrl, '_blank'); - }; - - const actions: Action[] = [ + const actions: AIAction[] = [ { - icon: 'copy', - label: 'Copy page', - description: 'Copy the page content', - onClick: handleCopyPage, + icon: , + label: 'Ask Docs Assistant', + description: 'Ask our Docs Assistant about this page', + onClick: () => {}, }, + ...(markdown + ? [ + { + icon: 'copy', + label: 'Copy for LLMs', + description: 'Copy page as Markdown', + onClick: () => { + if (!markdown) return; + navigator.clipboard.writeText(markdown); + }, + }, + ] + : []), { - icon: 'markdown', + icon: ( + + markdown icon + + + + ), label: 'View as Markdown', - description: 'Open a Markdown version of this page', + description: 'View this page as plain text', isExternal: true, - onClick: handleViewAsMarkdown, + onClick: () => { + window.open(markdownUrl, '_blank'); + }, }, ]; - // Get the header width with title and check if there is enough space to show the dropdown - useEffect(() => { - const getHeaderAvailableSpace = () => { - const header = document.getElementById('page-header'); - const headerTitle = header?.getElementsByTagName('h1')[0]; - - return ( - (header?.getBoundingClientRect().width ?? 0) - - (headerTitle?.getBoundingClientRect().width ?? 0) - ); - }; - - const dropdownWidth = 202; - - window.addEventListener('resize', () => { - const headerAvailableSpace = getHeaderAvailableSpace(); - if (ref.current) { - if (headerAvailableSpace <= dropdownWidth) { - ref.current.classList.add('-mt-3'); - ref.current.classList.remove('mt-3'); - } else { - ref.current.classList.remove('-mt-3'); - ref.current.classList.add('mt-3'); - } - } - }); - - window.addEventListener('load', () => { - const headerAvailableSpace = getHeaderAvailableSpace(); - if (ref.current) { - if (headerAvailableSpace <= dropdownWidth) { - ref.current.classList.add('-mt-3'); - ref.current.classList.remove('mt-3'); - } else { - ref.current.classList.remove('-mt-3'); - ref.current.classList.add('mt-3'); - } - } - }); - }, []); - return ( -
+
) : null} -
- - {action.label} +
+ + + {action.label} + {action.isExternal ? ( ) : null} diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 9f7c7cd831..51cad017f9 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -1,5 +1,6 @@ import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown'; import type { GitBookSiteContext } from '@/lib/context'; +import { getMarkdownForPage } from '@/lib/markdownPage'; import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; import type { RevisionPageDocument } from '@gitbook/api'; @@ -16,13 +17,14 @@ export async function PageHeader(props: { const { context, page, ancestors } = props; const { revision, linker } = context; + const markdownResult = await getMarkdownForPage(context, page.path); + if (!page.layout.title && !page.layout.description) { return null; } return ( ); } diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 3a87f92059..5069db59d7 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -74,6 +74,19 @@ export function Button({ const domClassName = tcls(variantClasses[variant], sizeClasses, className); const buttonOnlyClassNames = useClassnames(['ButtonStyles']); + const content = ( + <> + {icon ? ( + typeof icon === 'string' ? ( + + ) : ( + icon + ) + ) : null} + {iconOnly ? null : label} + + ); + if (href) { return ( - {icon ? ( - typeof icon === 'string' ? ( - - ) : ( - icon - ) - ) : null} - {iconOnly ? null : label} + {content} ); } @@ -104,14 +110,7 @@ export function Button({ aria-label={label} {...rest} > - {icon ? ( - typeof icon === 'string' ? ( - - ) : ( - icon - ) - ) : null} - {iconOnly ? null : label} + {content} ); diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts new file mode 100644 index 0000000000..41b06823ec --- /dev/null +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -0,0 +1,168 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import type { DataFetcherResponse } from '@/lib/data'; +import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; +import { getIndexablePages } from '@/lib/sitemap'; +import { getMarkdownForPagesTree } from '@/routes/llms'; +import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api'; +import type { Root } from 'mdast'; +import { toMarkdown } from 'mdast-util-to-markdown'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +type MarkdownResult = DataFetcherResponse; + +/** + * Generate a markdown version of a page. + * Handles both regular document pages and group pages (pages with child pages). + */ +export async function getMarkdownForPage( + context: GitBookSiteContext, + pagePath: string +): Promise { + const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath); + + if (!pageLookup) { + return { + error: { + message: `Page "${pagePath}" not found`, + code: 404, + }, + }; + } + + const { page } = pageLookup; + + // Only handle documents and groups + if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) { + return { + error: { + message: `Page "${pagePath}" is not a document or group`, + code: 400, + }, + }; + } + + // Handle group pages + if (page.type === RevisionPageType.Group) { + return servePageGroup(context, page); + } + + const { data: markdown } = await context.dataFetcher.getRevisionPageMarkdown({ + spaceId: context.space.id, + revisionId: context.revision.id, + pageId: page.id, + }); + + if (!markdown) { + return { + error: { + message: 'An error occurred while fetching the markdown for this page', + code: 500, + }, + }; + } + + // Handle empty document pages which have children + if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) { + return servePageGroup(context, page); + } + + return { + data: markdown, + }; +} + +/** + * Determine if a page is empty. + * A page is empty if it has no content or only a title. + */ +function isEmptyMarkdownPage(markdown: string): boolean { + // Remove frontmatter + const stripped = markdown + .trim() + .replace(/^---\n[\s\S]*?\n---\n?/g, '') + .trim(); + + // Fast path: try to quickly detect obvious matches + if (/^[ \t]*# .+$/m.test(stripped)) { + // If there's a single heading line or empty lines, and no other content + return ( + /^#{1,6} .+\s*$/.test(stripped) && + !/\n\S+/g.test(stripped.split('\n').slice(1).join('\n')) + ); + } + + // Fallback: parse with remark for safety + const tree = unified().use(remarkParse).parse(stripped) as Root; + + let seenHeading = false; + + for (const node of tree.children) { + if (node.type === 'heading') { + if (seenHeading) { + return false; + } + seenHeading = true; + continue; + } + + // Allow empty whitespace-only text nodes (e.g., extra newlines) + if ( + node.type === 'paragraph' && + node.children.length === 1 && + node.children[0].type === 'text' && + !node.children[0].value.trim() + ) { + continue; + } + + // Anything else is disallowed + return false; + } + + return seenHeading; +} + +/** + * Generate markdown for a group page by creating a page listing. + * Creates a markdown document with the page title as heading and a list of child pages. + */ +async function servePageGroup( + context: GitBookSiteContext, + page: RevisionPageDocument | RevisionPageGroup +): Promise { + const siteSpaceUrl = context.space.urls.published; + if (!siteSpaceUrl) { + return { + error: { + message: `Page "${page.title}" is not published`, + code: 404, + }, + }; + } + + const indexablePages = getIndexablePages(page.pages); + + // Create a markdown tree with the page title as heading and a list of child pages + const markdownTree: Root = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: page.title }], + }, + ...(await getMarkdownForPagesTree(indexablePages, { + siteSpaceUrl, + linker: context.linker, + withMarkdownPages: true, + })), + ], + }; + + return { + data: toMarkdown(markdownTree, { + bullet: '-', + }), + }; +} diff --git a/packages/gitbook/src/routes/markdownPage.ts b/packages/gitbook/src/routes/markdownPage.ts index 83c004a66e..fbc031c989 100644 --- a/packages/gitbook/src/routes/markdownPage.ts +++ b/packages/gitbook/src/routes/markdownPage.ts @@ -1,148 +1,17 @@ import type { GitBookSiteContext } from '@/lib/context'; import { throwIfDataError } from '@/lib/data'; -import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; -import { getIndexablePages } from '@/lib/sitemap'; -import { getMarkdownForPagesTree } from '@/routes/llms'; -import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api'; -import type { Root } from 'mdast'; -import { toMarkdown } from 'mdast-util-to-markdown'; -import remarkParse from 'remark-parse'; -import { unified } from 'unified'; +import { getMarkdownForPage } from '@/lib/markdownPage'; /** - * Generate a markdown version of a page. - * Handles both regular document pages and group pages (pages with child pages). + * Serve a markdown version of a page. + * Returns a 404 if the page is not found. */ export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) { - const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath); + const result = await throwIfDataError(getMarkdownForPage(context, pagePath)); - if (!pageLookup) { - return new Response(`Page "${pagePath}" not found`, { status: 404 }); - } - - const { page } = pageLookup; - - // Only handle documents and groups - if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) { - return new Response(`Page "${pagePath}" is not a document or group`, { status: 404 }); - } - - // Handle group pages - if (page.type === RevisionPageType.Group) { - return servePageGroup(context, page); - } - - const markdown = await throwIfDataError( - context.dataFetcher.getRevisionPageMarkdown({ - spaceId: context.space.id, - revisionId: context.revisionId, - pageId: page.id, - }) - ); - - // Handle empty document pages which have children - if (isEmptyMarkdownPage(markdown) && page.pages.length > 0) { - return servePageGroup(context, page); - } - - return new Response(markdown, { + return new Response(result, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', }, }); } - -/** - * Determine if a page is empty. - * A page is empty if it has no content or only a title. - */ -function isEmptyMarkdownPage(markdown: string): boolean { - // Remove frontmatter - const stripped = markdown - .trim() - .replace(/^---\n[\s\S]*?\n---\n?/g, '') - .trim(); - - // Fast path: try to quickly detect obvious matches - if (/^[ \t]*# .+$/m.test(stripped)) { - // If there's a single heading line or empty lines, and no other content - return ( - /^#{1,6} .+\s*$/.test(stripped) && - !/\n\S+/g.test(stripped.split('\n').slice(1).join('\n')) - ); - } - - // Fallback: parse with remark for safety - const tree = unified().use(remarkParse).parse(stripped) as Root; - - let seenHeading = false; - - for (const node of tree.children) { - if (node.type === 'heading') { - if (seenHeading) { - return false; - } - seenHeading = true; - continue; - } - - // Allow empty whitespace-only text nodes (e.g., extra newlines) - if ( - node.type === 'paragraph' && - node.children.length === 1 && - node.children[0].type === 'text' && - !node.children[0].value.trim() - ) { - continue; - } - - // Anything else is disallowed - return false; - } - - return seenHeading; -} - -/** - * Generate markdown for a group page by creating a page listing. - * Creates a markdown document with the page title as heading and a list of child pages. - */ -async function servePageGroup( - context: GitBookSiteContext, - page: RevisionPageDocument | RevisionPageGroup -) { - const siteSpaceUrl = context.space.urls.published; - if (!siteSpaceUrl) { - return new Response(`Page "${page.title}" is not published`, { status: 404 }); - } - - const indexablePages = getIndexablePages(page.pages); - - // Create a markdown tree with the page title as heading and a list of child pages - const markdownTree: Root = { - type: 'root', - children: [ - { - type: 'heading', - depth: 1, - children: [{ type: 'text', value: page.title }], - }, - ...(await getMarkdownForPagesTree(indexablePages, { - siteSpaceUrl, - linker: context.linker, - withMarkdownPages: true, - })), - ], - }; - - return new Response( - toMarkdown(markdownTree, { - bullet: '-', - }), - { - headers: { - 'Content-Type': 'text/markdown; charset=utf-8', - }, - } - ); -} From 84907fefb52176c9c5f5a41335eae96457193dfd Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Fri, 4 Jul 2025 12:22:57 +0200 Subject: [PATCH 03/22] Fixes --- .../src/components/AIActions/AIActionsDropdown.tsx | 8 ++++---- packages/gitbook/src/components/PageBody/PageHeader.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index dd6b3904ea..3cb47d7003 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -21,9 +21,9 @@ type AIAction = { export function AIActionsDropdown(props: { markdown?: string; - markdownUrl: string; + markdownPageUrl: string; }) { - const { markdown, markdownUrl } = props; + const { markdown, markdownPageUrl } = props; const chatController = useAIChatController(); const chat = useAIChatState(); @@ -47,7 +47,7 @@ export function AIActionsDropdown(props: { icon: , label: 'Ask Docs Assistant', description: 'Ask our Docs Assistant about this page', - onClick: () => {}, + onClick: handleOpenAIAssistant, }, ...(markdown ? [ @@ -74,7 +74,7 @@ export function AIActionsDropdown(props: { description: 'View this page as plain text', isExternal: true, onClick: () => { - window.open(markdownUrl, '_blank'); + window.open(markdownPageUrl, '_blank'); }, }, ]; diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 51cad017f9..e41f08af9f 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -102,7 +102,7 @@ export async function PageHeader(props: { {page.layout.tableOfContents ? ( ) : null}
From eb447f558bf9d393efa4ffe162eeb797025d96d6 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Fri, 4 Jul 2025 15:29:21 +0200 Subject: [PATCH 04/22] Fixes --- .../AIActions/AIActionsDropdown.tsx | 74 ++++++++++++++++--- packages/gitbook/src/lib/markdownPage.ts | 4 +- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 3cb47d7003..39f8734df9 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -60,23 +60,75 @@ export function AIActionsDropdown(props: { navigator.clipboard.writeText(markdown); }, }, + { + icon: ( + + markdown icon + + + + ), + label: 'View as Markdown', + description: 'View this page as plain text', + isExternal: true, + onClick: () => { + window.open(markdownPageUrl, '_blank'); + }, + }, ] : []), { icon: ( - - markdown icon - - + + ChatGPT + ), - label: 'View as Markdown', - description: 'View this page as plain text', + label: 'Open in ChatGPT', + description: 'Ask ChatGPT about this page', isExternal: true, onClick: () => { - window.open(markdownPageUrl, '_blank'); + window.open( + `https://chatgpt.com/c/?prompt=${encodeURIComponent(markdown ?? '')}`, + '_blank' + ); }, }, + { + icon: ( + + Claude + + + + + ), + label: 'Open in Claude', + description: 'Ask Claude about this page', + isExternal: true, + onClick: () => + window.open( + `https://claude.ai/chat?prompt=${encodeURIComponent(markdown ?? '')}`, + '_blank' + ), + }, ]; return ( @@ -107,10 +159,10 @@ export function AIActionsDropdown(props: { {action.icon ? ( -
+
{typeof action.icon === 'string' ? ( - - {action.label} - + {action.label} {action.isExternal ? ( ) : null} diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts index 41b06823ec..1d4f6380a3 100644 --- a/packages/gitbook/src/lib/markdownPage.ts +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -47,13 +47,13 @@ export async function getMarkdownForPage( return servePageGroup(context, page); } - const { data: markdown } = await context.dataFetcher.getRevisionPageMarkdown({ + const { data: markdown, error } = await context.dataFetcher.getRevisionPageMarkdown({ spaceId: context.space.id, revisionId: context.revision.id, pageId: page.id, }); - if (!markdown) { + if (error) { return { error: { message: 'An error occurred while fetching the markdown for this page', From e582e6c22a41131439e6c636bcd53cfe7eef0cbf Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Jul 2025 16:08:36 +0200 Subject: [PATCH 05/22] Fixes + handle aiChat enabled or not --- .../AIActions/AIActionsDropdown.tsx | 173 ++++++++---------- .../AIActions/assets/ChatGPTIcon.tsx | 8 + .../AIActions/assets/ClaudeIcon.tsx | 19 ++ .../AIActions/assets/MarkdownIcon.tsx | 9 + .../src/components/PageBody/PageHeader.tsx | 13 +- .../components/SpaceLayout/SpaceLayout.tsx | 6 +- .../src/components/utils/isAIChatEnabled.ts | 7 + 7 files changed, 137 insertions(+), 98 deletions(-) create mode 100644 packages/gitbook/src/components/AIActions/assets/ChatGPTIcon.tsx create mode 100644 packages/gitbook/src/components/AIActions/assets/ClaudeIcon.tsx create mode 100644 packages/gitbook/src/components/AIActions/assets/MarkdownIcon.tsx create mode 100644 packages/gitbook/src/components/utils/isAIChatEnabled.ts diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 39f8734df9..557636f9ad 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -1,12 +1,15 @@ 'use client'; import { useAIChatController, useAIChatState } from '@/components/AI/useAIChat'; +import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon'; +import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon'; +import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon'; import AIChatIcon from '@/components/AIChat/AIChatIcon'; import { Button } from '@/components/primitives/Button'; import { DropdownMenu, DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; -import { useRef } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; type AIAction = { icon?: IconName | React.ReactNode; @@ -22,15 +25,22 @@ type AIAction = { export function AIActionsDropdown(props: { markdown?: string; markdownPageUrl: string; + /** + * Whether to include the "Ask Docs Assistant" entry in the dropdown menu. This **does not** + * affect the standalone assistant button rendered next to the dropdown. + * Defaults to `false` to avoid duplicating the action unless explicitly requested. + */ + withAIChat?: boolean; + pageURL: string; }) { - const { markdown, markdownPageUrl } = props; + const { markdown, markdownPageUrl, withAIChat, pageURL } = props; const chatController = useAIChatController(); const chat = useAIChatState(); const language = useLanguage(); const ref = useRef(null); - const handleOpenAIAssistant = () => { + const handleDocsAssistant = useCallback(() => { // Open the chat if it's not already open if (!chat.opened) { chatController.open(); @@ -40,106 +50,74 @@ export function AIActionsDropdown(props: { chatController.postMessage({ message: tString(language, 'ai_chat_suggested_questions_about_this_page'), }); - }; + }, [chat, chatController, language]); - const actions: AIAction[] = [ - { - icon: , - label: 'Ask Docs Assistant', - description: 'Ask our Docs Assistant about this page', - onClick: handleOpenAIAssistant, - }, - ...(markdown - ? [ - { - icon: 'copy', - label: 'Copy for LLMs', - description: 'Copy page as Markdown', - onClick: () => { - if (!markdown) return; - navigator.clipboard.writeText(markdown); + const actions: AIAction[] = useMemo( + (): AIAction[] => [ + ...(withAIChat + ? [ + { + icon: , + label: 'Ask Docs Assistant', + description: 'Ask our Docs Assistant about this page', + onClick: handleDocsAssistant, + }, + ] + : []), + ...(markdown + ? [ + { + icon: 'copy', + label: 'Copy for LLMs', + description: 'Copy page as Markdown', + onClick: () => { + if (!markdown) return; + navigator.clipboard.writeText(markdown); + }, }, - }, - { - icon: ( - - markdown icon - - - - ), - label: 'View as Markdown', - description: 'View this page as plain text', - isExternal: true, - onClick: () => { - window.open(markdownPageUrl, '_blank'); + { + icon: , + label: 'View as Markdown', + description: 'View this page as plain text', + isExternal: true, + onClick: () => { + window.open(`${markdownPageUrl}.md`, '_blank'); + }, }, - }, - ] - : []), - { - icon: ( - - ChatGPT - - - ), - label: 'Open in ChatGPT', - description: 'Ask ChatGPT about this page', - isExternal: true, - onClick: () => { - window.open( - `https://chatgpt.com/c/?prompt=${encodeURIComponent(markdown ?? '')}`, - '_blank' - ); + ] + : []), + { + icon: , + label: 'Open in ChatGPT', + description: 'Ask ChatGPT about this page', + isExternal: true, + onClick: () => { + window.open(getLLMURL('chatgpt', pageURL), '_blank'); + }, }, - }, - { - icon: ( - - Claude - - - - - ), - label: 'Open in Claude', - description: 'Ask Claude about this page', - isExternal: true, - onClick: () => - window.open( - `https://claude.ai/chat?prompt=${encodeURIComponent(markdown ?? '')}`, - '_blank' - ), - }, - ]; + { + icon: , + label: 'Open in Claude', + description: 'Ask Claude about this page', + isExternal: true, + onClick: () => window.open(getLLMURL('claude', pageURL), '_blank'), + }, + ], + [markdown, markdownPageUrl, pageURL, withAIChat, handleDocsAssistant] + ); + + // The default action is Ask Docs Assistant if the AI assistant is enabled, otherwise View as Markdown + const defaultAction = actions[0]; return (
diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index bf13818139..f75e02e050 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -11,6 +11,7 @@ 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'; @@ -50,10 +51,7 @@ export function SpaceLayout(props: { customization.footer.logo || customization.footer.groups?.length; - // TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025) - const withAIChat = - context.customization.ai?.mode === CustomizationAIMode.Assistant && - (context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv'); + const withAIChat = isAIChatEnabled(context); return ( diff --git a/packages/gitbook/src/components/utils/isAIChatEnabled.ts b/packages/gitbook/src/components/utils/isAIChatEnabled.ts new file mode 100644 index 0000000000..d7e65a275e --- /dev/null +++ b/packages/gitbook/src/components/utils/isAIChatEnabled.ts @@ -0,0 +1,7 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import { CustomizationAIMode } from '@gitbook/api'; + +// TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025) +export const isAIChatEnabled = (context: GitBookSiteContext) => + context.customization.ai?.mode === CustomizationAIMode.Assistant && + (context.site.id === 'site_p4Xo4' || context.site.id === 'site_JOVzv'); From 334b913306cda161672b9c545b33c21e587483db Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Jul 2025 16:32:02 +0200 Subject: [PATCH 06/22] loading indicator --- .../src/components/AIActions/AIActionsDropdown.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 557636f9ad..075a01a1c6 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -20,6 +20,7 @@ type AIAction = { */ isExternal?: boolean; onClick?: () => void; + disabled?: boolean; }; export function AIActionsDropdown(props: { @@ -57,10 +58,15 @@ export function AIActionsDropdown(props: { ...(withAIChat ? [ { - icon: , + icon: chat.loading ? ( + + ) : ( + + ), label: 'Ask Docs Assistant', description: 'Ask our Docs Assistant about this page', onClick: handleDocsAssistant, + disabled: chat.loading || chat.error, }, ] : []), @@ -118,6 +124,7 @@ export function AIActionsDropdown(props: { label={defaultAction.label} className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm" onClick={defaultAction.onClick} + disabled={defaultAction.disabled} /> Date: Mon, 7 Jul 2025 16:38:23 +0200 Subject: [PATCH 07/22] Copied state --- .../AIActions/AIActionsDropdown.tsx | 168 ++++++++++-------- 1 file changed, 90 insertions(+), 78 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 075a01a1c6..18146cc1be 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/primitives/Button'; import { DropdownMenu, DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; -import { useCallback, useMemo, useRef } from 'react'; +import { useRef, useState } from 'react'; type AIAction = { icon?: IconName | React.ReactNode; @@ -34,85 +34,10 @@ export function AIActionsDropdown(props: { withAIChat?: boolean; pageURL: string; }) { - const { markdown, markdownPageUrl, withAIChat, pageURL } = props; - - const chatController = useAIChatController(); - const chat = useAIChatState(); - const language = useLanguage(); const ref = useRef(null); + const actions = useAIActions(props); - const handleDocsAssistant = useCallback(() => { - // Open the chat if it's not already open - if (!chat.opened) { - chatController.open(); - } - - // Send the "What is this page about?" message - chatController.postMessage({ - message: tString(language, 'ai_chat_suggested_questions_about_this_page'), - }); - }, [chat, chatController, language]); - - const actions: AIAction[] = useMemo( - (): AIAction[] => [ - ...(withAIChat - ? [ - { - icon: chat.loading ? ( - - ) : ( - - ), - label: 'Ask Docs Assistant', - description: 'Ask our Docs Assistant about this page', - onClick: handleDocsAssistant, - disabled: chat.loading || chat.error, - }, - ] - : []), - ...(markdown - ? [ - { - icon: 'copy', - label: 'Copy for LLMs', - description: 'Copy page as Markdown', - onClick: () => { - if (!markdown) return; - navigator.clipboard.writeText(markdown); - }, - }, - { - icon: , - label: 'View as Markdown', - description: 'View this page as plain text', - isExternal: true, - onClick: () => { - window.open(`${markdownPageUrl}.md`, '_blank'); - }, - }, - ] - : []), - { - icon: , - label: 'Open in ChatGPT', - description: 'Ask ChatGPT about this page', - isExternal: true, - onClick: () => { - window.open(getLLMURL('chatgpt', pageURL), '_blank'); - }, - }, - { - icon: , - label: 'Open in Claude', - description: 'Ask Claude about this page', - isExternal: true, - onClick: () => window.open(getLLMURL('claude', pageURL), '_blank'), - }, - ], - [markdown, markdownPageUrl, pageURL, withAIChat, handleDocsAssistant] - ); - - // The default action is Ask Docs Assistant if the AI assistant is enabled, otherwise View as Markdown + // The default action is "Ask Docs Assistant" if the AI assistant is enabled, otherwise "Copy for LLMs" const defaultAction = actions[0]; return ( @@ -189,3 +114,90 @@ function getLLMURL(provider: 'chatgpt' | 'claude', url: string) { return `https://claude.ai/new?q=${prompt}`; } } + +function useAIActions(props: { + markdown?: string; + markdownPageUrl: string; + pageURL: string; + withAIChat?: boolean; +}): AIAction[] { + const { markdown, markdownPageUrl, pageURL, withAIChat } = props; + + const chatController = useAIChatController(); + const chat = useAIChatState(); + const language = useLanguage(); + + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (!markdown) return; + navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleDocsAssistant = () => { + // Open the chat if it's not already open + if (!chat.opened) { + chatController.open(); + } + + // Send the "What is this page about?" message + chatController.postMessage({ + message: tString(language, 'ai_chat_suggested_questions_about_this_page'), + }); + }; + + return [ + ...(withAIChat + ? [ + { + icon: chat.loading ? ( + + ) : ( + + ), + label: 'Ask Docs Assistant', + description: 'Ask our Docs Assistant about this page', + onClick: handleDocsAssistant, + disabled: chat.loading || chat.error, + }, + ] + : []), + ...(markdown + ? [ + { + icon: copied ? 'check' : 'copy', + label: copied ? 'Copied!' : 'Copy for LLMs', + description: 'Copy page as Markdown', + onClick: handleCopy, + }, + { + icon: , + label: 'View as Markdown', + description: 'View this page as plain text', + isExternal: true, + onClick: () => { + window.open(`${markdownPageUrl}.md`, '_blank'); + }, + }, + ] + : []), + { + icon: , + label: 'Open in ChatGPT', + description: 'Ask ChatGPT about this page', + isExternal: true, + onClick: () => { + window.open(getLLMURL('chatgpt', pageURL), '_blank'); + }, + }, + { + icon: , + label: 'Open in Claude', + description: 'Ask Claude about this page', + isExternal: true, + onClick: () => window.open(getLLMURL('claude', pageURL), '_blank'), + }, + ]; +} From 32246b16741f672deeaee78eed76448f04bbd52b Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Jul 2025 18:00:11 +0200 Subject: [PATCH 08/22] quick chore --- .../AIActions/AIActionsDropdown.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 18146cc1be..4c64dd47c2 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -104,17 +104,6 @@ export function AIActionsDropdown(props: { ); } -function getLLMURL(provider: 'chatgpt' | 'claude', url: string) { - const prompt = encodeURIComponent(`Read ${url} and answer questions about the content.`); - - switch (provider) { - case 'chatgpt': - return `https://chat.openai.com/?q=${prompt}`; - case 'claude': - return `https://claude.ai/new?q=${prompt}`; - } -} - function useAIActions(props: { markdown?: string; markdownPageUrl: string; @@ -201,3 +190,14 @@ function useAIActions(props: { }, ]; } + +function getLLMURL(provider: 'chatgpt' | 'claude', url: string) { + const prompt = encodeURIComponent(`Read ${url} and answer questions about the content.`); + + switch (provider) { + case 'chatgpt': + return `https://chat.openai.com/?q=${prompt}`; + case 'claude': + return `https://claude.ai/new?q=${prompt}`; + } +} From c241a83c57bc5614b751699063914f0936b7f693 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Jul 2025 19:00:45 +0200 Subject: [PATCH 09/22] Update dropdown --- .../AIActions/AIActionsDropdown.tsx | 5 ++-- .../components/primitives/DropdownMenu.tsx | 25 +++++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 4c64dd47c2..05545f1b30 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -52,9 +52,8 @@ export function AIActionsDropdown(props: { disabled={defaultAction.disabled} /> setHovered(true)} onMouseLeave={() => setHovered(false)} - align="start" + align={align} + side={side} className="z-40 animate-scaleIn pt-2" - {...contentProps} >
Date: Mon, 7 Jul 2025 19:16:52 +0200 Subject: [PATCH 10/22] fixes icons --- .../src/components/AIActions/AIActionsDropdown.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 05545f1b30..11e0d44519 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -71,7 +71,7 @@ export function AIActionsDropdown(props: { className="flex items-stretch gap-2.5 p-2" > {action.icon ? ( -
+
{typeof action.icon === 'string' ? ( ) : null} -
+
{action.label} {action.isExternal ? ( @@ -161,7 +161,7 @@ function useAIActions(props: { onClick: handleCopy, }, { - icon: , + icon: , label: 'View as Markdown', description: 'View this page as plain text', isExternal: true, @@ -172,7 +172,7 @@ function useAIActions(props: { ] : []), { - icon: , + icon: , label: 'Open in ChatGPT', description: 'Ask ChatGPT about this page', isExternal: true, @@ -181,7 +181,7 @@ function useAIActions(props: { }, }, { - icon: , + icon: , label: 'Open in Claude', description: 'Ask Claude about this page', isExternal: true, From 1267f9873daceced542b69231dc02601df2e608a Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Mon, 7 Jul 2025 19:20:52 +0200 Subject: [PATCH 11/22] changeset --- .changeset/forty-dolls-decide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/forty-dolls-decide.md diff --git a/.changeset/forty-dolls-decide.md b/.changeset/forty-dolls-decide.md new file mode 100644 index 0000000000..85a5932e71 --- /dev/null +++ b/.changeset/forty-dolls-decide.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Implement AI actions dropdown From 4935c5e6aa60e471660a70634cdbe73617025755 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 10:39:59 +0200 Subject: [PATCH 12/22] Fixes after review --- .../src/components/AIActions/AIActions.tsx | 167 +++++++++++++++ .../AIActions/AIActionsDropdown.tsx | 196 ++++-------------- .../components/primitives/DropdownMenu.tsx | 5 +- 3 files changed, 210 insertions(+), 158 deletions(-) create mode 100644 packages/gitbook/src/components/AIActions/AIActions.tsx diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx new file mode 100644 index 0000000000..953262c541 --- /dev/null +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useAIChatController } from '@/components/AI/useAIChat'; +import { useAIChatState } from '@/components/AI/useAIChat'; +import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon'; +import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon'; +import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon'; +import AIChatIcon from '@/components/AIChat/AIChatIcon'; +import { Button } from '@/components/primitives/Button'; +import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; +import { tString, useLanguage } from '@/intl/client'; +import { Icon, type IconName, IconStyle } from '@gitbook/icons'; +import { useState } from 'react'; + +type AIActionType = 'button' | 'dropdown-menu-item'; + +export function OpenDocsAssistant(props: { type: AIActionType }) { + const { type } = props; + const chatController = useAIChatController(); + const chat = useAIChatState(); + const language = useLanguage(); + + return ( + } + label="Ask Docs Assistant" + description="Ask our Docs Assistant about this page" + onClick={() => { + // Open the chat if it's not already open + if (!chat.opened) { + chatController.open(); + } + + // Send the "What is this page about?" message + chatController.postMessage({ + message: tString(language, 'ai_chat_suggested_questions_about_this_page'), + }); + }} + /> + ); +} + +export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { + const { markdown, type } = props; + const [copied, setCopied] = useState(false); + + return ( + { + if (!markdown) return; + navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + /> + ); +} + +export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) { + const { markdownPageUrl, type } = props; + + return ( + } + label="View as Markdown" + description="View this page as plain text" + href={`${markdownPageUrl}.md`} + /> + ); +} + +export function OpenInLLM(props: { + provider: 'chatgpt' | 'claude'; + url: string; + type: AIActionType; +}) { + const { provider, url, type } = props; + + return ( + + ) : ( + + ) + } + label={provider === 'chatgpt' ? 'Open in ChatGPT' : 'Open in Claude'} + description={`Ask ${provider} about this page`} + href={getLLMURL(provider, url)} + /> + ); +} + +function AIActionWrapper(props: { + type: AIActionType; + icon: IconName | React.ReactNode; + label: string; + onClick?: () => void; + description?: string; + href?: string; +}) { + const { type, icon, label, onClick, href, description } = props; + + if (type === 'button') { + return ( +
); } -function useAIActions(props: { +function DropdownMenuContent(props: { markdown?: string; markdownPageUrl: string; - pageURL: string; withAIChat?: boolean; -}): AIAction[] { - const { markdown, markdownPageUrl, pageURL, withAIChat } = props; - - const chatController = useAIChatController(); - const chat = useAIChatState(); - const language = useLanguage(); - - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - if (!markdown) return; - navigator.clipboard.writeText(markdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const handleDocsAssistant = () => { - // Open the chat if it's not already open - if (!chat.opened) { - chatController.open(); - } - - // Send the "What is this page about?" message - chatController.postMessage({ - message: tString(language, 'ai_chat_suggested_questions_about_this_page'), - }); - }; + pageURL: string; +}) { + const { markdown, markdownPageUrl, withAIChat, pageURL } = props; - return [ - ...(withAIChat - ? [ - { - icon: chat.loading ? ( - - ) : ( - - ), - label: 'Ask Docs Assistant', - description: 'Ask our Docs Assistant about this page', - onClick: handleDocsAssistant, - disabled: chat.loading || chat.error, - }, - ] - : []), - ...(markdown - ? [ - { - icon: copied ? 'check' : 'copy', - label: copied ? 'Copied!' : 'Copy for LLMs', - description: 'Copy page as Markdown', - onClick: handleCopy, - }, - { - icon: , - label: 'View as Markdown', - description: 'View this page as plain text', - isExternal: true, - onClick: () => { - window.open(`${markdownPageUrl}.md`, '_blank'); - }, - }, - ] - : []), - { - icon: , - label: 'Open in ChatGPT', - description: 'Ask ChatGPT about this page', - isExternal: true, - onClick: () => { - window.open(getLLMURL('chatgpt', pageURL), '_blank'); - }, - }, - { - icon: , - label: 'Open in Claude', - description: 'Ask Claude about this page', - isExternal: true, - onClick: () => window.open(getLLMURL('claude', pageURL), '_blank'), - }, - ]; + return ( + <> + {withAIChat ? : null} + {markdown ? ( + <> + + + + ) : null} + + + + ); } -function getLLMURL(provider: 'chatgpt' | 'claude', url: string) { - const prompt = encodeURIComponent(`Read ${url} and answer questions about the content.`); +function DefaultAction(props: { + markdown?: string; + withAIChat?: boolean; + pageURL: string; +}) { + const { markdown, withAIChat, pageURL } = props; - switch (provider) { - case 'chatgpt': - return `https://chat.openai.com/?q=${prompt}`; - case 'claude': - return `https://claude.ai/new?q=${prompt}`; + if (withAIChat) { + return ; } + if (markdown) { + return ; + } + + return ; } diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index b30afdc51a..2ac15895ef 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -134,13 +134,14 @@ export function DropdownButton(props: { export function DropdownMenuItem( props: { href?: string; + target?: React.HTMLAttributeAnchorTarget; active?: boolean; className?: ClassValue; children: React.ReactNode; } & LinkInsightsProps & RadixDropdownMenu.DropdownMenuItemProps ) { - const { children, active = false, href, className, insights, ...rest } = props; + const { children, active = false, href, className, insights, target, ...rest } = props; const itemClassName = tcls( 'rounded straight-corners:rounded-sm px-3 py-1 text-sm flex gap-2 items-center', @@ -155,7 +156,7 @@ export function DropdownMenuItem( if (href) { return ( - + {children} From 6468a4bec2e55ff202d5c93dc5aed02fed31a292 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 10:45:34 +0200 Subject: [PATCH 13/22] Rebase --- packages/gitbook/src/lib/markdownPage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/lib/markdownPage.ts b/packages/gitbook/src/lib/markdownPage.ts index 1d4f6380a3..d3cccb27cf 100644 --- a/packages/gitbook/src/lib/markdownPage.ts +++ b/packages/gitbook/src/lib/markdownPage.ts @@ -49,7 +49,7 @@ export async function getMarkdownForPage( const { data: markdown, error } = await context.dataFetcher.getRevisionPageMarkdown({ spaceId: context.space.id, - revisionId: context.revision.id, + revisionId: context.revisionId, pageId: page.id, }); From a0c6ad099d7bd0423bc47df9c63a90aa2eb86c96 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 10:59:01 +0200 Subject: [PATCH 14/22] Fix markdownPageUrl --- packages/gitbook/src/components/PageBody/PageHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index c63459d1a4..d974319ff3 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -104,7 +104,7 @@ export async function PageHeader(props: { {page.layout.tableOfContents ? ( Date: Tue, 8 Jul 2025 13:14:16 +0200 Subject: [PATCH 15/22] Copy feedback --- .../src/components/AIActions/AIActions.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 953262c541..b71aa0061d 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -45,17 +45,38 @@ export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { const { markdown, type } = props; const [copied, setCopied] = useState(false); + // Close the dropdown menu manually after the copy button is clicked + const closeDropdownMenu = () => { + const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]'); + + // Cancel if no dropdown menu is open + if (!dropdownMenu) return; + + // Dispatch on `document` so that the event is captured by Radix's + // dismissable-layer listener regardless of focus location. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }; + return ( { + onClick={(e) => { + e.preventDefault(); + if (!markdown) return; navigator.clipboard.writeText(markdown); setCopied(true); - setTimeout(() => setCopied(false), 2000); + + setTimeout(() => { + if (type === 'dropdown-menu-item') { + closeDropdownMenu(); + } + + setCopied(false); + }, 2000); }} /> ); @@ -103,7 +124,7 @@ function AIActionWrapper(props: { type: AIActionType; icon: IconName | React.ReactNode; label: string; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; description?: string; href?: string; }) { From df2c1f6ef36db1d0b1a0111d6b106b3ec467e73c Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 16:46:20 +0200 Subject: [PATCH 16/22] Translations + mobile --- .../src/components/AIActions/AIActions.tsx | 42 +++++++++++++------ .../AIActions/AIActionsDropdown.tsx | 4 +- .../src/components/PageBody/PageHeader.tsx | 3 +- packages/gitbook/src/intl/translations/de.ts | 10 +++++ packages/gitbook/src/intl/translations/en.ts | 10 +++++ packages/gitbook/src/intl/translations/es.ts | 10 +++++ packages/gitbook/src/intl/translations/fr.ts | 10 +++++ packages/gitbook/src/intl/translations/ja.ts | 10 +++++ packages/gitbook/src/intl/translations/nl.ts | 10 +++++ packages/gitbook/src/intl/translations/no.ts | 10 +++++ .../gitbook/src/intl/translations/pt-br.ts | 10 +++++ packages/gitbook/src/intl/translations/zh.ts | 10 +++++ 12 files changed, 123 insertions(+), 16 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index b71aa0061d..b06f5fb45d 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -9,6 +9,7 @@ import AIChatIcon from '@/components/AIChat/AIChatIcon'; import { Button } from '@/components/primitives/Button'; import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; +import type { TranslationLanguage } from '@/intl/translations'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { useState } from 'react'; @@ -24,8 +25,13 @@ export function OpenDocsAssistant(props: { type: AIActionType }) { } - label="Ask Docs Assistant" - description="Ask our Docs Assistant about this page" + label={tString(language, 'ai_chat_ask', tString(language, 'ai_chat_assistant_name'))} + shortLabel={tString(language, 'ask')} + description={tString( + language, + 'ai_chat_ask_about_page', + tString(language, 'ai_chat_assistant_name') + )} onClick={() => { // Open the chat if it's not already open if (!chat.opened) { @@ -44,6 +50,7 @@ export function OpenDocsAssistant(props: { type: AIActionType }) { export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { const { markdown, type } = props; const [copied, setCopied] = useState(false); + const language = useLanguage(); // Close the dropdown menu manually after the copy button is clicked const closeDropdownMenu = () => { @@ -61,8 +68,8 @@ export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { { e.preventDefault(); @@ -84,13 +91,14 @@ export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) { const { markdownPageUrl, type } = props; + const language = useLanguage(); return ( } - label="View as Markdown" - description="View this page as plain text" + label={tString(language, 'view_page_markdown')} + description={tString(language, 'view_page_plaintext')} href={`${markdownPageUrl}.md`} /> ); @@ -102,6 +110,9 @@ export function OpenInLLM(props: { type: AIActionType; }) { const { provider, url, type } = props; + const language = useLanguage(); + + const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude'; return ( ) } - label={provider === 'chatgpt' ? 'Open in ChatGPT' : 'Open in Claude'} - description={`Ask ${provider} about this page`} - href={getLLMURL(provider, url)} + label={tString(language, 'open_in', providerLabel)} + shortLabel={providerLabel} + description={tString(language, 'ai_chat_ask_about_page', providerLabel)} + href={getLLMURL(provider, url, language)} /> ); } @@ -124,11 +136,15 @@ function AIActionWrapper(props: { type: AIActionType; icon: IconName | React.ReactNode; label: string; + /** + * The label to display in the button. If not provided, the `label` will be used. + */ + shortLabel?: string; onClick?: (e: React.MouseEvent) => void; description?: string; href?: string; }) { - const { type, icon, label, onClick, href, description } = props; + const { type, icon, label, shortLabel, onClick, href, description } = props; if (type === 'button') { return ( @@ -136,7 +152,7 @@ function AIActionWrapper(props: { icon={icon} size="small" variant="secondary" - label={label} + label={shortLabel || label} className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm" onClick={onClick} href={href} @@ -176,8 +192,8 @@ function AIActionWrapper(props: { ); } -function getLLMURL(provider: 'chatgpt' | 'claude', url: string) { - const prompt = encodeURIComponent(`Read ${url} and answer questions about the content.`); +function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) { + const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url)); switch (provider) { case 'chatgpt': diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 09467ed759..7a6e2eecfb 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -24,11 +24,11 @@ export function AIActionsDropdown(props: { const ref = useRef(null); return ( -
+
Date: Tue, 8 Jul 2025 17:01:19 +0200 Subject: [PATCH 17/22] Loading indicator for docs assistant --- .../gitbook/src/components/AIActions/AIActions.tsx | 14 ++++++++++++-- .../src/components/AIActions/AIActionsDropdown.tsx | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index b06f5fb45d..6742a5acc8 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -24,7 +24,13 @@ export function OpenDocsAssistant(props: { type: AIActionType }) { return ( } + icon={ + chat.loading ? ( + + ) : ( + + ) + } label={tString(language, 'ai_chat_ask', tString(language, 'ai_chat_assistant_name'))} shortLabel={tString(language, 'ask')} description={tString( @@ -32,6 +38,7 @@ export function OpenDocsAssistant(props: { type: AIActionType }) { 'ai_chat_ask_about_page', tString(language, 'ai_chat_assistant_name') )} + disabled={chat.loading} onClick={() => { // Open the chat if it's not already open if (!chat.opened) { @@ -143,8 +150,9 @@ function AIActionWrapper(props: { onClick?: (e: React.MouseEvent) => void; description?: string; href?: string; + disabled?: boolean; }) { - const { type, icon, label, shortLabel, onClick, href, description } = props; + const { type, icon, label, shortLabel, onClick, href, description, disabled } = props; if (type === 'button') { return ( @@ -157,6 +165,7 @@ function AIActionWrapper(props: { onClick={onClick} href={href} target={href ? '_blank' : undefined} + disabled={disabled} /> ); } @@ -167,6 +176,7 @@ function AIActionWrapper(props: { href={href} target="_blank" onClick={onClick} + disabled={disabled} > {icon ? (
diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 7a6e2eecfb..c2611d3b85 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -24,7 +24,7 @@ export function AIActionsDropdown(props: { const ref = useRef(null); return ( -
+
Date: Tue, 8 Jul 2025 18:00:05 +0200 Subject: [PATCH 18/22] Chores before merge --- .../src/components/AIActions/AIActions.tsx | 55 ++++++++++++++----- .../AIActions/AIActionsDropdown.tsx | 33 ++++++++--- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 6742a5acc8..ae0d41e43c 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -11,10 +11,14 @@ import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; import type { TranslationLanguage } from '@/intl/translations'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; -import { useState } from 'react'; +import type React from 'react'; +import { create } from 'zustand'; type AIActionType = 'button' | 'dropdown-menu-item'; +/** + * Opens our AI Docs Assistant. + */ export function OpenDocsAssistant(props: { type: AIActionType }) { const { type } = props; const chatController = useAIChatController(); @@ -24,13 +28,7 @@ export function OpenDocsAssistant(props: { type: AIActionType }) { return ( - ) : ( - - ) - } + icon={} label={tString(language, 'ai_chat_ask', tString(language, 'ai_chat_assistant_name'))} shortLabel={tString(language, 'ask')} description={tString( @@ -54,10 +52,27 @@ export function OpenDocsAssistant(props: { type: AIActionType }) { ); } -export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { - const { markdown, type } = props; - const [copied, setCopied] = useState(false); +// We need to store the copied state in a store to share the state between the +// copy button and the dropdown menu item. +const useCopiedStore = create<{ + copied: boolean; + setCopied: (copied: boolean) => void; +}>((set) => ({ + copied: false, + setCopied: (copied: boolean) => set({ copied }), +})); + +/** + * Copies the markdown version of the page to the clipboard. + */ +export function CopyMarkdown(props: { + markdown: string; + type: AIActionType; + isDefaultAction?: boolean; +}) { + const { markdown, type, isDefaultAction } = props; const language = useLanguage(); + const { copied, setCopied } = useCopiedStore(); // Close the dropdown menu manually after the copy button is clicked const closeDropdownMenu = () => { @@ -78,14 +93,16 @@ export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')} description={tString(language, 'copy_page_markdown')} onClick={(e) => { - e.preventDefault(); + if (!isDefaultAction) { + e.preventDefault(); + } if (!markdown) return; navigator.clipboard.writeText(markdown); setCopied(true); setTimeout(() => { - if (type === 'dropdown-menu-item') { + if (type === 'dropdown-menu-item' && !isDefaultAction) { closeDropdownMenu(); } @@ -96,6 +113,9 @@ export function CopyMarkdown(props: { markdown: string; type: AIActionType }) { ); } +/** + * Redirects to the markdown version of the page. + */ export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) { const { markdownPageUrl, type } = props; const language = useLanguage(); @@ -111,6 +131,9 @@ export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionT ); } +/** + * Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude. + */ export function OpenInLLM(props: { provider: 'chatgpt' | 'claude'; url: string; @@ -139,6 +162,9 @@ export function OpenInLLM(props: { ); } +/** + * Wraps an action in a button (for the default action) or dropdown menu item. + */ function AIActionWrapper(props: { type: AIActionType; icon: IconName | React.ReactNode; @@ -202,6 +228,9 @@ function AIActionWrapper(props: { ); } +/** + * Returns the URL to open the page in a LLM with a pre-filled prompt. + */ function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) { const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url)); diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index c2611d3b85..b68fa8cb05 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -10,13 +10,14 @@ import { Button } from '@/components/primitives/Button'; import { DropdownMenu } from '@/components/primitives/DropdownMenu'; import { useRef } from 'react'; +/** + * Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM). + */ export function AIActionsDropdown(props: { markdown?: string; markdownPageUrl: string; /** - * Whether to include the "Ask Docs Assistant" entry in the dropdown menu. This **does not** - * affect the standalone assistant button rendered next to the dropdown. - * Defaults to `false` to avoid duplicating the action unless explicitly requested. + * Whether to include the "Ask Docs Assistant" entry in the dropdown menu. */ withAIChat?: boolean; pageURL: string; @@ -39,13 +40,16 @@ export function AIActionsDropdown(props: { /> } > - +
); } -function DropdownMenuContent(props: { +/** + * The content of the dropdown menu. + */ +function AIActionsDropdownMenuContent(props: { markdown?: string; markdownPageUrl: string; withAIChat?: boolean; @@ -58,7 +62,11 @@ function DropdownMenuContent(props: { {withAIChat ? : null} {markdown ? ( <> - + ) : null} @@ -68,18 +76,27 @@ function DropdownMenuContent(props: { ); } +/** + * A default action shown as a quick-access button beside the dropdown menu + */ function DefaultAction(props: { markdown?: string; withAIChat?: boolean; pageURL: string; + markdownPageUrl: string; }) { - const { markdown, withAIChat, pageURL } = props; + const { markdown, withAIChat, pageURL, markdownPageUrl } = props; if (withAIChat) { return ; } + if (markdown) { - return ; + return ; + } + + if (markdownPageUrl) { + return ; } return ; From 458c4c37a717c32b6a94939d4f31677c7aa3d1ed Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 18:43:11 +0200 Subject: [PATCH 19/22] Fixes --- .../src/components/AIActions/AIActions.tsx | 59 +++++++++++++------ .../AIActions/assets/MarkdownIcon.tsx | 2 +- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index ae0d41e43c..d153554f22 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -11,7 +11,9 @@ import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; import type { TranslationLanguage } from '@/intl/translations'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; +import assertNever from 'assert-never'; import type React from 'react'; +import { useEffect, useRef } from 'react'; import { create } from 'zustand'; type AIActionType = 'button' | 'dropdown-menu-item'; @@ -73,6 +75,7 @@ export function CopyMarkdown(props: { const { markdown, type, isDefaultAction } = props; const language = useLanguage(); const { copied, setCopied } = useCopiedStore(); + const timeoutRef = useRef | null>(null); // Close the dropdown menu manually after the copy button is clicked const closeDropdownMenu = () => { @@ -86,29 +89,49 @@ export function CopyMarkdown(props: { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); }; + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const onClick = (e: React.MouseEvent) => { + // Prevent default behavior for non-default actions to avoid closing the dropdown. + // This allows showing transient UI (e.g., a "copied" state) inside the menu item. + // Default action buttons are excluded from this behavior. + if (!isDefaultAction) { + e.preventDefault(); + } + + // Cancel any pending timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + navigator.clipboard.writeText(markdown); + + setCopied(true); + + // Reset the copied state after 2 seconds + timeoutRef.current = setTimeout(() => { + // Close the dropdown menu if it's a dropdown menu item and not the default action + if (type === 'dropdown-menu-item' && !isDefaultAction) { + closeDropdownMenu(); + } + + setCopied(false); + }, 2000); + }; + return ( { - if (!isDefaultAction) { - e.preventDefault(); - } - - if (!markdown) return; - navigator.clipboard.writeText(markdown); - setCopied(true); - - setTimeout(() => { - if (type === 'dropdown-menu-item' && !isDefaultAction) { - closeDropdownMenu(); - } - - setCopied(false); - }, 2000); - }} + onClick={onClick} /> ); } @@ -239,5 +262,7 @@ function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: Transl return `https://chat.openai.com/?q=${prompt}`; case 'claude': return `https://claude.ai/new?q=${prompt}`; + default: + assertNever(provider); } } diff --git a/packages/gitbook/src/components/AIActions/assets/MarkdownIcon.tsx b/packages/gitbook/src/components/AIActions/assets/MarkdownIcon.tsx index d41ade975a..c88d526f57 100644 --- a/packages/gitbook/src/components/AIActions/assets/MarkdownIcon.tsx +++ b/packages/gitbook/src/components/AIActions/assets/MarkdownIcon.tsx @@ -1,7 +1,7 @@ export function MarkdownIcon(props: React.SVGProps) { return ( - markdown icon + Markdown From 23ac0e4353a65ef6bb8cdbd90df8a2979fe78b57 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 18:53:32 +0200 Subject: [PATCH 20/22] Update mobile title font-size --- packages/gitbook/src/components/PageBody/PageHeader.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 5140a8a396..d974319ff3 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -88,8 +88,7 @@ export async function PageHeader(props: { {page.layout.title ? (

Date: Tue, 8 Jul 2025 18:55:21 +0200 Subject: [PATCH 21/22] Remove leading none --- packages/gitbook/src/components/AIActions/AIActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index d153554f22..1e6f0ac9fe 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -240,7 +240,7 @@ function AIActionWrapper(props: { )}

) : null} -
+
{label} {href ? : null} From 471d89a9b4283672f9611d07223d60204aa259dc Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 8 Jul 2025 21:49:48 +0200 Subject: [PATCH 22/22] Align DropdownMenu and Button styles --- packages/gitbook/src/components/primitives/DropdownMenu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 2ac15895ef..20bb7d29b1 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -74,11 +74,11 @@ export function DropdownMenu(props: { onMouseLeave={() => setHovered(false)} align={align} side={side} - className="z-40 animate-scaleIn pt-2" + className="z-40 animate-scaleIn border-tint pt-2" >
@@ -144,7 +144,7 @@ export function DropdownMenuItem( const { children, active = false, href, className, insights, target, ...rest } = props; const itemClassName = tcls( - 'rounded straight-corners:rounded-sm px-3 py-1 text-sm flex gap-2 items-center', + 'rounded straight-corners:rounded-sm circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center', active ? 'bg-primary text-primary-strong data-[highlighted]:bg-primary-hover' : 'data-[highlighted]:bg-tint-hover',