Skip to content

Implement AI actions dropdown #3431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jul 8, 2025
Merged
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/forty-dolls-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': minor
---

Implement AI actions dropdown
268 changes: 268 additions & 0 deletions packages/gitbook/src/components/AIActions/AIActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
'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 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';

/**
* Opens our AI Docs Assistant.
*/
export function OpenDocsAssistant(props: { type: AIActionType }) {
const { type } = props;
const chatController = useAIChatController();
const chat = useAIChatState();
const language = useLanguage();

return (
<AIActionWrapper
type={type}
icon={<AIChatIcon state={chat.loading ? 'thinking' : 'default'} />}
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')
)}
disabled={chat.loading}
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'),
});
}}
/>
);
}

// 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();
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// 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 }));
};

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 (
<AIActionWrapper
type={type}
icon={copied ? 'check' : 'copy'}
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
description={tString(language, 'copy_page_markdown')}
onClick={onClick}
/>
);
}

/**
* Redirects to the markdown version of the page.
*/
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
const { markdownPageUrl, type } = props;
const language = useLanguage();

return (
<AIActionWrapper
type={type}
icon={<MarkdownIcon className="size-4 fill-current" />}
label={tString(language, 'view_page_markdown')}
description={tString(language, 'view_page_plaintext')}
href={`${markdownPageUrl}.md`}
/>
);
}

/**
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude.
*/
export function OpenInLLM(props: {
provider: 'chatgpt' | 'claude';
url: string;
type: AIActionType;
}) {
const { provider, url, type } = props;
const language = useLanguage();

const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';

return (
<AIActionWrapper
type={type}
icon={
provider === 'chatgpt' ? (
<ChatGPTIcon className="size-3.5 fill-current" />
) : (
<ClaudeIcon className="size-3.5 fill-current" />
)
}
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
href={getLLMURL(provider, url, language)}
/>
);
}

/**
* Wraps an action in a button (for the default action) or dropdown menu item.
*/
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;
disabled?: boolean;
}) {
const { type, icon, label, shortLabel, onClick, href, description, disabled } = props;

if (type === 'button') {
return (
<Button
icon={icon}
size="small"
variant="secondary"
label={shortLabel || label}
className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm"
onClick={onClick}
href={href}
target={href ? '_blank' : undefined}
disabled={disabled}
/>
);
}

return (
<DropdownMenuItem
className="flex items-stretch gap-2.5 p-2"
href={href}
target="_blank"
onClick={onClick}
disabled={disabled}
>
{icon ? (
<div className="flex size-5 items-center justify-center text-tint">
{typeof icon === 'string' ? (
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Regular}
className="size-4 fill-transparent stroke-current"
/>
) : (
icon
)}
</div>
) : null}
<div className="flex flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2 text-tint-strong">
<span className="truncate font-medium text-sm">{label}</span>
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null}
</span>
{description && <span className="truncate text-tint text-xs">{description}</span>}
</div>
</DropdownMenuItem>
);
}

/**
* 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));

switch (provider) {
case 'chatgpt':
return `https://chat.openai.com/?q=${prompt}`;
case 'claude':
return `https://claude.ai/new?q=${prompt}`;
default:
assertNever(provider);
}
}
103 changes: 103 additions & 0 deletions packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client';

import {
CopyMarkdown,
OpenDocsAssistant,
OpenInLLM,
ViewAsMarkdown,
} from '@/components/AIActions/AIActions';
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.
*/
withAIChat?: boolean;
pageURL: string;
}) {
const ref = useRef<HTMLDivElement>(null);

return (
<div ref={ref} className="hidden h-fit items-stretch justify-start sm:flex">
<DefaultAction {...props} />
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
button={
<Button
icon="chevron-down"
iconOnly
size="small"
variant="secondary"
className="hover:!scale-100 !shadow-none !rounded-l-none bg-tint-base text-sm"
/>
}
>
<AIActionsDropdownMenuContent {...props} />
</DropdownMenu>
</div>
);
}

/**
* The content of the dropdown menu.
*/
function AIActionsDropdownMenuContent(props: {
markdown?: string;
markdownPageUrl: string;
withAIChat?: boolean;
pageURL: string;
}) {
const { markdown, markdownPageUrl, withAIChat, pageURL } = props;

return (
<>
{withAIChat ? <OpenDocsAssistant type="dropdown-menu-item" /> : null}
{markdown ? (
<>
<CopyMarkdown
markdown={markdown}
isDefaultAction={!withAIChat}
type="dropdown-menu-item"
/>
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
</>
) : null}
<OpenInLLM provider="chatgpt" url={pageURL} type="dropdown-menu-item" />
<OpenInLLM provider="claude" url={pageURL} type="dropdown-menu-item" />
</>
);
}

/**
* 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, markdownPageUrl } = props;

if (withAIChat) {
return <OpenDocsAssistant type="button" />;
}

if (markdown) {
return <CopyMarkdown isDefaultAction={!withAIChat} markdown={markdown} type="button" />;
}

if (markdownPageUrl) {
return <ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="button" />;
}

return <OpenInLLM provider="chatgpt" url={pageURL} type="button" />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
<title>ChatGPT</title>
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
</svg>
);
}
Loading