Add agent actions to the prompt block (#4610)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Greg Bergé
2026-09-14 12:01:04 +02:00
committed by GitHub
parent c08d05ade5
commit 39316fc205
7 changed files with 377 additions and 111 deletions
+5
View File
@@ -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');
});
});
+70
View File
@@ -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);
}