diff --git a/.changeset/page-actions-items.md b/.changeset/page-actions-items.md new file mode 100644 index 000000000..b3d185ea2 --- /dev/null +++ b/.changeset/page-actions-items.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action diff --git a/bun.lock b/bun.lock index a01983909..fb34c9798 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.15", + "turbo": "^2.9.18", "vercel": "50.37.3", }, }, @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.183.0", + "@gitbook/api": "0.183.1", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -756,7 +756,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.183.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-0+6VyRH7me5AtzU+mwZVFHDaJudtnHYEcxwQ/qO7p2swj7dOe/dOevfaphgx+pUvhXAg7VVlihETaAW/WsLTzQ=="], + "@gitbook/api": ["@gitbook/api@0.183.1", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-l9Bse8mLXG9rUR+jTiD/xZYMQ21t4XrxQvvaa7skaibIyBjjnIsNGo7NhVmjESUKHrA6F40ouH36Oiv2DfH+Wg=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 3ba6b2a99..286c9e640 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.183.0", + "@gitbook/api": "0.183.1", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx index e70783fd4..bb03a3d71 100644 --- a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx +++ b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx @@ -3,9 +3,13 @@ import { Button, ButtonGroup } from '@/components/primitives/Button'; import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; -import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api'; -import React, { useRef } from 'react'; -import { useAI } from '../AI'; +import type { + CustomizationPageActionType, + GitSyncState, + SiteCustomizationSettings, +} from '@gitbook/api'; +import { type ReactNode, useRef } from 'react'; +import { type Assistant, useAI } from '../AI'; import { ToggleChevron } from '../primitives'; import { ActionCopyMCPCommand, @@ -21,6 +25,32 @@ import { type PageActionAssistantContext, } from './PageActions'; +/** + * Type of a built-in page action that can be displayed in the page actions menu. + */ +type PageActionType = `${CustomizationPageActionType}`; + +/** + * Order used to derive the list of actions from the deprecated boolean flags when the API does not + * provide `items` yet. It matches the order the page actions menu used before the `items` model, so + * existing sites keep the same dropdown ordering until they are migrated. + */ +const LEGACY_PAGE_ACTION_ORDER: PageActionType[] = [ + 'assistant', + 'markdown', + 'external-ai', + 'mcp', + 'git', + 'pdf', +]; + +/** + * Default-button priority used in legacy mode (no `items`). It reproduces the previous behavior, + * which only ever surfaced the assistant, the Git edit link or the markdown copy as the default + * action — never ChatGPT, MCP or PDF. + */ +const LEGACY_DEFAULT_ACTION_PRIORITY: PageActionType[] = ['assistant', 'git', 'markdown']; + export type PageActionsDropdownURLs = { html: string; markdown: string; @@ -43,19 +73,90 @@ interface PageActionsDropdownProps { } /** - * Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM). + * Dropdown menu for the page actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM…). + * + * The order and enabled state of the built-in actions are driven by `actions.items`, the ordered + * list of enabled page actions, with its first available action shown as a quick-access button. + * When the API does not provide `items` yet, the menu falls back to the previous ordering and + * default-action priority so existing sites keep their current behavior. */ export function PageActionsDropdown(props: PageActionsDropdownProps) { const ref = useRef(null); const language = useLanguage(); + const { siteTitle, urls } = props; - const defaultAction = usePageDefaultAction(props); - const dropdownActions = getPageDropdownActions(props); + const assistants = useAI().assistants.filter( + (assistant) => assistant.ui === true && assistant.pageAction + ); + // `items` is the source of truth when the API provides it. Until then (legacy mode), we derive + // the list from the deprecated boolean flags using the previous ordering. + const configuredItems = getConfiguredPageActionItems(props.actions); + const isLegacy = configuredItems === null; + const items = configuredItems ?? deriveLegacyPageActionItems(props.actions); - return defaultAction || dropdownActions.length > 0 ? ( + let defaultAction: ReactNode = null; + let markdownIsDefault = false; + if (urls.rss) { + // The RSS feed is not part of the configurable `items` list: it is only available on the + // relevant pages (e.g. blog/changelog index). It is promoted as the default action whenever + // present, as a contextual override of the configured list. + defaultAction = ; + } else { + // The default button is the first available action. With `items`, that is simply the first + // entry of the configured list. In legacy mode we keep the previous default-action priority + // (assistant → Git → markdown), so existing sites don't suddenly surface ChatGPT/MCP/PDF. + const defaultPriority = isLegacy ? LEGACY_DEFAULT_ACTION_PRIORITY : items; + const defaultActionType = defaultPriority.find( + (type) => items.includes(type) && isActionTypeAvailable(type, urls, assistants) + ); + if (defaultActionType) { + defaultAction = renderDefaultActionForType(defaultActionType, { + siteTitle, + urls, + assistants, + page: props.page, + }); + markdownIsDefault = defaultActionType === 'markdown'; + } + } + + // Build the dropdown menu items, grouped by action type. RSS is appended as its own group + // since it is not part of the configurable `items` list. + const groups: { key: string; items: ReactNode[] }[] = items + .map((type) => ({ + key: type, + items: renderDropdownActionsForType(type, { + siteTitle, + urls, + markdownIsDefault, + assistants, + page: props.page, + }), + })) + .filter((group) => group.items.length > 0); + + if (urls.rss) { + groups.push({ + key: 'rss', + items: [], + }); + } + + // Count the actual menu items (not the groups): the dropdown toggle must stay visible when a + // single action type still exposes more than one item beyond the default button (e.g. markdown + // exposes both "Copy page" and "View as Markdown"). + const menuItemCount = groups.reduce((total, group) => total + group.items.length, 0); + + // Insert a separator before each group; the leading one is hidden via `first:hidden`. + const dropdownActions = groups.flatMap((group) => [ + , + ...group.items, + ]); + + return defaultAction || menuItemCount > 0 ? ( {defaultAction} - {!defaultAction || dropdownActions.length > 1 ? ( + {!defaultAction || menuItemCount > 1 ? ( assistant.ui === true && assistant.pageAction - ); +function getConfiguredPageActionItems( + actions: SiteCustomizationSettings['pageActions'] +): PageActionType[] | null { + return actions.items ?? null; +} - return [ - ...assistants.map((assistant) => ( - - )), +/** + * Derive the ordered list of enabled page actions from the deprecated boolean flags, following the + * ordering used before the `items` model. Used only when the API does not provide `items`. + */ +function deriveLegacyPageActionItems( + actions: SiteCustomizationSettings['pageActions'] +): PageActionType[] { + return LEGACY_PAGE_ACTION_ORDER.filter((type) => { + switch (type) { + case 'external-ai': + return actions.externalAI; + case 'markdown': + return actions.markdown; + case 'mcp': + return actions.mcp; + // `assistant` is governed by the AI mode setting, and `git`/`pdf` are not represented + // by the legacy `pageActions` flags; all three are gated by availability at render time. + case 'assistant': + case 'git': + case 'pdf': + return true; + default: + return false; + } + }); +} - actions.markdown ? ( - - +/** + * Whether an action type can be rendered given the available URLs and assistants. + */ +function isActionTypeAvailable( + type: PageActionType, + urls: PageActionsDropdownURLs, + assistants: Assistant[] +): boolean { + switch (type) { + case 'assistant': + return assistants.length > 0; + case 'external-ai': + case 'markdown': + return true; + case 'mcp': + return !!urls.mcp; + case 'git': + return !!urls.editOnGit; + case 'pdf': + return !!urls.pdf; + default: + return false; + } +} + +/** + * Render the list of menu items shown in the dropdown for a given action type. + * + * Returns a flat array of items (without separators); the caller groups them and inserts the + * separators between groups. + */ +function renderDropdownActionsForType( + type: PageActionType, + params: { + siteTitle: string; + urls: PageActionsDropdownURLs; + markdownIsDefault: boolean; + assistants: Assistant[]; + page: PageActionAssistantContext; + } +): ReactNode[] { + const { siteTitle, urls, markdownIsDefault, assistants, page } = params; + + switch (type) { + case 'assistant': + return assistants.map((assistant) => ( + + )); + case 'external-ai': + return [ + , + , + ]; + case 'markdown': + return [ - - - ) : null, - - actions.externalAI ? ( - - - - - - ) : null, - - actions.mcp && urls.mcp ? ( - - - + />, + , + ]; + case 'mcp': + if (!urls.mcp) { + return []; + } + return [ + , + />, + />, - - ) : null, - - urls.editOnGit || urls.pdf || urls.rss ? ( - - - {urls.editOnGit ? ( - - ) : null} - {urls.rss ? : null} - {urls.pdf ? : null} - - ) : null, - ].filter(Boolean); + />, + ]; + case 'git': + if (!urls.editOnGit) { + return []; + } + return [ + , + ]; + case 'pdf': + if (!urls.pdf) { + return []; + } + return []; + default: + return []; + } } /** - * A default action shown as a quick-access button beside the dropdown menu + * Render the action shown as the quick-access default button for a given action type. */ -function usePageDefaultAction(props: PageActionsDropdownProps) { - const { urls, actions, page } = props; - const assistants = useAI().assistants.filter( - (assistant) => assistant.ui === true && assistant.pageAction - ); - - if (urls.rss) { - return ; +function renderDefaultActionForType( + type: PageActionType, + params: { + siteTitle: string; + urls: PageActionsDropdownURLs; + assistants: Assistant[]; + page: PageActionAssistantContext; } +): ReactNode { + const { urls, assistants, page } = params; - const assistant = assistants[0]; - if (assistant) { - return ; + switch (type) { + case 'assistant': + return assistants[0] ? ( + + ) : null; + case 'external-ai': + return ; + case 'markdown': + return ( + + ); + case 'mcp': + return urls.mcp ? : null; + case 'git': + return urls.editOnGit ? ( + + ) : null; + case 'pdf': + return urls.pdf ? : null; + default: + return null; } - - if (urls.editOnGit) { - return ( - - ); - } - - if (actions.markdown) { - return ( - - ); - } - - return null; } diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index f48478203..d118de9ae 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; import { getPageRSSURL } from '@/routes/rss'; -import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api'; +import { CustomizationAIMode, type RevisionPageDocument, SiteVisibility } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import urlJoin from 'url-join'; import { getPDFURLSearchParams } from '../PDF'; @@ -33,7 +33,10 @@ export async function PageHeader(props: { const hasPageActions = pageActionsEnabled && [ - ...Object.values(context.customization.pageActions), + context.customization.ai.mode === CustomizationAIMode.Assistant, + context.customization.pageActions.externalAI, + context.customization.pageActions.markdown, + context.customization.pageActions.mcp, context.customization.pdf.enabled, context.customization.git.showEditLink, withRSSFeed,