mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Add agent actions to the prompt block (#4610)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Give the prompt block a default "Copy prompt" action alongside "Open in Claude", "Open in Codex" and "Open in Cursor", and remember the visitor's last pick across every prompt block.
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { type PromptActionId, setPromptAction, usePromptAction } from './promptAction';
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
ToggleChevron,
|
||||
} from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { AI_AGENTS, getAIAgent } from '@/lib/ai-agents';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/** How long the copy button shows its confirmation. */
|
||||
const COPIED_MESSAGE_DURATION = 1000;
|
||||
|
||||
/**
|
||||
* Actions of a prompt block: copying the prompt, or handing it to a coding agent. The visitor's
|
||||
* last pick becomes the main button, here and in every other prompt block they come across.
|
||||
*/
|
||||
export function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
|
||||
const { prompt, openInAIProviders } = props;
|
||||
const language = useLanguage();
|
||||
const selectedAction = usePromptAction();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, COPIED_MESSAGE_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [copied]);
|
||||
|
||||
const copyPrompt = () => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
// The pick follows the visitor from site to site, so fall back to copying wherever the agent
|
||||
// actions are turned off — and with nothing to hand over, an agent link would open an agent on
|
||||
// an empty prompt.
|
||||
const action: PromptActionId = openInAIProviders && prompt ? selectedAction : 'copy';
|
||||
const agent = action === 'copy' ? null : getAIAgent(action);
|
||||
|
||||
const mainButton = agent ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
icon={agent.icon}
|
||||
label={tString(language, 'open_in', agent.label)}
|
||||
href={agent.getURL(prompt)}
|
||||
// The OS picks the deep link up and the page stays put, where `_blank` would strand the
|
||||
// visitor on a tab that never loads anything.
|
||||
target="_self"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
|
||||
disabled={!prompt}
|
||||
onClick={copyPrompt}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
// Lifted above the header's overlay button, which otherwise swallows the clicks.
|
||||
<div className="relative z-20 flex shrink-0 items-center gap-2">
|
||||
{openInAIProviders ? (
|
||||
<ButtonGroup>
|
||||
{mainButton}
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-48 max-w-max"
|
||||
button={
|
||||
<Button
|
||||
label={tString(language, 'more')}
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
truncate={false}
|
||||
disabled={!prompt}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentIconStack />
|
||||
<ToggleChevron className="size-text-sm" />
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
active={action === 'copy'}
|
||||
leadingIcon="copy"
|
||||
onClick={() => {
|
||||
setPromptAction('copy');
|
||||
copyPrompt();
|
||||
}}
|
||||
>
|
||||
{tString(language, 'prompt_copy')}
|
||||
</DropdownMenuItem>
|
||||
{AI_AGENTS.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
active={action === item.id}
|
||||
leadingIcon={item.icon}
|
||||
href={item.getURL(prompt)}
|
||||
target="_self"
|
||||
onClick={() => setPromptAction(item.id)}
|
||||
>
|
||||
{tString(language, 'open_in', item.label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
mainButton
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents on offer, overlapped into a stack, so the menu advertises what it holds without
|
||||
* spelling out three names next to a button that already has one.
|
||||
*/
|
||||
function AgentIconStack() {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
{AI_AGENTS.map((agent, index) => (
|
||||
<span
|
||||
key={agent.id}
|
||||
className={tcls(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border border-tint bg-tint-base',
|
||||
// Each chip's border cuts into the one behind it, so the marks stay legible
|
||||
// however tightly they are stacked.
|
||||
index > 0 && '-ms-1.5'
|
||||
)}
|
||||
>
|
||||
<Icon icon={agent.icon} className="size-2.5" />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,11 @@ import React from 'react';
|
||||
import type { DocumentBlockPrompt } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/components/primitives';
|
||||
import { getURLForLLM } from '@/components/utils';
|
||||
import { PromptActions } from './PromptActions';
|
||||
import { ToggleChevron } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
|
||||
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
|
||||
|
||||
type PromptClientProps = DocumentBlockPrompt['data'] & {
|
||||
contentIcon: IconName | null;
|
||||
prompt: string;
|
||||
@@ -88,106 +85,3 @@ export function PromptClient(props: PromptClientProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
|
||||
const { prompt, openInAIProviders } = props;
|
||||
|
||||
return (
|
||||
<div className="relative z-20 flex shrink-0 items-center gap-2">
|
||||
<CopyPromptButton prompt={prompt} />
|
||||
{openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// time in milliseconds to show the "Copied" message after copying a prompt
|
||||
const COPIED_MESSAGE_DURATION = 1000;
|
||||
|
||||
function CopyPromptButton(props: { prompt: string }) {
|
||||
const { prompt } = props;
|
||||
const language = useLanguage();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, COPIED_MESSAGE_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [copied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xsmall"
|
||||
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
|
||||
disabled={!prompt}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenPromptDropdown(props: { prompt: string }) {
|
||||
const { prompt } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
align="end"
|
||||
className="!min-w-48 max-w-max"
|
||||
button={
|
||||
<Button
|
||||
label={tString(language, 'open_in_ai')}
|
||||
trailing={<ToggleChevron className="size-text-sm" />}
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
className="max-sm:hidden"
|
||||
disabled={!prompt}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{OPEN_IN_AI_PROVIDERS.map((provider) => {
|
||||
const definition = getPromptOpenActionDefinition(provider);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider}
|
||||
href={getURLForLLM(provider, prompt)}
|
||||
target="_blank"
|
||||
leadingIcon={definition.icon}
|
||||
>
|
||||
{tString(language, 'open_in', definition.label)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function getPromptOpenActionDefinition(action: AIProviders): { icon: IconName; label: string } {
|
||||
switch (action) {
|
||||
case 'cursor':
|
||||
return {
|
||||
icon: 'cursor',
|
||||
label: 'Cursor',
|
||||
};
|
||||
case 'claude':
|
||||
return {
|
||||
icon: 'claude',
|
||||
label: 'Claude',
|
||||
};
|
||||
case 'chatgpt':
|
||||
return {
|
||||
icon: 'chatgpt',
|
||||
label: 'ChatGPT',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { type AIAgentId, isAIAgentId } from '@/lib/ai-agents';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||
|
||||
/**
|
||||
* What a prompt block does when its main button is pressed: copy the prompt, or hand it to one of
|
||||
* the coding agents.
|
||||
*/
|
||||
export type PromptActionId = 'copy' | AIAgentId;
|
||||
|
||||
/**
|
||||
* Not namespaced per site: a visitor who works in Cursor works in Cursor everywhere, so the pick
|
||||
* follows them across sites the same way the `select` store's slugs do.
|
||||
*/
|
||||
const STORAGE_KEY = '@gitbook/prompt-action';
|
||||
|
||||
const DEFAULT_ACTION: PromptActionId = 'copy';
|
||||
|
||||
let state: PromptActionId = DEFAULT_ACTION;
|
||||
let loaded = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function isPromptActionId(value: unknown): value is PromptActionId {
|
||||
return value === 'copy' || isAIAgentId(value);
|
||||
}
|
||||
|
||||
function read(): PromptActionId {
|
||||
const stored = getLocalStorageItem<unknown>(STORAGE_KEY, DEFAULT_ACTION);
|
||||
return isPromptActionId(stored) ? stored : DEFAULT_ACTION;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt what's in storage, once per page load. Deferred to the first subscription rather than run at
|
||||
* module scope so the first client render still matches the server's, and the stored pick only
|
||||
* lands once React is listening.
|
||||
*/
|
||||
function load() {
|
||||
if (loaded || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
state = read();
|
||||
|
||||
// Another tab picking an action updates this one too.
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== null && event.key !== STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
state = read();
|
||||
emitChange();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
load();
|
||||
listeners.add(listener);
|
||||
// Storage was only read just now, so nudge the subscriber to re-read the snapshot.
|
||||
listener();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the action the visitor picked, for every prompt block on the page and the next one.
|
||||
*/
|
||||
export function setPromptAction(action: PromptActionId) {
|
||||
load();
|
||||
if (state === action) {
|
||||
return;
|
||||
}
|
||||
state = action;
|
||||
setLocalStorageItem(STORAGE_KEY, action);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* The action the visitor last picked, defaulting to copying the prompt.
|
||||
*/
|
||||
export function usePromptAction(): PromptActionId {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => state,
|
||||
() => DEFAULT_ACTION
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,13 @@ import assertNever from 'assert-never';
|
||||
/**
|
||||
* Returns the URL to open the page in a LLM with a pre-filled prompt.
|
||||
*/
|
||||
export function getURLForLLM(provider: 'chatgpt' | 'claude' | 'cursor', prompt: string) {
|
||||
export function getURLForLLM(provider: 'chatgpt' | 'claude', prompt: string) {
|
||||
const encodedPrompt = encodeURIComponent(prompt);
|
||||
switch (provider) {
|
||||
case 'chatgpt':
|
||||
return `https://chat.openai.com/?q=${encodedPrompt}`;
|
||||
case 'claude':
|
||||
return `https://claude.ai/new?q=${encodedPrompt}`;
|
||||
case 'cursor':
|
||||
return `https://cursor.com/link/prompt?text=${encodedPrompt}`;
|
||||
default:
|
||||
assertNever(provider);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { AI_AGENTS, getAIAgent, isAIAgentId } from './ai-agents';
|
||||
|
||||
describe('AI_AGENTS', () => {
|
||||
it('builds a deep link for each agent', () => {
|
||||
expect(AI_AGENTS.map((agent) => agent.getURL('hello'))).toEqual([
|
||||
'claude://code/new?q=hello',
|
||||
'codex://new?prompt=hello',
|
||||
'cursor://anysphere.cursor-deeplink/prompt?text=hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('encodes prompts so their content never leaks into the query string', () => {
|
||||
const prompt = 'Fix the bug in a/b.ts?\n#1 & be nice';
|
||||
|
||||
for (const agent of AI_AGENTS) {
|
||||
const url = agent.getURL(prompt);
|
||||
// Everything past the single `=` is the encoded prompt, so the separators a prompt can
|
||||
// contain (`?`, `#`, `&`, newlines) can't be read as URL syntax.
|
||||
const [prefix, ...rest] = url.split('=');
|
||||
expect(rest).toHaveLength(1);
|
||||
expect(prefix).not.toContain(' ');
|
||||
expect(decodeURIComponent(rest.join(''))).toBe(prompt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAIAgentId', () => {
|
||||
it('accepts known agents', () => {
|
||||
expect(isAIAgentId('claude')).toBe(true);
|
||||
expect(isAIAgentId('codex')).toBe(true);
|
||||
expect(isAIAgentId('cursor')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects anything else', () => {
|
||||
expect(isAIAgentId('copy')).toBe(false);
|
||||
expect(isAIAgentId('chatgpt')).toBe(false);
|
||||
expect(isAIAgentId(undefined)).toBe(false);
|
||||
expect(isAIAgentId({ id: 'claude' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAIAgent', () => {
|
||||
it('returns the agent for a known id', () => {
|
||||
expect(getAIAgent('cursor').label).toBe('Cursor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* A coding agent a prompt can be handed to.
|
||||
*/
|
||||
export interface AIAgent {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
/** The agent's deep link, opening it with the prompt typed in but not sent. */
|
||||
getURL: (prompt: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coding agents a prompt can be handed to, in the order menus offer them.
|
||||
*
|
||||
* Each link opens the agent installed on the visitor's machine, so the prompt never leaves their
|
||||
* computer. Each also caps how much it carries — around 14,000 characters for Claude, 8,000 for
|
||||
* Cursor.
|
||||
*
|
||||
* Claude Code's own `claude-cli://` scheme is deliberately absent: on macOS it launches by having
|
||||
* AppleScript *type* its command into a terminal, where the tty cuts the line at 1,024 bytes and
|
||||
* silently truncates the prompt. `claude://code/new` opens the same session inside the desktop app,
|
||||
* with no terminal in between.
|
||||
*/
|
||||
export const AI_AGENTS = [
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude',
|
||||
icon: 'claude',
|
||||
// https://support.claude.com/en/articles/14729294-open-claude-desktop-with-a-link
|
||||
getURL: (prompt: string) => `claude://code/new?q=${encodeURIComponent(prompt)}`,
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
// No Codex icon in the library; the OpenAI mark it shares with ChatGPT stands in, as it
|
||||
// already does for the Codex MCP page action.
|
||||
icon: 'chatgpt',
|
||||
getURL: (prompt: string) => `codex://new?prompt=${encodeURIComponent(prompt)}`,
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
icon: 'cursor',
|
||||
// https://cursor.com/docs/integrations/deeplinks
|
||||
getURL: (prompt: string) =>
|
||||
`cursor://anysphere.cursor-deeplink/prompt?text=${encodeURIComponent(prompt)}`,
|
||||
},
|
||||
] as const satisfies readonly AIAgent[];
|
||||
|
||||
export type AIAgentId = (typeof AI_AGENTS)[number]['id'];
|
||||
|
||||
/**
|
||||
* Find an agent by its identifier.
|
||||
*/
|
||||
export function getAIAgent(id: AIAgentId): (typeof AI_AGENTS)[number] {
|
||||
const agent = AI_AGENTS.find((agent) => agent.id === id);
|
||||
if (!agent) {
|
||||
throw new Error(`Unknown AI agent: ${id}`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a value is a known agent identifier, to validate persisted or external input.
|
||||
*/
|
||||
export function isAIAgentId(value: unknown): value is AIAgentId {
|
||||
return AI_AGENTS.some((agent) => agent.id === value);
|
||||
}
|
||||
Reference in New Issue
Block a user