Skip to content

Commit 0003030

Browse files
authored
Implement AI actions dropdown (#3431)
1 parent 9ad8f45 commit 0003030

File tree

23 files changed

+778
-185
lines changed

23 files changed

+778
-185
lines changed

.changeset/forty-dolls-decide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': minor
3+
---
4+
5+
Implement AI actions dropdown
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
'use client';
2+
3+
import { useAIChatController } from '@/components/AI/useAIChat';
4+
import { useAIChatState } from '@/components/AI/useAIChat';
5+
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon';
6+
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon';
7+
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
8+
import AIChatIcon from '@/components/AIChat/AIChatIcon';
9+
import { Button } from '@/components/primitives/Button';
10+
import { DropdownMenuItem } from '@/components/primitives/DropdownMenu';
11+
import { tString, useLanguage } from '@/intl/client';
12+
import type { TranslationLanguage } from '@/intl/translations';
13+
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
14+
import assertNever from 'assert-never';
15+
import type React from 'react';
16+
import { useEffect, useRef } from 'react';
17+
import { create } from 'zustand';
18+
19+
type AIActionType = 'button' | 'dropdown-menu-item';
20+
21+
/**
22+
* Opens our AI Docs Assistant.
23+
*/
24+
export function OpenDocsAssistant(props: { type: AIActionType }) {
25+
const { type } = props;
26+
const chatController = useAIChatController();
27+
const chat = useAIChatState();
28+
const language = useLanguage();
29+
30+
return (
31+
<AIActionWrapper
32+
type={type}
33+
icon={<AIChatIcon state={chat.loading ? 'thinking' : 'default'} />}
34+
label={tString(language, 'ai_chat_ask', tString(language, 'ai_chat_assistant_name'))}
35+
shortLabel={tString(language, 'ask')}
36+
description={tString(
37+
language,
38+
'ai_chat_ask_about_page',
39+
tString(language, 'ai_chat_assistant_name')
40+
)}
41+
disabled={chat.loading}
42+
onClick={() => {
43+
// Open the chat if it's not already open
44+
if (!chat.opened) {
45+
chatController.open();
46+
}
47+
48+
// Send the "What is this page about?" message
49+
chatController.postMessage({
50+
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
51+
});
52+
}}
53+
/>
54+
);
55+
}
56+
57+
// We need to store the copied state in a store to share the state between the
58+
// copy button and the dropdown menu item.
59+
const useCopiedStore = create<{
60+
copied: boolean;
61+
setCopied: (copied: boolean) => void;
62+
}>((set) => ({
63+
copied: false,
64+
setCopied: (copied: boolean) => set({ copied }),
65+
}));
66+
67+
/**
68+
* Copies the markdown version of the page to the clipboard.
69+
*/
70+
export function CopyMarkdown(props: {
71+
markdown: string;
72+
type: AIActionType;
73+
isDefaultAction?: boolean;
74+
}) {
75+
const { markdown, type, isDefaultAction } = props;
76+
const language = useLanguage();
77+
const { copied, setCopied } = useCopiedStore();
78+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
79+
80+
// Close the dropdown menu manually after the copy button is clicked
81+
const closeDropdownMenu = () => {
82+
const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]');
83+
84+
// Cancel if no dropdown menu is open
85+
if (!dropdownMenu) return;
86+
87+
// Dispatch on `document` so that the event is captured by Radix's
88+
// dismissable-layer listener regardless of focus location.
89+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
90+
};
91+
92+
useEffect(() => {
93+
return () => {
94+
if (timeoutRef.current) {
95+
clearTimeout(timeoutRef.current);
96+
}
97+
};
98+
}, []);
99+
100+
const onClick = (e: React.MouseEvent) => {
101+
// Prevent default behavior for non-default actions to avoid closing the dropdown.
102+
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
103+
// Default action buttons are excluded from this behavior.
104+
if (!isDefaultAction) {
105+
e.preventDefault();
106+
}
107+
108+
// Cancel any pending timeout
109+
if (timeoutRef.current) {
110+
clearTimeout(timeoutRef.current);
111+
}
112+
113+
navigator.clipboard.writeText(markdown);
114+
115+
setCopied(true);
116+
117+
// Reset the copied state after 2 seconds
118+
timeoutRef.current = setTimeout(() => {
119+
// Close the dropdown menu if it's a dropdown menu item and not the default action
120+
if (type === 'dropdown-menu-item' && !isDefaultAction) {
121+
closeDropdownMenu();
122+
}
123+
124+
setCopied(false);
125+
}, 2000);
126+
};
127+
128+
return (
129+
<AIActionWrapper
130+
type={type}
131+
icon={copied ? 'check' : 'copy'}
132+
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')}
133+
description={tString(language, 'copy_page_markdown')}
134+
onClick={onClick}
135+
/>
136+
);
137+
}
138+
139+
/**
140+
* Redirects to the markdown version of the page.
141+
*/
142+
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) {
143+
const { markdownPageUrl, type } = props;
144+
const language = useLanguage();
145+
146+
return (
147+
<AIActionWrapper
148+
type={type}
149+
icon={<MarkdownIcon className="size-4 fill-current" />}
150+
label={tString(language, 'view_page_markdown')}
151+
description={tString(language, 'view_page_plaintext')}
152+
href={`${markdownPageUrl}.md`}
153+
/>
154+
);
155+
}
156+
157+
/**
158+
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude.
159+
*/
160+
export function OpenInLLM(props: {
161+
provider: 'chatgpt' | 'claude';
162+
url: string;
163+
type: AIActionType;
164+
}) {
165+
const { provider, url, type } = props;
166+
const language = useLanguage();
167+
168+
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude';
169+
170+
return (
171+
<AIActionWrapper
172+
type={type}
173+
icon={
174+
provider === 'chatgpt' ? (
175+
<ChatGPTIcon className="size-3.5 fill-current" />
176+
) : (
177+
<ClaudeIcon className="size-3.5 fill-current" />
178+
)
179+
}
180+
label={tString(language, 'open_in', providerLabel)}
181+
shortLabel={providerLabel}
182+
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
183+
href={getLLMURL(provider, url, language)}
184+
/>
185+
);
186+
}
187+
188+
/**
189+
* Wraps an action in a button (for the default action) or dropdown menu item.
190+
*/
191+
function AIActionWrapper(props: {
192+
type: AIActionType;
193+
icon: IconName | React.ReactNode;
194+
label: string;
195+
/**
196+
* The label to display in the button. If not provided, the `label` will be used.
197+
*/
198+
shortLabel?: string;
199+
onClick?: (e: React.MouseEvent) => void;
200+
description?: string;
201+
href?: string;
202+
disabled?: boolean;
203+
}) {
204+
const { type, icon, label, shortLabel, onClick, href, description, disabled } = props;
205+
206+
if (type === 'button') {
207+
return (
208+
<Button
209+
icon={icon}
210+
size="small"
211+
variant="secondary"
212+
label={shortLabel || label}
213+
className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm"
214+
onClick={onClick}
215+
href={href}
216+
target={href ? '_blank' : undefined}
217+
disabled={disabled}
218+
/>
219+
);
220+
}
221+
222+
return (
223+
<DropdownMenuItem
224+
className="flex items-stretch gap-2.5 p-2"
225+
href={href}
226+
target="_blank"
227+
onClick={onClick}
228+
disabled={disabled}
229+
>
230+
{icon ? (
231+
<div className="flex size-5 items-center justify-center text-tint">
232+
{typeof icon === 'string' ? (
233+
<Icon
234+
icon={icon as IconName}
235+
iconStyle={IconStyle.Regular}
236+
className="size-4 fill-transparent stroke-current"
237+
/>
238+
) : (
239+
icon
240+
)}
241+
</div>
242+
) : null}
243+
<div className="flex flex-1 flex-col gap-0.5">
244+
<span className="flex items-center gap-2 text-tint-strong">
245+
<span className="truncate font-medium text-sm">{label}</span>
246+
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null}
247+
</span>
248+
{description && <span className="truncate text-tint text-xs">{description}</span>}
249+
</div>
250+
</DropdownMenuItem>
251+
);
252+
}
253+
254+
/**
255+
* Returns the URL to open the page in a LLM with a pre-filled prompt.
256+
*/
257+
function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) {
258+
const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url));
259+
260+
switch (provider) {
261+
case 'chatgpt':
262+
return `https://chat.openai.com/?q=${prompt}`;
263+
case 'claude':
264+
return `https://claude.ai/new?q=${prompt}`;
265+
default:
266+
assertNever(provider);
267+
}
268+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use client';
2+
3+
import {
4+
CopyMarkdown,
5+
OpenDocsAssistant,
6+
OpenInLLM,
7+
ViewAsMarkdown,
8+
} from '@/components/AIActions/AIActions';
9+
import { Button } from '@/components/primitives/Button';
10+
import { DropdownMenu } from '@/components/primitives/DropdownMenu';
11+
import { useRef } from 'react';
12+
13+
/**
14+
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
15+
*/
16+
export function AIActionsDropdown(props: {
17+
markdown?: string;
18+
markdownPageUrl: string;
19+
/**
20+
* Whether to include the "Ask Docs Assistant" entry in the dropdown menu.
21+
*/
22+
withAIChat?: boolean;
23+
pageURL: string;
24+
}) {
25+
const ref = useRef<HTMLDivElement>(null);
26+
27+
return (
28+
<div ref={ref} className="hidden h-fit items-stretch justify-start sm:flex">
29+
<DefaultAction {...props} />
30+
<DropdownMenu
31+
align="end"
32+
className="!min-w-60 max-w-max"
33+
button={
34+
<Button
35+
icon="chevron-down"
36+
iconOnly
37+
size="small"
38+
variant="secondary"
39+
className="hover:!scale-100 !shadow-none !rounded-l-none bg-tint-base text-sm"
40+
/>
41+
}
42+
>
43+
<AIActionsDropdownMenuContent {...props} />
44+
</DropdownMenu>
45+
</div>
46+
);
47+
}
48+
49+
/**
50+
* The content of the dropdown menu.
51+
*/
52+
function AIActionsDropdownMenuContent(props: {
53+
markdown?: string;
54+
markdownPageUrl: string;
55+
withAIChat?: boolean;
56+
pageURL: string;
57+
}) {
58+
const { markdown, markdownPageUrl, withAIChat, pageURL } = props;
59+
60+
return (
61+
<>
62+
{withAIChat ? <OpenDocsAssistant type="dropdown-menu-item" /> : null}
63+
{markdown ? (
64+
<>
65+
<CopyMarkdown
66+
markdown={markdown}
67+
isDefaultAction={!withAIChat}
68+
type="dropdown-menu-item"
69+
/>
70+
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" />
71+
</>
72+
) : null}
73+
<OpenInLLM provider="chatgpt" url={pageURL} type="dropdown-menu-item" />
74+
<OpenInLLM provider="claude" url={pageURL} type="dropdown-menu-item" />
75+
</>
76+
);
77+
}
78+
79+
/**
80+
* A default action shown as a quick-access button beside the dropdown menu
81+
*/
82+
function DefaultAction(props: {
83+
markdown?: string;
84+
withAIChat?: boolean;
85+
pageURL: string;
86+
markdownPageUrl: string;
87+
}) {
88+
const { markdown, withAIChat, pageURL, markdownPageUrl } = props;
89+
90+
if (withAIChat) {
91+
return <OpenDocsAssistant type="button" />;
92+
}
93+
94+
if (markdown) {
95+
return <CopyMarkdown isDefaultAction={!withAIChat} markdown={markdown} type="button" />;
96+
}
97+
98+
if (markdownPageUrl) {
99+
return <ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="button" />;
100+
}
101+
102+
return <OpenInLLM provider="chatgpt" url={pageURL} type="button" />;
103+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) {
2+
return (
3+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}>
4+
<title>ChatGPT</title>
5+
<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" />
6+
</svg>
7+
);
8+
}

0 commit comments

Comments
 (0)