diff --git a/.changeset/assistant-chat-aria-busy.md b/.changeset/assistant-chat-aria-busy.md new file mode 100644 index 000000000..fc563d777 --- /dev/null +++ b/.changeset/assistant-chat-aria-busy.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state. diff --git a/.changeset/assistant-form-overflow.md b/.changeset/assistant-form-overflow.md new file mode 100644 index 000000000..d86d7b567 --- /dev/null +++ b/.changeset/assistant-form-overflow.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text. diff --git a/.changeset/cookieless-integration-content-host.md b/.changeset/cookieless-integration-content-host.md new file mode 100644 index 000000000..0ffc8ecdf --- /dev/null +++ b/.changeset/cookieless-integration-content-host.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured. diff --git a/.changeset/dvorak-shortcut-logical-key.md b/.changeset/dvorak-shortcut-logical-key.md new file mode 100644 index 000000000..ddbf72db0 --- /dev/null +++ b/.changeset/dvorak-shortcut-logical-key.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts). diff --git a/.changeset/hint-wide-content-overflow.md b/.changeset/hint-wide-content-overflow.md new file mode 100644 index 000000000..73f1956a7 --- /dev/null +++ b/.changeset/hint-wide-content-overflow.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them. diff --git a/.changeset/link-tooltip-external-button.md b/.changeset/link-tooltip-external-button.md new file mode 100644 index 000000000..52bcf63b8 --- /dev/null +++ b/.changeset/link-tooltip-external-button.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive. 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/.changeset/page-reference-chip.md b/.changeset/page-reference-chip.md new file mode 100644 index 000000000..0ad093c06 --- /dev/null +++ b/.changeset/page-reference-chip.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on. diff --git a/.changeset/rich-chefs-dig.md b/.changeset/rich-chefs-dig.md new file mode 100644 index 000000000..1490e5859 --- /dev/null +++ b/.changeset/rich-chefs-dig.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Support OpenAPI one-operation-per-page mode diff --git a/.changeset/table-block-search.md b/.changeset/table-block-search.md new file mode 100644 index 000000000..b25d7a82f --- /dev/null +++ b/.changeset/table-block-search.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a client-side search field to table blocks, with per-column filters for select and checkbox columns diff --git a/.changeset/tiny-items-talk.md b/.changeset/tiny-items-talk.md new file mode 100644 index 000000000..30c196306 --- /dev/null +++ b/.changeset/tiny-items-talk.md @@ -0,0 +1,5 @@ +--- +"@gitbook/react-openapi": patch +--- + +Handle circular oneOf with discriminator and allOf in OpenAPI schemas diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 8ade51001..8e5ee4823 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -55,6 +55,7 @@ runs: GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST + GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index 163a847fb..bb7b81750 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -57,6 +57,7 @@ runs: GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST + GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE diff --git a/bun.lock b/bun.lock index a01983909..c8a3208bf 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.184.0", "@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.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 3ba6b2a99..f2a7592a7 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.184.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 45f3abf2e..cb856294b 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -31,12 +31,24 @@ import { headerLinks, runTestCases, setTimeToMorning, + waitForAIChatResponse, waitForCookiesDialog, waitForCoverImages, waitForNotFound, } from './util'; -const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`; +// Kept as deterministic as possible to reduce visual flakiness: no preamble, a +// single fixed search, a concise answer, and a fixed number of follow-ups. The +// model is never perfectly deterministic, so `overrideAIResponse` still +// normalizes the rendered content below — this prompt just narrows the variance. +const AI_PROMPT = [ + "You're being invoked by the GitBook CI/CD pipeline for automated visual testing.", + 'Follow these instructions exactly and do not deviate:', + '1. Do not write any preamble, commentary, or reasoning before acting.', + '2. Perform a single search for exactly "Lorem ipsum".', + '3. Reply with only the first sentence of the first page you find, and nothing else.', + '4. Always end by proposing exactly 3 follow-up suggestions.', +].join('\n'); const overrideAIInitialState = () => { const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]'); @@ -44,21 +56,45 @@ const overrideAIInitialState = () => { greeting.textContent = 'Good morning'; } }; + +/** + * Normalize the non-deterministic content of an AI response before screenshotting, + * while preserving the surrounding structure (message bubbles, tool/activity + * summary, response container, suggestion buttons) so visual regressions in the + * chat chrome are still caught. The actual answer formatting is covered separately + * by the deterministic page tests, since the AI response renders through the same + * `DocumentView`. + * + * Must run only once the chat is no longer `aria-busy` (the response has fully + * settled), otherwise React re-renders from late stream events will clobber these + * mutations. See `waitForAIChatResponse`. + */ const overrideAIResponse = () => { - const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]'); - if (userMessage) { + // The user's prompt varies in length; pin it to a fixed string. + document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => { userMessage.textContent = '[Replaced message] Chat message sent by the user'; - } - const assistantMessage = document.querySelectorAll( - '[data-testid="ai-chat-message-assistant"] .ai-response-document' - ); - assistantMessage.forEach((message) => { - message.innerHTML = '[Replaced message] AI chat response'; }); - const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]'); - suggestions.forEach((suggestion) => { - suggestion.textContent = 'Follow-up suggestion'; + + // The assistant's answer text is non-deterministic; replace the rendered + // document body while keeping the `.ai-response-document` container. + document + .querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document') + .forEach((message) => { + message.innerHTML = '

[Replaced message] AI chat response

'; + }); + + // The "Explored with N tools" activity label varies with the number of tool + // calls; pin it (the chevron sibling is left intact). + document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => { + summary.textContent = 'Explored'; }); + + // Follow-up suggestion text varies; pin each label. + document + .querySelectorAll('[data-testid="ai-chat-followup-suggestion"]') + .forEach((suggestion) => { + suggestion.textContent = 'Follow-up suggestion'; + }); }; const searchTestCases: Test[] = [ @@ -167,12 +203,13 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT); await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible(); - await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, { - timeout: 60_000, - }); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIResponse); + // Wait for the full response (incl. follow-up suggestions) to settle before + // asserting/screenshotting, rather than racing a fixed suggestion count. + await waitForAIChatResponse(page); + await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible(); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse), }, { name: 'Ask - AI Mode: Assistant - Keyboard shortcut', @@ -186,9 +223,9 @@ const searchTestCases: Test[] = [ await page.keyboard.press('ControlOrMeta+I'); await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-input')).toBeFocused(); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIInitialState); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState), }, { name: 'Ask - AI Mode: Assistant - Button', @@ -202,9 +239,9 @@ const searchTestCases: Test[] = [ await page.getByTestId('ai-chat-button').click(); await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-input')).toBeFocused(); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIInitialState); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState), }, { name: 'Ask - AI Mode: Assistant - URL query (Initial)', @@ -219,9 +256,9 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('search-input')).toBeEmpty(); await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-input')).toBeFocused(); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIInitialState); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState), }, { name: 'Ask - AI Mode: Assistant - URL query (Results)', @@ -237,12 +274,13 @@ const searchTestCases: Test[] = [ await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT); await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible(); - await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, { - timeout: 60_000, - }); - // Override text content for visual consistency in screenshots - await page.evaluate(overrideAIResponse); + // Wait for the full response (incl. follow-up suggestions) to settle before + // asserting/screenshotting, rather than racing a fixed suggestion count. + await waitForAIChatResponse(page); + await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible(); }, + // Re-applied per viewport so the replacement survives resize-driven re-renders. + normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse), }, ]; @@ -1323,6 +1361,7 @@ const testCases: TestsCase[] = [ pageActions: { markdown: false, externalAI: false, + items: [], }, }), run: waitForCookiesDialog, @@ -2221,9 +2260,13 @@ const testCases: TestsCase[] = [ await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab await expect(iframe.getByTestId('ai-chat')).toBeVisible(); - - await iframe.owner().evaluate(overrideAIInitialState); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIInitialState), }, { name: 'API - navigateToPage', @@ -2257,8 +2300,15 @@ const testCases: TestsCase[] = [ await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText( AI_PROMPT ); - await iframe.owner().evaluate(overrideAIResponse); + // Wait for the full response to settle before normalizing. + await waitForAIChatResponse(iframe); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIResponse), }, { name: 'Configuration - Suggested questions', @@ -2284,8 +2334,13 @@ const testCases: TestsCase[] = [ await expect( iframe.getByTestId('ai-chat-suggested-question').nth(2) ).toHaveText('What can you do?'); - await iframe.owner().evaluate(overrideAIInitialState); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIInitialState), }, { name: 'Configuration - Custom action buttons', @@ -2367,8 +2422,15 @@ const testCases: TestsCase[] = [ await actions.nth(3).click(); await expect(page.locator('#gitbook-widget-window')).not.toBeVisible(); await page.locator('#gitbook-widget-button').click(); - await iframe.owner().evaluate(overrideAIResponse); + // Wait for the response posted above to settle before normalizing. + await waitForAIChatResponse(iframe); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIResponse), }, { name: 'Configuration - Custom tools', @@ -2412,8 +2474,16 @@ const testCases: TestsCase[] = [ await expect(toolConfirmation).toBeVisible({ timeout: 30000, }); - await iframe.owner().evaluate(overrideAIResponse); + // The turn settles (aria-busy clears) once the stream pauses on the + // confirmation control; wait for that before normalizing. + await waitForAIChatResponse(iframe); }, + // Runs inside the iframe (not the parent doc) and per viewport. + normalizeBeforeScreenshot: (page) => + page + .frameLocator('#gitbook-widget-iframe') + .locator('body') + .evaluate(overrideAIResponse), }, ], }, diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index fe206d040..dca42428b 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -13,6 +13,7 @@ import { CustomizationIconsStyle, CustomizationLinksStyle, CustomizationLocale, + CustomizationPageActionType, CustomizationSearchStyle, CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, @@ -21,7 +22,14 @@ import { type SiteCustomizationSettings, SiteExternalLinksTarget, } from '@gitbook/api'; -import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test'; +import { + type BrowserContext, + type FrameLocator, + type Page, + type Response, + expect, + test, +} from '@playwright/test'; import deepMerge from 'deepmerge'; import rison from 'rison'; import type { DeepPartial } from 'ts-essentials'; @@ -39,6 +47,16 @@ export interface Test { * Test to run */ run?: (page: Page, response: Response | null) => Promise; + /** + * Re-applied right before every viewport screenshot (after Argos + * stabilization), so it survives re-renders triggered by viewport resizing. + * + * Use this — rather than mutating the DOM once in `run` — to normalize + * non-deterministic content (e.g. AI responses). A one-time mutation in `run` + * is clobbered when React re-renders on resize (e.g. crossing the mobile + * breakpoint), so only the first viewport ends up normalized. + */ + normalizeBeforeScreenshot?: (page: Page) => Promise | void; /** * Mode for the test. */ @@ -159,6 +177,28 @@ export async function waitForNotFound(_page: Page, response: Response | null) { expect(response?.status()).toBe(404); } +/** + * Wait for an AI chat response to be fully settled before asserting or + * screenshotting it. + * + * The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`), + * which stays true from the moment a message is sent until the stream — including + * the follow-up suggestion phase — completes. Gating on it avoids the two main + * sources of flakiness: capturing a "thinking" placeholder or a half-streamed + * answer, and running the content normalization while React is still re-rendering + * (which would clobber the replacements). + * + * Argos also waits for `aria-busy` to clear during its own stabilization + * (`waitForAriaBusy`), so this is both an explicit gate and a backstop. + * + * Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe). + */ +export async function waitForAIChatResponse(scope: Page | FrameLocator) { + await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', { + timeout: 60_000, + }); +} + export async function setTimeToMorning(page: Page) { const now = new Date(); now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time) @@ -253,6 +293,9 @@ export function runTestCases(testCases: TestsCase[]) { await waitForTOCScrolling(page); } await waitForIcons(page); + // Re-apply per viewport, last — after any resize-driven + // re-render — so normalized content survives to capture. + await testEntry.normalizeBeforeScreenshot?.(page); }, }); } @@ -372,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial { + it('returns an empty string when there are no references', () => { + expect(serializeReferences([])).toBe(''); + }); + + it('serializes a single page reference as a markdown link', () => { + const refs: AIChatReference[] = [ + { type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' }, + ]; + const result = serializeReferences(refs); + expect(result).toContain('The user is referring to the following page they are reading'); + expect(result).toContain('- [Getting started](/getting-started)'); + expect(result.endsWith('\n\n---\n\n')).toBe(true); + }); + + it('falls back to the path when no href is provided', () => { + const refs: AIChatReference[] = [ + { type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' }, + ]; + const result = serializeReferences(refs); + expect(result).toContain('- [Overview](getting-started)'); + }); + + it('renders a quoted label when neither href nor path is provided', () => { + const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }]; + const result = serializeReferences(refs); + expect(result).toContain('- "Overview"'); + expect(result).not.toContain(']('); + }); + + it('uses the plural form for multiple pages', () => { + const refs: AIChatReference[] = [ + { type: 'page', id: 'page-1', label: 'One' }, + { type: 'page', id: 'page-2', label: 'Two' }, + ]; + const result = serializeReferences(refs); + expect(result).toContain('the following pages they are reading'); + expect(result).toContain('in the context of them'); + }); + + it('serializes a single code block reference', () => { + const refs: AIChatReference[] = [ + { + type: 'code-block', + id: 'code-1', + label: 'main.ts', + content: 'const a = 1;', + syntax: 'ts', + }, + ]; + const result = serializeReferences(refs); + expect(result).toContain('The user is referring to the following code block'); + expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```'); + }); + + it('escapes backtick fences inside the code content', () => { + const refs: AIChatReference[] = [ + { type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' }, + ]; + const result = serializeReferences(refs); + expect(result).toContain('````md\na ``` b\n````'); + }); + + it('groups page and code references into distinct sections', () => { + const refs: AIChatReference[] = [ + { type: 'page', id: 'page-1', label: 'Quickstart' }, + { type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' }, + ]; + const result = serializeReferences(refs); + const pageIndex = result.indexOf('referring to the following page'); + const codeIndex = result.indexOf('referring to the following code block'); + expect(pageIndex).toBeGreaterThanOrEqual(0); + expect(codeIndex).toBeGreaterThan(pageIndex); + }); +}); diff --git a/packages/gitbook/src/components/AI/references.ts b/packages/gitbook/src/components/AI/references.ts index d6958dc7c..76b162ff9 100644 --- a/packages/gitbook/src/components/AI/references.ts +++ b/packages/gitbook/src/components/AI/references.ts @@ -1,34 +1,93 @@ -import assertNever from 'assert-never'; - type BaseAIChatReference = { id: string; label?: string; - content: string; }; export type CodeBlockReference = BaseAIChatReference & { type: 'code-block'; + content: string; syntax?: string; }; -export type AIChatReference = CodeBlockReference; +export type PageReference = BaseAIChatReference & { + type: 'page'; + /** Title of the page being referenced. */ + label: string; + /** Path of the page, used to help the assistant identify it. */ + path?: string; + /** Site-relative href of the page, used to navigate back to it from the chip. */ + href?: string; +}; +export type TextReference = BaseAIChatReference & { + type: 'text'; + /** The selected text content. */ + content: string; +}; + +export type AIChatReference = CodeBlockReference | PageReference | TextReference; + +/** + * Serialize the staged references into a preamble prepended to the user's message, + * so the assistant is informed about the context the user is referring to. + */ export function serializeReferences(refs: AIChatReference[]): string { if (refs.length === 0) { return ''; } - const plural = refs.length > 1; - const blocks = refs.map(serializeReference).join('\n\n'); - return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}\n\n---\n\n`; + + const sections: string[] = []; + + const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page'); + if (pageRefs.length > 0) { + sections.push(serializePageReferences(pageRefs)); + } + + const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block'); + if (codeRefs.length > 0) { + sections.push(serializeCodeBlockReferences(codeRefs)); + } + + const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text'); + if (textRefs.length > 0) { + sections.push(serializeTextReferences(textRefs)); + } + + if (sections.length === 0) { + return ''; + } + + return `${sections.join('\n\n')}\n\n---\n\n`; } -function serializeReference(ref: AIChatReference): string { - switch (ref.type) { - case 'code-block': - return buildCodeBlockFence(ref); - default: - assertNever(ref.type); - } +function serializePageReferences(refs: PageReference[]): string { + const plural = refs.length > 1; + const list = refs + .map((ref) => { + const url = ref.href ?? ref.path; + return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`; + }) + .join('\n'); + return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`; +} + +function serializeCodeBlockReferences(refs: CodeBlockReference[]): string { + const plural = refs.length > 1; + const blocks = refs.map(buildCodeBlockFence).join('\n\n'); + return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`; +} + +function serializeTextReferences(refs: TextReference[]): string { + const plural = refs.length > 1; + const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n'); + return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`; +} + +function quoteText(content: string): string { + return content + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); } function buildCodeBlockFence(ref: CodeBlockReference): string { diff --git a/packages/gitbook/src/components/AI/useAI.tsx b/packages/gitbook/src/components/AI/useAI.tsx index 2bb697467..9c6c81e06 100644 --- a/packages/gitbook/src/components/AI/useAI.tsx +++ b/packages/gitbook/src/components/AI/useAI.tsx @@ -95,7 +95,7 @@ export function useAI(): AIContext { label: config.assistantName ?? getAIChatName(language, config.trademark), icon: ( diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 12408e162..575639341 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -86,7 +86,23 @@ export type AIChatState = { control: AnyAIControl | null; /** - * If true, the session is in progress. + * If true, the assistant is actively producing its answer — from the moment a + * message is sent until the `response_finish` event. It is cleared at that point + * (even though follow-up suggestions may still trickle in) so the input can be + * re-enabled. Drives the local "are we still answering" UI: the disabled input, + * the loading shim, and the thinking/exploring/working status. + */ + responding: boolean; + + /** + * If true, the turn is still in progress overall: from the moment a message is + * sent until the stream fully completes, including the follow-up suggestion + * phase. + * + * Unlike `responding` — which clears on `response_finish` — this stays true + * until the response is truly settled. It is the global busyness indicator, + * surfaced as `aria-busy` on the chat so that assistive tech (and visual tests) + * can wait for a complete, stable response. */ loading: boolean; @@ -160,6 +176,7 @@ const globalState = zustand.create(() => { query: null, followUpSuggestions: [], control: null, + responding: false, loading: false, error: false, initialQuery: null, @@ -255,6 +272,7 @@ export function AIChatProvider(props: { ...state, followUpSuggestions: [], control: null, + responding: true, loading: true, error: false, messages: [ @@ -268,6 +286,16 @@ export function AIChatProvider(props: { }; }); + // A stream becomes stale once a newer turn (or a clear) has replaced its + // query. Because `responding` clears on `response_finish` — before follow-up + // suggestions finish streaming — the user can start a new turn while this one + // is still wrapping up. A stale stream must not mutate the shared + // loading/responding state, which now belongs to the active turn; otherwise + // it would make the UI look idle mid-response. (`userQuery` is only set for + // user-initiated turns, not tool-call continuations.) + const isSuperseded = () => + !!input.userQuery && globalState.getState().query !== input.userQuery; + // Execute a tool call const executeToolCall = async (event: AIStreamResponseToolCallPending) => { const tools = getTools([navigateToPageTool]); @@ -330,8 +358,8 @@ export function AIChatProvider(props: { for await (const data of stream) { if (!data) continue; - if (input.userQuery && globalState.getState().query !== input.userQuery) { - // Chat was cleared, stop processing the stream + if (isSuperseded()) { + // Chat was cleared or a newer turn started; stop processing. break; } @@ -342,9 +370,9 @@ export function AIChatProvider(props: { globalState.setState((state) => ({ ...state, responseId: event.response.id ?? null, - // Mark as not loading when the response is finished + // Mark as not responding when the response is finished // Even if the stream might continue as we receive 'response_followup_suggestion' - loading: false, + responding: false, error: false, })); break; @@ -462,23 +490,40 @@ export function AIChatProvider(props: { })); } - // Execute the tool call if it doesn't require confirmation + // If a newer turn replaced this one while we were finishing (e.g. + // streaming follow-up suggestions after `response_finish`), abandon this + // stale stream without executing leftover tools or clearing the shared + // loading/responding state, which now belongs to the active turn. + if (isSuperseded()) { + return; + } + + // Execute the tool call if it doesn't require confirmation. + // When a tool call (or control) keeps the turn going, `loading` + // stays true: either the recursive `streamResponse` will clear it + // when its stream settles, or it is cleared below once the loop ends + // (e.g. while waiting on a user confirmation control). if (toolToExecute) { await executeToolCall(toolToExecute); } else { globalState.setState((state) => ({ ...state, + responding: false, loading: false, error: false, })); } } catch (error) { console.error('Error streaming AI response', error); - globalState.setState((state) => ({ - ...state, - loading: false, - error: true, - })); + // Don't surface a stale stream's error onto the active turn. + if (!isSuperseded()) { + globalState.setState((state) => ({ + ...state, + responding: false, + loading: false, + error: true, + })); + } } }, [ @@ -494,14 +539,14 @@ export function AIChatProvider(props: { // Post a message to the AI chat const onPostMessage = React.useCallback( async (input: { message: string }) => { - const { query, messages, control, references, loading } = globalState.getState(); + const { query, messages, control, references, responding } = globalState.getState(); if (control) { throw new Error("We can't post a message when a control is active"); } // Ignore duplicates while a previous turn is still streaming - if (loading) { + if (responding) { return; } @@ -550,7 +595,7 @@ export function AIChatProvider(props: { ], query: input.message, followUpSuggestions: [], - loading: true, + responding: true, error: false, initialQuery: state.initialQuery ?? input.message, references: [], @@ -566,6 +611,7 @@ export function AIChatProvider(props: { const onClear = React.useCallback(() => { globalState.setState((state) => ({ opened: state.opened, + responding: false, loading: false, messages: [], query: null, @@ -702,7 +748,7 @@ export function getAIChatStatus(chat: AIChatState): AIChatStatus { return 'confirm'; } - if (chat.loading) { + if (chat.responding) { const latestMessage = getLatestAssistantMessage(chat.messages); const phase = latestMessage?.activity?.currentPhase; switch (phase) { diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index 7ce10fffb..b2214d506 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -88,8 +88,8 @@ export function AIChat() { 'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80' )} > - - + + @@ -281,8 +281,8 @@ export function AIChatBody(props: { {chat.control ? : null} { chatController.postMessage({ message: value }); }} diff --git a/packages/gitbook/src/components/AIChat/AIChatInput.tsx b/packages/gitbook/src/components/AIChat/AIChatInput.tsx index 21f3d7536..f330bd619 100644 --- a/packages/gitbook/src/components/AIChat/AIChatInput.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatInput.tsx @@ -13,10 +13,10 @@ export function AIChatInput(props: { /** * When true, the input is disabled */ - loading: boolean; + responding: boolean; onSubmit: (value: string) => void; }) { - const { onSubmit, disabled, loading } = props; + const { onSubmit, disabled, responding } = props; const language = useLanguage(); const chat = useAIChatState(); @@ -35,7 +35,7 @@ export function AIChatInput(props: { }, [chat.inputDraft, chatController]); useEffect(() => { - if (chat.opened && !disabled && !loading) { + if (chat.opened && !disabled && !responding) { // Add a small delay to ensure the input is rendered before focusing // This fixes inconsistent focus behaviour across browsers const timeout = setTimeout(() => { @@ -44,7 +44,7 @@ export function AIChatInput(props: { return () => clearTimeout(timeout); } - }, [disabled, loading, chat.opened]); + }, [disabled, responding, chat.opened]); // Explicit focus requests (e.g. clicking "Ask" while the chat is already open). useEffect(() => { @@ -61,6 +61,9 @@ export function AIChatInput(props: { }, { enableOnFormTags: true, + // Match the logical character so Dvorak ⌘-C (physical "I" key) copies + // instead of focusing the Assistant input. RND-11340. + ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i', } ); @@ -87,21 +90,21 @@ export function AIChatInput(props: { rows={1} maxLength={2048} keyboardShortcut={ - !disabled && !loading + !disabled && !responding ? { keys: ['mod', 'i'], className: 'bg-tint-base group-focus-within/input:hidden', } : undefined } - disabled={disabled || loading || chat.control !== null} - aria-busy={loading} + disabled={disabled || responding || chat.control !== null} + aria-busy={responding} ref={inputRef} header={ } trailing={ diff --git a/packages/gitbook/src/components/AIChat/AIChatMessages.tsx b/packages/gitbook/src/components/AIChat/AIChatMessages.tsx index 95668124d..ff99c0cbd 100644 --- a/packages/gitbook/src/components/AIChat/AIChatMessages.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatMessages.tsx @@ -22,7 +22,7 @@ export function AIChatMessages(props: { }) { const { chat, chatController } = props; const status = getAIChatStatus(chat); - const showLoadingShim = chat.loading && status !== 'working' && status !== 'done'; + const showLoadingShim = chat.responding && status !== 'working' && status !== 'done'; // Group messages: user messages start a new group, all following messages until next user message belong to that group type MessageGroup = { message: AIChatMessage; originalIndex: number }; @@ -139,19 +139,21 @@ export function AIChatMessages(props: { className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start" >
- {toolCount > 0 - ? t( - language, - 'ai_chat_explored_with', - tString( + + {toolCount > 0 + ? t( language, - toolCount === 1 - ? 'tool_count' - : 'tool_count_plural', - toolCount.toString() + 'ai_chat_explored_with', + tString( + language, + toolCount === 1 + ? 'tool_count' + : 'tool_count_plural', + toolCount.toString() + ) ) - ) - : t(language, 'ai_chat_explored')} + : t(language, 'ai_chat_explored')} +
@@ -184,7 +186,7 @@ export function AIChatMessages(props: { {isLastMessage ? ( <> - {!chat.loading && + {!chat.responding && !chat.error && chat.query && chat.responseId && diff --git a/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx b/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx index 0e2d58b40..d7db9df2f 100644 --- a/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatReferenceChips.tsx @@ -1,6 +1,14 @@ -import { Icon } from '@gitbook/icons'; +'use client'; +import { Icon, type IconName } from '@gitbook/icons'; +import assertNever from 'assert-never'; + +import { tcls } from '@/lib/tailwind'; import type { AIChatReference } from '../AI/references'; +import { Link } from '../primitives'; + +const triggerClassName = + 'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint'; export function AIChatReferenceChips(props: { references: AIChatReference[]; @@ -15,43 +23,99 @@ export function AIChatReferenceChips(props: { return (
- {references.map((ref) => ( -
- - {onRemove ? ( - - ) : null} -
- ))} + {ref.type === 'text' ? ref.content : ref.label} + + + ); + + return ( +
+ {ref.type === 'page' && ref.href ? ( + // A page reference may be clicked from anywhere: render a link so it + // navigates back to the page (and supports cmd/ctrl-click to open in a + // new tab). + event.stopPropagation()} + > + {content} + + ) : ref.type === 'text' ? ( + // A text selection has no persistent DOM anchor to navigate to, so the + // excerpt is shown as plain (non-interactive) content. + {content} + ) : ( + + )} + {onRemove ? ( + + ) : null} +
+ ); + })}
); } +function getReferenceIcon(ref: AIChatReference): IconName { + switch (ref.type) { + case 'code-block': + return 'code'; + case 'page': + return 'memo'; + case 'text': + return 'quote-left'; + default: + assertNever(ref); + } +} + +/** + * Jump to the content a reference points at, for chips that aren't rendered as links: + * - a code block: scroll it into view on the current page and focus it; + * - a page without a known href: scroll back to the top (the reader is most likely on it). + */ function focusReference(ref: AIChatReference) { + if (ref.type === 'page') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + const candidates = document.querySelectorAll(`#${CSS.escape(ref.id)}`); const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]')); if (!target) { diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx b/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx new file mode 100644 index 000000000..94056b6ee --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/AskAITextSelection.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { CustomizationAIMode } from '@gitbook/api'; +import fnv1a from '@sindresorhus/fnv1a'; +import { AnimatePresence, motion } from 'motion/react'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import { useAIChatController, useAIConfig } from '@/components/AI'; +import { useIsMobile } from '@/components/hooks/useIsMobile'; +import { useIsMounted } from '@/components/hooks/useIsMounted'; +import { Button } from '@/components/primitives'; +import { t, useLanguage } from '@/intl/client'; + +import { AIChatIcon } from '../AIChatIcon'; +import { useStableTextSelection } from './useStableTextSelection'; + +/** Gap between the selection and the button. */ +const GAP = 8; +/** Minimum distance to the viewport edges. */ +const MARGIN = 8; + +/** + * Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a + * reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices. + */ +export function AskAITextSelection() { + const config = useAIConfig(); + const language = useLanguage(); + const chatController = useAIChatController(); + const isMobile = useIsMobile(); + const isMounted = useIsMounted(); + + const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile; + + const toolbarRef = React.useRef(null); + const { selection, clear } = useStableTextSelection({ + rootSelector: '[data-content-ref-root]', + enabled, + ignoreRef: toolbarRef, + }); + + const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null); + + // Position once the button has been measured, so it can be centered and clamped to the viewport. + React.useLayoutEffect(() => { + if (!selection) { + return; + } + const el = toolbarRef.current; + if (!el) { + return; + } + + const width = el.offsetWidth; + const height = el.offsetHeight; + const { anchor } = selection; + + let top = anchor.top - GAP - height; + if (top < MARGIN) { + // Not enough room above the selection: drop below it. + top = anchor.bottom + GAP; + } + top = Math.min(top, window.innerHeight - height - MARGIN); + + const left = Math.min( + Math.max(anchor.centerX - width / 2, MARGIN), + window.innerWidth - width - MARGIN + ); + + setCoords({ top, left }); + }, [selection]); + + const onClick = () => { + if (!selection) { + return; + } + const content = selection.text; + if (!content.trim()) { + return; + } + + chatController.addReference({ + type: 'text', + id: `text-${fnv1a(content, { size: 32 })}`, + content, + }); + chatController.open(); + chatController.focus(); + clear(); + }; + + if (!enabled || !isMounted) { + return null; + } + + return createPortal( + + {selection ? ( + event.preventDefault()} + > + + + ) : null} + , + document.body + ); +} diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts b/packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts new file mode 100644 index 000000000..5e6f83c85 --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/index.ts @@ -0,0 +1 @@ +export * from './AskAITextSelection'; diff --git a/packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts b/packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts new file mode 100644 index 000000000..ae0e92f3c --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AskAITextSelection/useStableTextSelection.ts @@ -0,0 +1,202 @@ +'use client'; + +import * as React from 'react'; + +type SelectionAnchor = { + top: number; + bottom: number; + centerX: number; +}; + +export type StableTextSelection = { + anchor: SelectionAnchor; + text: string; +}; + +type Options = { + /** Selector of the content root; both selection endpoints must be inside it. */ + rootSelector: string; + enabled: boolean; + /** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */ + ignoreRef: React.RefObject; +}; + +const KEYBOARD_DEBOUNCE_MS = 250; +const SCROLL_SETTLE_MS = 200; +// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show. +const POINTER_SETTLE_MS = 120; + +/** + * Track a stable text selection within `rootSelector`, debounced so the floating UI it powers + * doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and + * when a new gesture starts. + */ +export function useStableTextSelection(options: Options): { + selection: StableTextSelection | null; + clear: () => void; +} { + const { rootSelector, enabled, ignoreRef } = options; + const [selection, setSelection] = React.useState(null); + + const clear = React.useCallback(() => { + window.getSelection()?.removeAllRanges(); + setSelection(null); + }, []); + + React.useEffect(() => { + if (!enabled) { + setSelection(null); + return; + } + + let pointerDown = false; + let keyboardTimer: ReturnType | null = null; + let scrollTimer: ReturnType | null = null; + let finalizeTimer: ReturnType | null = null; + + const clearKeyboardTimer = () => { + if (keyboardTimer) { + clearTimeout(keyboardTimer); + keyboardTimer = null; + } + }; + + const clearFinalizeTimer = () => { + if (finalizeTimer) { + clearTimeout(finalizeTimer); + finalizeTimer = null; + } + }; + + const finalize = () => { + // A new gesture started; it will reschedule its own finalize. + if (pointerDown) { + return; + } + setSelection(readStableSelection(rootSelector)); + }; + + const schedulePointerFinalize = () => { + clearKeyboardTimer(); + clearFinalizeTimer(); + finalizeTimer = setTimeout(() => { + finalizeTimer = null; + finalize(); + }, POINTER_SETTLE_MS); + }; + + const isIgnored = (target: EventTarget | null) => + target instanceof Node && !!ignoreRef.current?.contains(target); + + const onSelectionChange = () => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) { + clearKeyboardTimer(); + setSelection(null); + return; + } + // Mid-drag, or a pointer finalize is already queued: let the gesture settle first. + if (pointerDown || finalizeTimer) { + return; + } + clearKeyboardTimer(); + keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS); + }; + + const onPointerDown = (event: PointerEvent) => { + if (isIgnored(event.target)) { + return; + } + // New gesture: hide and cancel a pending show so it can't fire mid double-click. + pointerDown = true; + clearFinalizeTimer(); + setSelection(null); + }; + + const onPointerUp = (event: PointerEvent) => { + if (isIgnored(event.target)) { + return; + } + pointerDown = false; + schedulePointerFinalize(); + }; + + // pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff). + const onPointerCancel = () => { + pointerDown = false; + schedulePointerFinalize(); + }; + + const onScroll = () => { + setSelection(null); + if (scrollTimer) { + clearTimeout(scrollTimer); + } + scrollTimer = setTimeout(() => { + if (!pointerDown) { + finalize(); + } + }, SCROLL_SETTLE_MS); + }; + + const onWindowBlur = () => { + clearKeyboardTimer(); + setSelection(null); + }; + + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('pointerup', onPointerUp, true); + document.addEventListener('pointercancel', onPointerCancel, true); + window.addEventListener('scroll', onScroll, true); + window.addEventListener('blur', onWindowBlur); + + return () => { + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('pointerup', onPointerUp, true); + document.removeEventListener('pointercancel', onPointerCancel, true); + window.removeEventListener('scroll', onScroll, true); + window.removeEventListener('blur', onWindowBlur); + clearKeyboardTimer(); + clearFinalizeTimer(); + if (scrollTimer) { + clearTimeout(scrollTimer); + } + }; + }, [enabled, rootSelector, ignoreRef]); + + return { selection, clear }; +} + +function readStableSelection(rootSelector: string): StableTextSelection | null { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) { + return null; + } + + const text = sel.toString().trim(); + if (!text) { + return null; + } + + const root = document.querySelector(rootSelector); + if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) { + return null; + } + + const anchor = getSelectionAnchor(sel); + return anchor ? { anchor, text } : null; +} + +/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */ +function getSelectionAnchor(sel: Selection): SelectionAnchor | null { + const rect = sel.getRangeAt(0).getBoundingClientRect(); + if (!rect.width && !rect.height) { + return null; + } + if (rect.bottom < 0 || rect.top > window.innerHeight) { + return null; + } + return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 }; +} diff --git a/packages/gitbook/src/components/AIChat/index.ts b/packages/gitbook/src/components/AIChat/index.ts index 12eaa899e..57307673a 100644 --- a/packages/gitbook/src/components/AIChat/index.ts +++ b/packages/gitbook/src/components/AIChat/index.ts @@ -3,3 +3,4 @@ export * from './AIChatButton'; export * from './AIChatIcon'; export * from './AIResponseFeedback'; export * from './AIChatControlButton'; +export * from './AskAITextSelection'; diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx index 1fd9cc253..2a4c59935 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx @@ -33,6 +33,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withWrap = block.data.overflow === 'wrap'; const title = block.data.title; + const isSingleLine = theme.lines.length <= 1 && !withWrap; const id = useId(); const codeId = props.id || id; @@ -79,7 +80,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer( ) : null}
-
+
{!isPrint ? (
 handleSubmit(value as string)}
diff --git a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
index dc1d72d15..1623dcbb2 100644
--- a/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
+++ b/packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx
@@ -151,10 +151,7 @@ function InlineLinkTooltipWrapper(props: {
         ;
     target: {
-        href: string;
         text: string;
         subText?: string;
         icon?: React.ReactNode;
     };
-    openInNewTabLabel: string;
     children: React.ReactNode;
 }) {
-    const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
+    const { isExternal, target, breadcrumbs, children } = props;
 
     return (
         
             {children}
             
-                
-
- {breadcrumbs && breadcrumbs.length > 0 ? ( -
- {breadcrumbs.map((crumb, index) => { - const Tag = crumb.href ? StyledLink : 'div'; +
+ {breadcrumbs && breadcrumbs.length > 0 ? ( +
+ {breadcrumbs.map((crumb, index) => { + const Tag = crumb.href ? StyledLink : 'div'; - return ( - - {index !== 0 ? ( - + return ( + + {index !== 0 ? ( + + ) : null} + + {crumb.icon ? ( + + {crumb.icon} + ) : null} - - {crumb.icon ? ( - - {crumb.icon} - - ) : null} - {crumb.label} - - - ); - })} -
- ) : null} -
- {target.icon ? ( -
- {target.icon} -
- ) : null} -
{target.text}
+ {crumb.label} + + + ); + })}
-
- {!isSamePage && target.href ? ( -
{target.subText ?

{target.subText}

: null} diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 6286bbce7..8904145fd 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -1,4 +1,4 @@ -import { GITBOOK_INTEGRATIONS_HOST } from '@/lib/env'; +import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env'; import { tcls } from '@/lib/tailwind'; import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api'; import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit'; @@ -82,7 +82,16 @@ export async function IntegrationBlock(props: BlockProps data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'], expandAllModelSections: data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'], + headless: block.type === 'openapi-operation' ? block.data.headless : undefined, })} className="openapi-block" /> diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx index 2c6b21946..00b6b25cc 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPIWebhook.tsx @@ -56,6 +56,7 @@ async function OpenAPIWebhookBody(props: BlockProps) { data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'], expandAllModelSections: data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'], + headless: block.data.headless, })} className="openapi-block" /> diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 9f37bbd2f..0a102bf13 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -26,8 +26,9 @@ export function getOpenAPIContext(args: { context: GitBookAnyContext | undefined; expandAllResponses?: boolean; expandAllModelSections?: boolean; + headless?: boolean; }): OpenAPIContextInput { - const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args; + const { props, specUrl, context, expandAllResponses, expandAllModelSections, headless } = args; const { block } = props; const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE; @@ -99,6 +100,7 @@ export function getOpenAPIContext(args: { ), expandAllResponses: expandAllResponses || props.context.mode === 'print', expandAllModelSections: expandAllModelSections || props.context.mode === 'print', + headless, id: block.meta?.id, blockKey: block.key, locale, diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css index fa5ae7752..faec737a1 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css @@ -272,7 +272,7 @@ body { } .scalar-activate-button { @apply flex gap-2 items-center; - @apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start; + @apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-lg straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start; @apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs; @apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12; @apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all; diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index 25abf7ab8..b4f52a72a 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -40,7 +40,7 @@ .openapi-deprecated, .openapi-stability { - @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; + @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; } .openapi-stability-alpha { @@ -78,7 +78,7 @@ } .openapi-markdown code { - @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; + @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; } .openapi-markdown pre code { @@ -101,7 +101,17 @@ /* Method Tags */ .openapi-method, .openapi-statuscode { - @apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap; + @apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap; +} + +.openapi-method-small {} + +.openapi-method-medium { + @apply m-0 px-2.5 py-1 h-6 text-[0.813rem]; +} + +.toclink .openapi-method { + @apply text-[0.625rem] flex items-center justify-center; } .openapi-method-get, @@ -130,7 +140,8 @@ .openapi-method-head, .openapi-method-options, -.openapi-method-trace { +.openapi-method-trace, +.openapi-method-hook { @apply bg-tint; } @@ -149,7 +160,7 @@ } .openapi-column-preview { - @apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset); + @apply flex flex-col flex-1 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] scroll-mt-4 top-[calc(var(--toc-top-offset)+1.5rem)]; } .openapi-column-preview-body { @@ -276,11 +287,11 @@ } .openapi-schema-enum-value:first-child { - @apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0; + @apply rounded-l-md straight-corners:rounded-none circular-corners:rounded-l-lg ml-0; } .openapi-schema-enum-value:last-child { - @apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md; + @apply rounded-r-md straight-corners:rounded-none circular-corners:rounded-r-lg; } /* Schema Description */ @@ -314,7 +325,7 @@ .openapi-schema-pattern code, .openapi-schema-enum-value code, .openapi-schema-default code { - @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; + @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none; } /* Authentication */ @@ -344,7 +355,7 @@ } .openapi-securities-url { - @apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors; + @apply ml-0.5 px-0.5 rounded-md straight-corners:rounded-none circular-corners:rounded-lg hover:bg-tint dark:hover:bg-tint-hover transition-colors; } .openapi-securities-body { @@ -455,16 +466,12 @@ @apply max-w-full overflow-hidden shrink pl-0.5 py-0.5; } -.openapi-response-examples-header .openapi-select > button .openapi-statuscode { - @apply h-full; -} - .openapi-codesample-header-content { @apply flex flex-row items-center justify-between h-fit p-2.5; } .openapi-codesample-header-content .openapi-path { - @apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong; + @apply flex items-center font-mono text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong; } .openapi-codesample-header-content .openapi-path-title { @@ -491,17 +498,13 @@ } .openapi-path-variable { - @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none; + @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-none before:content-none! after:!content-none; } .openapi-path-server { @apply text-tint inline; } -.openapi-summary .openapi-path .openapi-method { - @apply m-0 items-center flex px-2 py-1 h-6; -} - .openapi-path-title { @apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10; @apply whitespace-nowrap md:whitespace-normal; @@ -537,7 +540,7 @@ .openapi-panel, .openapi-codesample, .openapi-response-examples { - @apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm; + @apply border shrink min-h-32 overflow-hidden rounded-corners:rounded-xl circular-corners:rounded-2xl straight-corners:rounded-xs bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm; } .openapi-response-examples-panel { @@ -616,7 +619,7 @@ body:has(.openapi-select-popover) { } .openapi-select > button { - @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none; + @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg leading-none; @apply hover:bg-tint dark:hover:bg-tint-hover transition-all; } @@ -649,7 +652,7 @@ body:has(.openapi-select-popover) { } .openapi-select-popover { - @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl; + @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none; @apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1; } @@ -662,7 +665,7 @@ body:has(.openapi-select-popover) { } .openapi-select-item { - @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none; + @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded-md straight-corners:rounded-none circular-corners:rounded-lg !outline-none; @apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current; } @@ -763,7 +766,7 @@ body:has(.openapi-select-popover) { } .openapi-tabs-tab { - @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md; + @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded-md straight-corners:rounded-none circular-corners:rounded-lg; } .openapi-tabs-tab[aria-selected="true"] { @@ -777,7 +780,7 @@ body:has(.openapi-select-popover) { /* Disclosure group */ .openapi-disclosure-group { - @apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none circular-corners:last:rounded-b-xl first:rounded-t-md straight-corners:first:rounded-none circular-corners:first:rounded-t-xl first:border-t relative; + @apply border-tint-subtle transition-all border-b border-x overflow-auto rounded-corners:last:rounded-b-xl circular-corners:last:rounded-b-2xl straight-corners:last:rounded-b-xs rounded-corners:first:rounded-t-xl circular-corners:first:rounded-t-2xl straight-corners:first:rounded-t-xs first:border-t relative; } .openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) { @@ -830,7 +833,7 @@ body:has(.openapi-select-popover) { /* Disclosure */ .openapi-schemas-disclosure { - @apply border-t border-x last:border-b border-tint-subtle !ring-0 first:!rounded-t-xl last:!rounded-b-xl !rounded-none; + @apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none; } .openapi-schemas-disclosure > .openapi-disclosure-trigger { @@ -875,7 +878,7 @@ body:has(.openapi-select-popover) { .openapi-schema-alternatives .openapi-disclosure, .openapi-schemas-disclosure .openapi-schema.openapi-disclosure ) { - @apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none; + @apply rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none; } .openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure { @@ -898,7 +901,7 @@ body:has(.openapi-select-popover) { } .openapi-disclosure-trigger-label { - @apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs; + @apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded-md straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs; } .openapi-disclosure-trigger-label span { @@ -961,7 +964,7 @@ body:has(.openapi-select-popover) { } .openapi-tooltip { - @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg font-medium px-1.5 py-0.5 shadow-sm text-[13px]; + @apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px]; } .openapi-tooltip svg { @@ -1025,7 +1028,7 @@ body:has(.openapi-select-popover) { } .openapi-path-copy-button { - @apply p-1 flex rounded-md straight-corners:rounded-none; + @apply p-1 flex rounded-lg straight-corners:rounded-none; @apply hover:bg-tint dark:hover:bg-tint-hover; } @@ -1042,7 +1045,7 @@ body:has(.openapi-select-popover) { } .openapi-required-scopes { - @apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0; + @apply border text-base rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium mx-0; } .openapi-required-scopes .openapi-required-scopes-header { diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx index 73a708ad2..605d82f6a 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx @@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind'; import { RecordColumnValue } from './RecordColumnValue'; import type { TableRecordKV, TableViewProps } from './Table'; +import { TableSearchRecord } from './TableSearch'; import { getColumnWidth } from './layout'; import { getColumnVerticalAlignment } from './utils'; @@ -14,19 +15,21 @@ export function RecordRow( fixedColumns: string[]; } ) { - const { view, autoSizedColumns, fixedColumns, block, context } = props; + const { view, record, autoSizedColumns, fixedColumns, block, context } = props; const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true; const firstVisibleColumn = view.columns[0]; return ( -
{view.columns.map((column) => { const columnWidth = getColumnWidth({ @@ -63,6 +66,6 @@ export function RecordRow(
); })} -
+ ); } diff --git a/packages/gitbook/src/components/DocumentView/Table/Table.tsx b/packages/gitbook/src/components/DocumentView/Table/Table.tsx index af0236f1d..cb280002b 100644 --- a/packages/gitbook/src/components/DocumentView/Table/Table.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/Table.tsx @@ -1,4 +1,4 @@ -import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api'; +import type { DocumentBlockTable } from '@gitbook/api'; import assertNever from 'assert-never'; import { tcls } from '@/lib/tailwind'; @@ -6,11 +6,23 @@ import { tcls } from '@/lib/tailwind'; import type { BlockProps } from '../Block'; import { isBlockOffscreen } from '../utils'; import { StickyViewGrid } from './StickyViewGrid'; +import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch'; import { ViewCards } from './ViewCards'; import { ViewGrid, ViewGridHeader } from './ViewGrid'; import { getViewGridLayout, hasVisibleHeader } from './layout'; +import { + type TableRecordKV, + getTableCheckboxColumns, + getTableRecordSearchData, + getTableSelectColumns, +} from './search'; -export type TableRecordKV = [string, DocumentTableRecord]; +export type { TableRecordKV }; + +/** + * Only show the table search once there are enough records that searching is useful. + */ +const MIN_RECORDS_FOR_SEARCH = 7; export interface TableViewProps extends BlockProps { view: View; @@ -22,9 +34,40 @@ export function Table(props: BlockProps) { const { block, ancestorBlocks, document, context, style } = props; const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document }); - const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => { - return a[1].orderIndex.localeCompare(b[1].orderIndex); - }); + const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => + a[1].orderIndex.localeCompare(b[1].orderIndex) + ); + + const showSearch = context.mode !== 'print' && records.length >= MIN_RECORDS_FOR_SEARCH; + const searchRecords = showSearch + ? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) })) + : []; + + return ( + +
+ {showSearch ? ( + + ) : null} + + +
+
+ ); +} + +/** + * Renders the table itself (grid or cards view) for the given records. + */ +function TableView({ + isOffscreen, + records, + ...props +}: BlockProps & { isOffscreen: boolean; records: TableRecordKV[] }) { + const { block, context, style } = props; switch (block.data.view.type) { case 'cards': diff --git a/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx new file mode 100644 index 000000000..4d01fe1fe --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/TableSearch.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives'; +import { tString, useLanguage } from '@/intl/client'; +import { type ClassValue, tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; +import React from 'react'; +import type { TableCheckboxColumn, TableSelectColumn } from './search'; +import { type SelectedOptions, recordMatches } from './searchMatch'; + +/** + * Client-side table search. + * + * Site pages are statically rendered and can't read `searchParams`, so filtering happens entirely + * on the client rather than round-tripping through the server. The provider matches every record + * once and exposes the set of visible ids; each row/card just looks itself up by id. + */ + +/** Per-record matching data, computed on the server. */ +export interface TableSearchRecordData { + /** Record key, matching the `key` passed to ``. */ + id: string; + searchText: string; + selectValues?: Record; + checkboxValues?: Record; +} + +type TableSearchContextValue = { + query: string; + setQuery: (query: string) => void; + selectedOptions: SelectedOptions; + toggleOption: (column: string, value: string) => void; + /** Checkbox columns whose filter is currently enabled. */ + checkedColumns: ReadonlySet; + toggleCheckbox: (column: string) => void; + /** + * Ids of the records matching the active filters, or `null` when no filter is active + * (in which case every record is shown). + */ + visibleIds: ReadonlySet | null; + /** True when there are records but the active filters match none of them. */ + isEmpty: boolean; +}; + +const TableSearchContext = React.createContext(null); + +/** + * Holds the search query and active filters for a single table. + */ +export function TableSearchProvider(props: { + records?: TableSearchRecordData[]; + children: React.ReactNode; +}) { + const { records = [] } = props; + const [query, setQuery] = React.useState(''); + const [selectedOptions, setSelectedOptions] = React.useState(() => ({})); + const [checkedColumns, setCheckedColumns] = React.useState>( + () => new Set() + ); + + const toggleOption = React.useCallback((column: string, value: string) => { + setSelectedOptions((previous) => { + const values = new Set(previous[column]); + if (values.has(value)) { + values.delete(value); + } else { + values.add(value); + } + + const next = { ...previous }; + if (values.size === 0) { + delete next[column]; + } else { + next[column] = values; + } + return next; + }); + }, []); + + const toggleCheckbox = React.useCallback((column: string) => { + setCheckedColumns((previous) => { + const next = new Set(previous); + if (next.has(column)) { + next.delete(column); + } else { + next.add(column); + } + return next; + }); + }, []); + + const hasActiveFilters = + query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0; + + // Match every record once, here, rather than in each row — rows just look themselves up by id. + const visibleIds = React.useMemo(() => { + if (!hasActiveFilters) { + return null; + } + + const ids = new Set(); + for (const record of records) { + if ( + recordMatches( + record.searchText, + record.selectValues, + record.checkboxValues, + query, + selectedOptions, + checkedColumns + ) + ) { + ids.add(record.id); + } + } + return ids; + }, [records, query, selectedOptions, checkedColumns, hasActiveFilters]); + + const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0; + + const value = React.useMemo( + () => ({ + query, + setQuery, + selectedOptions, + toggleOption, + checkedColumns, + toggleCheckbox, + visibleIds, + isEmpty, + }), + [query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty] + ); + + return ( + {props.children} + ); +} + +function useTableSearch(): TableSearchContextValue { + const context = React.useContext(TableSearchContext); + if (!context) { + throw new Error('useTableSearch must be used within a '); + } + return context; +} + +/** + * The search form rendered above a table, with a multi-select filter dropdown per select column. + */ +export function TableSearchInput(props: { + selectColumns?: TableSelectColumn[]; + checkboxColumns?: TableCheckboxColumn[]; + className?: ClassValue; +}) { + const { selectColumns = [], checkboxColumns = [] } = props; + const language = useLanguage(); + const { query, setQuery } = useTableSearch(); + const hasFilters = selectColumns.length > 0 || checkboxColumns.length > 0; + + return ( + event.stopPropagation()} + > + {selectColumns.map((column) => ( + + ))} + {checkboxColumns.map((column) => ( + + ))} +
+ ) : undefined + } + /> + ); +} + +/** + * Shown below the table when the active filters match no records. + */ +export function TableSearchEmpty(props: { className?: ClassValue }) { + const language = useLanguage(); + const { query, isEmpty } = useTableSearch(); + + if (!isEmpty) { + return null; + } + + const trimmed = query.trim(); + return ( +
+ {trimmed + ? tString(language, 'search_no_results_for', trimmed) + : tString(language, 'search_no_results')} +
+ ); +} + +/** + * A blank multi-select dropdown button for a single select column. Becomes `active` while any + * of its options are selected. + */ +function SelectFilterDropdown(props: { column: TableSelectColumn }) { + const { column } = props; + const language = useLanguage(); + const { selectedOptions, toggleOption } = useTableSearch(); + const selectedValues = selectedOptions[column.id]; + const activeCount = selectedValues?.size ?? 0; + // Fall back to a generic "Filter" label when the column title is hidden/empty. + const label = column.label.trim() || tString(language, 'search_scope_title'); + + return ( + 0} + label={activeCount > 1 ? `${label} · ${activeCount}` : label} + trailing={} + /> + } + > + {column.options.map((option) => { + const selected = selectedValues?.has(option.value) ?? false; + return ( + { + // Keep the menu open so several options can be toggled at once. + event.preventDefault(); + toggleOption(column.id, option.value); + }} + > + {option.label || option.value} + + ); + })} + + ); +} + +/** + * A checkbox control filtering the table to records where the given checkbox column is checked. + */ +function CheckboxFilter(props: { column: TableCheckboxColumn }) { + const { column } = props; + const language = useLanguage(); + const { checkedColumns, toggleCheckbox } = useTableSearch(); + const checked = checkedColumns.has(column.id); + const id = `table-search-checkbox-${column.id}`; + // Fall back to a generic "Filter" label when the column title is hidden/empty. + const label = column.label.trim() || tString(language, 'search_scope_title'); + + return ( + + ); +} + +type TableSearchRecordProps = Omit, 'className'> & { + /** Record id, matching the `id` of the data passed to ``. */ + recordId: string; + /** Display utility applied when the record matches (e.g. `flex` for rows, `contents` for cards). */ + visibleClassName: string; + className?: ClassValue; + children: React.ReactNode; +}; + +/** + * Wraps a single table record (a grid row or a card) and hides it when it doesn't match + * the current filters. Matching happens once in the provider; here we just look up the id. + */ +export function TableSearchRecord(props: TableSearchRecordProps) { + const { recordId, visibleClassName, className, children, ...rest } = props; + const { visibleIds } = useTableSearch(); + const matches = visibleIds === null || visibleIds.has(recordId); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx index 761078f98..f86eecd3e 100644 --- a/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/ViewCards.tsx @@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind'; import { RecordCard } from './RecordCard'; import type { TableViewProps } from './Table'; +import { TableSearchRecord } from './TableSearch'; export function ViewCards(props: TableViewProps) { const { block, view, records, style } = props; @@ -21,7 +22,15 @@ export function ViewCards(props: TableViewProps) { )} > {records.map((record) => { - return ; + return ( + + + + ); })}
); diff --git a/packages/gitbook/src/components/DocumentView/Table/search.ts b/packages/gitbook/src/components/DocumentView/Table/search.ts new file mode 100644 index 000000000..f8c9716e4 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/search.ts @@ -0,0 +1,146 @@ +import type { + DocumentBlockTable, + DocumentTableRecord, + DocumentTableSelectOption, +} from '@gitbook/api'; + +import { getNodeFragmentByName, getNodeText } from '@/lib/document'; + +export type TableRecordKV = [string, DocumentTableRecord]; + +export interface TableSelectColumn { + /** Column id (the column key in `block.data.definition`). */ + id: string; + /** Column title, shown on the filter button. */ + label: string; + /** Available options for the column. */ + options: DocumentTableSelectOption[]; +} + +/** + * List the visible "select" columns of a table along with their options. + * Used to render the per-column filter dropdowns next to the search input. + */ +export function getTableSelectColumns(block: DocumentBlockTable): TableSelectColumn[] { + return block.data.view.columns.flatMap((column) => { + const definition = block.data.definition[column]; + if (definition?.type !== 'select') { + return []; + } + + return [{ id: column, label: definition.title, options: definition.options }]; + }); +} + +export interface TableCheckboxColumn { + /** Column id. */ + id: string; + /** Column title, shown next to the checkbox. */ + label: string; +} + +/** + * List the visible "checkbox" columns of a table. + * Used to render a filter checkbox per column next to the search input. + */ +export function getTableCheckboxColumns(block: DocumentBlockTable): TableCheckboxColumn[] { + return block.data.view.columns.flatMap((column) => { + const definition = block.data.definition[column]; + if (definition?.type !== 'checkbox') { + return []; + } + + return [{ id: column, label: definition.title }]; + }); +} + +/** + * Build the search data for a record (searchable text + select/checkbox values) in a single + * pass over its columns. Used to feed the client-side table search (see `TableSearch`). + */ +export function getTableRecordSearchData(block: DocumentBlockTable, record: DocumentTableRecord) { + const searchText: string[] = []; + const selectValues: Record = {}; + const checkboxValues: Record = {}; + + for (const column of block.data.view.columns) { + const text = getTableCellSearchText(block, record, column); + if (text) { + searchText.push(text); + } + + const value = record.values[column]; + switch (block.data.definition[column]?.type) { + case 'select': + if (Array.isArray(value)) { + selectValues[column] = value.filter( + (item): item is string => typeof item === 'string' + ); + } + break; + case 'checkbox': + if (typeof value === 'boolean') { + checkboxValues[column] = value; + } + break; + } + } + + return { searchText: searchText.join(' '), selectValues, checkboxValues }; +} + +function getTableCellSearchText( + block: DocumentBlockTable, + record: DocumentTableRecord, + column: string +): string { + const definition = block.data.definition[column]; + const value = record.values[column]; + + if (!definition || value === null || value === undefined) { + return ''; + } + + switch (definition.type) { + case 'text': { + if (typeof value !== 'string') { + return ''; + } + + const fragment = getNodeFragmentByName(block, value); + return normalizeSearchText(fragment ? getNodeText(fragment) : ''); + } + case 'select': { + if (!Array.isArray(value)) { + return ''; + } + + return normalizeSearchText( + value + .map((selectId) => { + return ( + definition.options.find((option) => option.value === selectId)?.label ?? + selectId + ); + }) + .join(' ') + ); + } + case 'number': + case 'rating': { + return typeof value === 'number' ? `${value}` : ''; + } + case 'checkbox': { + return typeof value === 'boolean' ? `${value}` : ''; + } + // Reference-like columns (files, users, content-ref, image) render resolved names/text + // asynchronously in `RecordColumnValue`. We only have raw ids here, so indexing them would + // never match the visible text — leave them out rather than search opaque ids. + default: + return ''; + } +} + +function normalizeSearchText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} diff --git a/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts b/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts new file mode 100644 index 000000000..54ea6a432 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/searchMatch.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'bun:test'; + +import { type SelectedOptions, matchesText, recordMatches } from './searchMatch'; + +const NO_OPTIONS: SelectedOptions = {}; +const NO_CHECKBOXES: ReadonlySet = new Set(); + +function match( + record: { + searchText?: string; + selectValues?: Record; + checkboxValues?: Record; + }, + filters: { + query?: string; + selectedOptions?: SelectedOptions; + checkedColumns?: ReadonlySet; + } +): boolean { + return recordMatches( + record.searchText ?? '', + record.selectValues, + record.checkboxValues, + filters.query ?? '', + filters.selectedOptions ?? NO_OPTIONS, + filters.checkedColumns ?? NO_CHECKBOXES + ); +} + +describe('matchesText', () => { + it('matches everything when the query is empty', () => { + expect(matchesText('anything', '')).toBe(true); + expect(matchesText('', ' ')).toBe(true); + }); + + it('matches case-insensitively', () => { + expect(matchesText('Hello World', 'hello')).toBe(true); + expect(matchesText('Hello World', 'nope')).toBe(false); + }); + + it('matches regex metacharacters literally', () => { + expect(matchesText('value a+b here', 'a+b')).toBe(true); + expect(matchesText('v1x2', 'v1.2')).toBe(false); + expect(matchesText('a (b) c', '(b)')).toBe(true); + }); +}); + +describe('recordMatches', () => { + it('shows every record when no filter is active', () => { + expect(match({ searchText: 'whatever' }, {})).toBe(true); + }); + + it('applies the text filter', () => { + expect(match({ searchText: 'Ace AI' }, { query: 'ace' })).toBe(true); + expect(match({ searchText: 'Ace AI' }, { query: 'discrete' })).toBe(false); + }); + + describe('select columns', () => { + const selectedStatus: SelectedOptions = { status: new Set(['active', 'pending']) }; + + it('ORs multiple values within a single column', () => { + expect( + match( + { selectValues: { status: ['pending'] } }, + { selectedOptions: selectedStatus } + ) + ).toBe(true); + expect( + match( + { selectValues: { status: ['archived'] } }, + { selectedOptions: selectedStatus } + ) + ).toBe(false); + }); + + it('ANDs across different columns', () => { + const filters: SelectedOptions = { + status: new Set(['active']), + tier: new Set(['gold']), + }; + expect( + match( + { selectValues: { status: ['active'], tier: ['gold'] } }, + { selectedOptions: filters } + ) + ).toBe(true); + // Matches one column but not the other → excluded. + expect( + match( + { selectValues: { status: ['active'], tier: ['silver'] } }, + { selectedOptions: filters } + ) + ).toBe(false); + }); + + it('excludes records missing the column entirely', () => { + expect(match({ selectValues: {} }, { selectedOptions: selectedStatus })).toBe(false); + }); + }); + + describe('checkbox columns', () => { + const featured: ReadonlySet = new Set(['featured']); + + it('keeps only records checked for the enabled column', () => { + expect( + match({ checkboxValues: { featured: true } }, { checkedColumns: featured }) + ).toBe(true); + expect( + match({ checkboxValues: { featured: false } }, { checkedColumns: featured }) + ).toBe(false); + expect(match({ checkboxValues: {} }, { checkedColumns: featured })).toBe(false); + }); + + it('ANDs multiple enabled checkbox columns', () => { + const both: ReadonlySet = new Set(['featured', 'inStock']); + expect( + match( + { checkboxValues: { featured: true, inStock: true } }, + { checkedColumns: both } + ) + ).toBe(true); + expect( + match( + { checkboxValues: { featured: true, inStock: false } }, + { checkedColumns: both } + ) + ).toBe(false); + }); + }); + + it('ANDs the text, select and checkbox filters together', () => { + const record = { + searchText: 'Ace AI', + selectValues: { status: ['active'] }, + checkboxValues: { featured: true }, + }; + const filters = { + query: 'ace', + selectedOptions: { status: new Set(['active']) } satisfies SelectedOptions, + checkedColumns: new Set(['featured']), + }; + + expect(match(record, filters)).toBe(true); + // Each individual filter failing flips the result to false. + expect(match(record, { ...filters, query: 'discrete' })).toBe(false); + expect( + match(record, { ...filters, selectedOptions: { status: new Set(['archived']) } }) + ).toBe(false); + expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts b/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts new file mode 100644 index 000000000..57c22f957 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/searchMatch.ts @@ -0,0 +1,65 @@ +/** + * Pure matching logic for the client-side table search. + * + * Kept free of React/client dependencies so it can be unit-tested in isolation and shared + * between the search UI and (potentially) other callers. + */ + +/** Selected option values per select column, keyed by column id. */ +export type SelectedOptions = Readonly>>; + +/** + * Whether a record passes the current filters. + * + * The text query, each select column and each enabled checkbox column are combined with AND: + * a record must match the text (when present), satisfy every column that has a selection, and + * be checked for every enabled checkbox column. Within a single select column the selected + * values are combined with OR — the record matches the column if it has any of them. + */ +export function recordMatches( + searchText: string, + selectValues: Record | undefined, + checkboxValues: Record | undefined, + query: string, + selectedOptions: SelectedOptions, + checkedColumns: ReadonlySet +): boolean { + if (query.trim() !== '' && !matchesText(searchText, query)) { + return false; + } + + for (const [column, values] of Object.entries(selectedOptions)) { + if (values.size === 0) { + continue; + } + + const recordValues = selectValues?.[column]; + const matchesColumn = !!recordValues && recordValues.some((value) => values.has(value)); + if (!matchesColumn) { + return false; + } + } + + for (const column of checkedColumns) { + if (checkboxValues?.[column] !== true) { + return false; + } + } + + return true; +} + +/** + * Whether a record's searchable text contains the query. + * + * Plain case-insensitive substring match: the field is a free-text search, so regex + * metacharacters (`.`, `+`, `(`, …) are matched literally rather than treated as patterns. + */ +export function matchesText(searchText: string, query: string): boolean { + const trimmed = query.trim(); + if (!trimmed) { + return true; + } + + return searchText.toLowerCase().includes(trimmed.toLowerCase()); +} diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx index a705ee87e..545df7321 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx @@ -79,7 +79,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) { - + {!tabsRef.current ? ( diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index fb259da32..16dd4b240 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useAIChatState } from '@/components/AI'; +import { useAIChatController, useAIChatState } from '@/components/AI'; import type { Assistant } from '@/components/AI'; import { Button } from '@/components/primitives/Button'; import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu'; @@ -15,12 +15,28 @@ import { createStore, useStore } from 'zustand'; type PageActionType = 'button' | 'dropdown-menu-item'; +/** + * Context about the current page, attached to the assistant as a reference when opened. + */ +export type PageActionAssistantContext = { + id: string; + title: string; + path?: string; + /** Site-relative href of the page, used to navigate back to it from the chip. */ + href?: string; +}; + /** * Action to open the GitBook Assistant. */ -export function ActionOpenAssistant(props: { assistant: Assistant; type: PageActionType }) { - const { assistant, type } = props; +export function ActionOpenAssistant(props: { + assistant: Assistant; + type: PageActionType; + page?: PageActionAssistantContext; +}) { + const { assistant, type, page } = props; const chat = useAIChatState(); + const chatController = useAIChatController(); const language = useLanguage(); return ( @@ -30,8 +46,20 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct label={assistant.label} shortLabel={tString(language, 'ask')} description={tString(language, 'ai_chat_ask_about_page', assistant.label)} - disabled={chat.loading} + disabled={chat.responding} onClick={() => { + // Stage a reference to the current page so the assistant is informed about + // the context the user is asking from. Only the sidebar GitBook Assistant + // uses the chat reference system. + if (page && assistant.mode === 'sidebar') { + chatController.addReference({ + type: 'page', + id: page.id, + label: page.title, + path: page.path, + href: page.href, + }); + } assistant.open(); }} /> diff --git a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx index cf29c1495..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, @@ -18,8 +22,35 @@ import { ActionViewAsMarkdown, ActionViewAsPDF, ActionViewAsRSS, + 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; @@ -37,22 +68,95 @@ interface PageActionsDropdownProps { urls: PageActionsDropdownURLs; className?: string; actions: SiteCustomizationSettings['pageActions']; + /** The current page, referenced by the assistant when opened. */ + page: PageActionAssistantContext; } /** - * 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 } = 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/PageAside/ScrollSectionsList.tsx b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx index 8f4278a40..d69472e87 100644 --- a/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx +++ b/packages/gitbook/src/components/PageAside/ScrollSectionsList.tsx @@ -7,6 +7,7 @@ import type { DocumentSection } from '@/lib/document-sections'; import { tcls } from '@/lib/tailwind'; import { useBodyLoaded } from '@/components/primitives'; +import { OpenAPIMethodBadge } from '@gitbook/react-openapi'; import { HEADER_HEIGHT_DESKTOP } from '../layout'; /** @@ -141,11 +142,11 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] } )} > {section.tag ? ( - - {section.tag} - + ) : null} } > - +
+ +
) : ( diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 691caeb1f..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, @@ -56,6 +59,12 @@ export async function PageHeader(props: { siteTitle={context.site.title} urls={getPageActionsURLs({ context, page, withRSSFeed })} actions={context.customization.pageActions} + page={{ + id: page.id, + title: page.title, + path: page.path, + href: linker.toPathForPage({ pages: revision.pages, page }), + }} /> ) : null} diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 0e6911909..452eff295 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -69,6 +69,10 @@ export function SearchContainer({ }, { enableOnFormTags: true, + // Match the logical character typed, not the physical key position, so + // non-QWERTY layouts don't trigger the shortcut by position (e.g. on + // Dvorak the physical "K"/"I" keys produce other characters). RND-11340. + ignoreEventWhen: (e) => e.key.toLowerCase() !== 'k', } ); @@ -84,6 +88,9 @@ export function SearchContainer({ }, { enableOnFormTags: true, + // Match the logical character so Dvorak ⌘-C (physical "I" key) copies + // instead of opening the Assistant. RND-11340. + ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i', } ); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 198f3492c..1564b080f 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -14,7 +14,7 @@ import { GITBOOK_APP_URL } from '@/lib/env'; import { tcls } from '@/lib/tailwind'; import { AIChatProvider } from '../AI'; import type { RenderAIMessageOptions } from '../AI'; -import { AIChat } from '../AIChat'; +import { AIChat, AskAITextSelection } from '../AIChat'; import { AdaptiveVisitorContextProvider } from '../Adaptive'; import { Announcement } from '../Announcement'; import { OpenAPICodeSampleAIProvider } from '../DocumentView/OpenAPI/OpenAPICodeSampleAIProvider'; @@ -130,7 +130,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
- {customization.ai?.mode === CustomizationAIMode.Assistant ? : null} + {customization.ai?.mode === CustomizationAIMode.Assistant ? ( + <> + + + + ) : null} {/* Chat panel shifts content left when open */}
diff --git a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx index 6a9ef5b5f..a4103c627 100644 --- a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx @@ -2,6 +2,7 @@ import { tcls } from '@/lib/tailwind'; import { SiteInsightsLinkPosition } from '@gitbook/api'; +import { OpenAPIMethodBadge } from '@gitbook/react-openapi'; import { Tag } from '../Tag'; import { PagesList } from './PagesList'; import { TOCPageIcon } from './TOCPageIcon'; @@ -41,7 +42,18 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) { icon={} tag={page.primaryTag ? : null} > - {page.title} + {page.openAPIOperation ? ( + + + + ) : null} + + {page.title} + ); diff --git a/packages/gitbook/src/components/TableOfContents/encodeClientTableOfContents.ts b/packages/gitbook/src/components/TableOfContents/encodeClientTableOfContents.ts index df8667a68..db633204f 100644 --- a/packages/gitbook/src/components/TableOfContents/encodeClientTableOfContents.ts +++ b/packages/gitbook/src/components/TableOfContents/encodeClientTableOfContents.ts @@ -1,4 +1,5 @@ import type { GitBookSiteContext } from '@/lib/context'; +import { getOpenAPIOperationPageProps } from '@/lib/openapi/computedSourceProps'; import { getPagePaths, hasPageVisibleDescendant } from '@/lib/pages'; import { resolveContentRef } from '@/lib/references'; import { getRevisionTags, resolveTag } from '@/lib/tags'; @@ -26,6 +27,7 @@ export type ClientTOCPageDocument = { pathnames: string[]; descendants?: ClientTOCPage[]; primaryTag?: RevisionTag; + openAPIOperation?: { method: string; deprecated: boolean }; }; export type ClientTOCPageGroup = { @@ -83,6 +85,7 @@ export async function encodeClientTableOfContents( pathnames: getPagePaths(rootPages, page), descendants, primaryTag, + openAPIOperation: getOpenAPIOperationPageProps(page), type: 'document', }) ); diff --git a/packages/gitbook/src/components/TableOfContents/styles.ts b/packages/gitbook/src/components/TableOfContents/styles.ts index c61ab9645..001fdf5e9 100644 --- a/packages/gitbook/src/components/TableOfContents/styles.ts +++ b/packages/gitbook/src/components/TableOfContents/styles.ts @@ -3,7 +3,7 @@ export const ToCItemBaseStyles = [ 'circular-corners:rounded-2xl rounded-md straight-corners:rounded-none p-1.5 pl-3', 'focus-visible:-outline-offset-2', 'before:contents[] before:-left-px before:absolute before:inset-y-0', - 'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px', + 'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none has-[.openapi-method]:pl-3 [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px', ]; export const ToCLinkItemStyles = [ diff --git a/packages/gitbook/src/lib/env/globals.ts b/packages/gitbook/src/lib/env/globals.ts index 1cb3c7350..85b069cd9 100644 --- a/packages/gitbook/src/lib/env/globals.ts +++ b/packages/gitbook/src/lib/env/globals.ts @@ -84,6 +84,15 @@ export const GITBOOK_DISABLE_TRACKING = Boolean( export const GITBOOK_INTEGRATIONS_HOST = process.env.GITBOOK_INTEGRATIONS_HOST || 'integrations.gitbook.com'; +/** + * Hostname serving integration-rendered content (e.g. WebFrames), isolated from the + * cookie-bearing integrations origin to remediate stored-XSS on the main origin. + * Defaults to `GITBOOK_INTEGRATIONS_HOST` until a distinct content origin is configured, + * which keeps behavior unchanged. + */ +export const GITBOOK_INTEGRATIONS_CONTENT_HOST = + process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST || GITBOOK_INTEGRATIONS_HOST; + /** * Hostname for fonts. */ diff --git a/packages/gitbook/src/lib/openapi/computedSourceProps.ts b/packages/gitbook/src/lib/openapi/computedSourceProps.ts new file mode 100644 index 000000000..4c9b83086 --- /dev/null +++ b/packages/gitbook/src/lib/openapi/computedSourceProps.ts @@ -0,0 +1,28 @@ +import type { ComputedContentSourceDocumentOpenAPI, RevisionPageDocument } from '@gitbook/api'; + +/** + * Get the method and deprecated state of a page generated by the OpenAPI + * computed source in one-operation-per-page mode. + */ +export function getOpenAPIOperationPageProps( + page: RevisionPageDocument +): { method: string; deprecated: boolean } | undefined { + if (!('computed' in page)) { + return; + } + + const source = page.computed; + if (source.type !== 'builtin:openapi') { + return; + } + + const { props } = source as ComputedContentSourceDocumentOpenAPI; + if (props.doc !== 'operation') { + return; + } + + return { + method: props.method, + deprecated: props.deprecated === true, + }; +} diff --git a/packages/gitbook/src/lib/utils.ts b/packages/gitbook/src/lib/utils.ts index 3c23d3a0f..dcf202114 100644 --- a/packages/gitbook/src/lib/utils.ts +++ b/packages/gitbook/src/lib/utils.ts @@ -1,4 +1,5 @@ import * as api from '@gitbook/api'; +import { CustomizationPageActionType } from '@gitbook/api'; /** * Return the default customization settings for a site. @@ -78,6 +79,11 @@ export function defaultCustomization(): api.SiteCustomizationSettings { externalAI: true, markdown: true, mcp: true, + items: [ + CustomizationPageActionType.Markdown, + CustomizationPageActionType.ExternalAi, + CustomizationPageActionType.Mcp, + ], }, trademark: { enabled: true, diff --git a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx index 2b0390905..f47ee086e 100644 --- a/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx +++ b/packages/react-openapi/src/OpenAPICodeSampleSelector.tsx @@ -77,7 +77,13 @@ function OpenAPICodeSampleHeader(props: { return ( <> - + {allItems.length > 1 ? ( + {short ? formatOpenAPIMethod(normalized) : method} + + ); +} diff --git a/packages/react-openapi/src/OpenAPIPath.tsx b/packages/react-openapi/src/OpenAPIPath.tsx index 53c31c6c6..e0a20ef03 100644 --- a/packages/react-openapi/src/OpenAPIPath.tsx +++ b/packages/react-openapi/src/OpenAPIPath.tsx @@ -16,6 +16,11 @@ export type OpenAPIPathProps = { * @default true */ canCopy?: boolean; + /** + * The size of the HTTP method badge. + * @default 'medium' + */ + methodBadgeSize?: 'small' | 'medium'; }; /** diff --git a/packages/react-openapi/src/OpenAPIPathItem.tsx b/packages/react-openapi/src/OpenAPIPathItem.tsx index 9d500bc0a..d597a4582 100644 --- a/packages/react-openapi/src/OpenAPIPathItem.tsx +++ b/packages/react-openapi/src/OpenAPIPathItem.tsx @@ -1,4 +1,5 @@ import { OpenAPICopyButton } from './OpenAPICopyButton'; +import { OpenAPIMethodBadge } from './OpenAPIMethodBadge'; import type { OpenAPIPathProps } from './OpenAPIPath'; import type { OpenAPIClientContext } from './context'; @@ -10,14 +11,22 @@ export function OpenAPIPathItem( context: OpenAPIClientContext; } ) { - const { value, canCopy = true, context, children, data, copyType = 'children' } = props; + const { + value, + canCopy = true, + context, + children, + data, + copyType = 'children', + methodBadgeSize = 'medium', + } = props; const { operation, method } = data; const title = {children}; return (
-
{method}
+ {canCopy && value ? ( copyType === 'children' ? ( { it('should flatten oneOf', () => { @@ -681,6 +682,209 @@ describe('getSchemaAlternatives', () => { expect(result?.schemas[0]?.required).toContain('labelArgbColor'); }); }); + + describe('circular oneOf with discriminator and allOf', () => { + it('should handle variants that reference the parent via allOf', () => { + const pet: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'A pet in the store', + discriminator: { + propertyName: 'petType', + mapping: { + dog: '#/components/schemas/Dog', + cat: '#/components/schemas/Cat', + }, + }, + oneOf: [], + properties: { + name: { type: 'string' }, + petType: { type: 'string' }, + }, + required: ['petType'], + }; + + const dog: OpenAPIV3.SchemaObject = { + title: 'Dog', + allOf: [pet, { type: 'object', properties: { barkVolume: { type: 'number' } } }], + }; + + const cat: OpenAPIV3.SchemaObject = { + title: 'Cat', + allOf: [pet], + properties: { huntingSkill: { type: 'string' } }, + }; + + pet.oneOf = [dog, cat]; + + const result = getSchemaAlternatives(pet); + + expect(result?.type).toBe('oneOf'); + expect(result?.schemas).toHaveLength(2); + + const dogVariant = result?.schemas[0]; + expect(dogVariant?.title).toBe('Dog'); + expect(dogVariant?.properties).toHaveProperty('name'); + expect(dogVariant?.properties).toHaveProperty('petType'); + expect(dogVariant?.properties).toHaveProperty('barkVolume'); + expect(dogVariant).not.toHaveProperty('oneOf'); + expect(dogVariant).not.toHaveProperty('discriminator'); + expect(dogVariant).not.toHaveProperty('description'); + + const catVariant = result?.schemas[1]; + expect(catVariant?.title).toBe('Cat'); + expect(catVariant?.properties).toHaveProperty('name'); + expect(catVariant?.properties).toHaveProperty('petType'); + expect(catVariant?.properties).toHaveProperty('huntingSkill'); + + // Original schema must not be mutated + expect(Object.keys(pet.properties ?? {})).toHaveLength(2); + expect(pet.properties).not.toHaveProperty('barkVolume'); + expect(pet.properties).not.toHaveProperty('huntingSkill'); + }); + + it('should handle dereferenced copies (different object, shared property refs)', () => { + // After @scalar/openapi-parser dereference, $ref entries become new objects + // with shallow-copied properties from the original (not the same JS reference). + const pet: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'A pet in the store', + discriminator: { + propertyName: 'petType', + mapping: { + dog: '#/components/schemas/Dog', + cat: '#/components/schemas/Cat', + }, + }, + oneOf: [], + properties: { + name: { type: 'string' }, + petType: { type: 'string' }, + }, + required: ['petType'], + }; + + // Simulate dereference: $ref is replaced with a NEW object that has + // the same property values (shared references) as the original. + const petCopyForDog = { ...pet }; + const petCopyForCat = { ...pet }; + + const dog: OpenAPIV3.SchemaObject = { + title: 'Dog', + allOf: [ + petCopyForDog, + { type: 'object', properties: { barkVolume: { type: 'number' } } }, + ], + }; + + const cat: OpenAPIV3.SchemaObject = { + title: 'Cat', + allOf: [petCopyForCat], + properties: { huntingSkill: { type: 'string' } }, + }; + + pet.oneOf = [dog, cat]; + + const result = getSchemaAlternatives(pet); + + expect(result?.type).toBe('oneOf'); + expect(result?.schemas).toHaveLength(2); + + const dogVariant = result?.schemas[0]; + expect(dogVariant?.title).toBe('Dog'); + expect(dogVariant?.properties).toHaveProperty('name'); + expect(dogVariant?.properties).toHaveProperty('petType'); + expect(dogVariant?.properties).toHaveProperty('barkVolume'); + expect(dogVariant).not.toHaveProperty('oneOf'); + expect(dogVariant).not.toHaveProperty('discriminator'); + expect(dogVariant).not.toHaveProperty('description'); + + const catVariant = result?.schemas[1]; + expect(catVariant?.title).toBe('Cat'); + expect(catVariant?.properties).toHaveProperty('name'); + expect(catVariant?.properties).toHaveProperty('petType'); + expect(catVariant?.properties).toHaveProperty('huntingSkill'); + expect(catVariant).not.toHaveProperty('oneOf'); + expect(catVariant).not.toHaveProperty('discriminator'); + expect(catVariant).not.toHaveProperty('description'); + }); + }); + + describe('integration: parse + dereference + getSchemaAlternatives', () => { + it('should resolve polymorphic oneOf variants from a real spec', async () => { + const spec = JSON.stringify({ + openapi: '3.0.1', + info: { title: 'PetStore', version: '1.0' }, + paths: {}, + components: { + schemas: { + Pet: { + type: 'object', + description: 'A pet in the store', + discriminator: { + propertyName: 'petType', + mapping: { + dog: '#/components/schemas/Dog', + cat: '#/components/schemas/Cat', + }, + }, + oneOf: [ + { $ref: '#/components/schemas/Dog' }, + { $ref: '#/components/schemas/Cat' }, + ], + properties: { + name: { type: 'string' }, + petType: { type: 'string' }, + }, + required: ['petType'], + }, + Dog: { + allOf: [ + { $ref: '#/components/schemas/Pet' }, + { + type: 'object', + properties: { barkVolume: { type: 'number' } }, + }, + ], + }, + Cat: { + allOf: [{ $ref: '#/components/schemas/Pet' }], + properties: { huntingSkill: { type: 'string' } }, + }, + }, + }, + }); + + const { filesystem } = await parseOpenAPI({ + value: spec, + rootURL: 'memory://spec.json', + }); + const doc = await dereferenceFilesystem(filesystem); + const pet = doc.components?.schemas?.Pet as OpenAPIV3.SchemaObject; + + const result = getSchemaAlternatives(pet); + + expect(result?.type).toBe('oneOf'); + expect(result?.schemas).toHaveLength(2); + + const dogVariant = result?.schemas[0]; + expect(dogVariant?.title).toBe('Dog'); + expect(dogVariant?.properties).toHaveProperty('name'); + expect(dogVariant?.properties).toHaveProperty('petType'); + expect(dogVariant?.properties).toHaveProperty('barkVolume'); + expect(dogVariant).not.toHaveProperty('oneOf'); + expect(dogVariant).not.toHaveProperty('discriminator'); + expect(dogVariant).not.toHaveProperty('description'); + + const catVariant = result?.schemas[1]; + expect(catVariant?.title).toBe('Cat'); + expect(catVariant?.properties).toHaveProperty('name'); + expect(catVariant?.properties).toHaveProperty('petType'); + expect(catVariant?.properties).toHaveProperty('huntingSkill'); + expect(catVariant).not.toHaveProperty('oneOf'); + expect(catVariant).not.toHaveProperty('discriminator'); + expect(catVariant).not.toHaveProperty('description'); + }); + }); }); describe('getSchemaProperties', () => { diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index 0b6c4a83b..fee855a38 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -453,7 +453,10 @@ function OpenAPISchemaAlternativeSeparator(props: { /** * Render a circular reference to a schema. */ -function OpenAPISchemaCircularRef(props: { id: string; schema: OpenAPIV3.SchemaObject }) { +function OpenAPISchemaCircularRef(props: { + id: string; + schema: OpenAPIV3.SchemaObject; +}) { const { id, schema } = props; return ( @@ -1095,7 +1098,7 @@ function flattenSchema( return [{ ...schema, ...(required ? { required } : {}) }]; } - // if a schema has allOf that can be safely merged, merge it + // If a schema has allOf that can be safely merged, merge it. if ( (alternativeType === 'oneOf' || alternativeType === 'anyOf') && schema.allOf && @@ -1107,6 +1110,11 @@ function flattenSchema( ); if (allOfSchemas.length > 0) { + // Circular allOf: a variant references its parent (e.g. Dog allOf: [Pet, ...]) + if (allOfSchemas.some((s) => isAncestorOrCopy(s, ancestors))) { + return flattenCircularAllOf(schema, allOfSchemas, ancestors, latestAncestor); + } + const merged = mergeAlternatives('allOf', allOfSchemas); if (merged && merged.length > 0) { // Only merge if all schemas were successfully merged into one (safe to merge) @@ -1135,6 +1143,95 @@ function flattenSchema( ]; } +/** + * Flatten a circular allOf by stripping ancestor fields and merging the rest. + */ +function flattenCircularAllOf( + schema: OpenAPIV3.SchemaObject, + allOfSchemas: OpenAPIV3.SchemaObject[], + ancestors: Set, + latestAncestor: OpenAPIV3.SchemaObject | undefined +): OpenAPIV3.SchemaObject[] { + const cleanSchemas = allOfSchemas.map((s) => + isAncestorOrCopy(s, ancestors) ? stripAncestorFields(s, ancestors) : s + ); + + const { allOf: _, oneOf: _1, anyOf: _2, discriminator: _3, ...ownProps } = schema; + let merged = mergeSchemas(cleanSchemas); + merged = mergeTwoSchemas(merged, ownProps); + + const required = mergeRequiredFields(merged, latestAncestor); + return [ + { + ...merged, + ...(required ? { required } : {}), + ...(schema.title ? { title: schema.title } : {}), + }, + ]; +} + +/** + * Check if a schema is an ancestor or a structurally matching copy of one. + */ +function isAncestorOrCopy( + schema: OpenAPIV3.SchemaObject, + ancestors: Set +): boolean { + if (ancestors.has(schema)) { + return true; + } + const discriminatorName = schema.discriminator?.propertyName; + if (!discriminatorName) { + return false; + } + for (const ancestor of Array.from(ancestors)) { + if (ancestor.discriminator?.propertyName === discriminatorName) { + return true; + } + } + return false; +} + +const polymorphicFields = new Set([ + 'oneOf', + 'anyOf', + 'discriminator', + 'description', + 'x-gitbook-description-html', +]); + +/** + * Strip polymorphic fields from an ancestor schema, preserving non-circular allOf composition. + */ +function stripAncestorFields( + schema: OpenAPIV3.SchemaObject, + ancestors: Set +): OpenAPIV3.SchemaObject { + let base: OpenAPIV3.SchemaObject = schema; + + // Merge non-circular allOf entries so composition properties aren't lost + if (Array.isArray(schema.allOf)) { + const safeAllOf = schema.allOf.filter( + (s): s is OpenAPIV3.SchemaObject => + !checkIsReference(s) && !isAncestorOrCopy(s, ancestors) + ); + if (safeAllOf.length > 0) { + base = mergeSchemas([schema, ...safeAllOf]); + } + } + + const clean = Object.fromEntries( + Object.entries(base).filter(([key]) => !polymorphicFields.has(key) && key !== 'allOf') + ) as OpenAPIV3.SchemaObject; + if (clean.properties) { + clean.properties = { ...clean.properties }; + } + if (Array.isArray(clean.required)) { + clean.required = [...clean.required]; + } + return clean; +} + /** * Merge two schemas by combining their properties and required fields. * Later schema properties override earlier ones. @@ -1182,7 +1279,7 @@ function mergeSchemas(schemas: OpenAPIV3.SchemaObject[]): OpenAPIV3.SchemaObject return firstSchema; } // Start with first schema and merge the rest into it - return schemas.reduce((acc, schema) => mergeTwoSchemas(acc, schema), firstSchema); + return schemas.slice(1).reduce((acc, schema) => mergeTwoSchemas(acc, schema), firstSchema); } /** diff --git a/packages/react-openapi/src/common/OpenAPIColumnSpec.tsx b/packages/react-openapi/src/common/OpenAPIColumnSpec.tsx index 59eb8d539..b7eea0b83 100644 --- a/packages/react-openapi/src/common/OpenAPIColumnSpec.tsx +++ b/packages/react-openapi/src/common/OpenAPIColumnSpec.tsx @@ -24,7 +24,9 @@ export function OpenAPIColumnSpec(props: { ])}
) : null} - + {!context.headless ? ( + + ) : null}
); diff --git a/packages/react-openapi/src/common/OpenAPISummary.tsx b/packages/react-openapi/src/common/OpenAPISummary.tsx index 8c81ae2b4..78e8b289d 100644 --- a/packages/react-openapi/src/common/OpenAPISummary.tsx +++ b/packages/react-openapi/src/common/OpenAPISummary.tsx @@ -23,7 +23,10 @@ export function OpenAPISummary(props: { })(); return ( -
+
{(operation.deprecated || operation['x-stability']) && (
{operation.deprecated &&
Deprecated
} @@ -32,7 +35,7 @@ export function OpenAPISummary(props: { )}
)} - {title + {!context.headless && title ? context.renderHeading({ deprecated: operation.deprecated ?? false, stability: operation['x-stability'], diff --git a/packages/react-openapi/src/context.ts b/packages/react-openapi/src/context.ts index 700e9cd86..f3dfec1e9 100644 --- a/packages/react-openapi/src/context.ts +++ b/packages/react-openapi/src/context.ts @@ -85,6 +85,11 @@ export interface OpenAPIContext label: string; }) => React.ReactNode; + /** + * If `true`, the block omits its own title and description (the page renders them instead). + */ + headless?: boolean; + /** * Public specification URL, used by Scalar's "Test it" modal. * When null, the "Test it" button is hidden. diff --git a/packages/react-openapi/src/dereference.ts b/packages/react-openapi/src/dereference.ts index dc772f4cd..350206eeb 100644 --- a/packages/react-openapi/src/dereference.ts +++ b/packages/react-openapi/src/dereference.ts @@ -1,4 +1,9 @@ -import { type Filesystem, type OpenAPIV3xDocument, dereference } from '@gitbook/openapi-parser'; +import { + type Filesystem, + type OpenAPIV3, + type OpenAPIV3xDocument, + dereference, +} from '@gitbook/openapi-parser'; const dereferenceCache = new WeakMap>(); @@ -19,6 +24,10 @@ export function dereferenceFilesystem(filesystem: Filesystem): Promise { + // Set default titles BEFORE dereferencing so they propagate through $ref resolution. + // This is idempotent and only adds titles to schemas that don't already have one. + setDefaultSchemaTitles(filesystem); + const result = await dereference(filesystem); if (!result.schema) { @@ -27,3 +36,27 @@ async function baseDereferenceFilesystem(filesystem: Filesystem): Promise f.isEntrypoint); + const schemas = entrypoint?.specification?.components?.schemas as Record< + string, + OpenAPIV3.SchemaObject + >; + + if (!schemas || typeof schemas !== 'object') { + return; + } + + const entries = Object.entries(schemas); + + for (const [name, schema] of entries) { + if (schema && typeof schema === 'object' && !schema.$ref && !schema.title) { + schema.title = name; + } + } +} diff --git a/packages/react-openapi/src/formatOpenAPIMethod.ts b/packages/react-openapi/src/formatOpenAPIMethod.ts new file mode 100644 index 000000000..36b70055c --- /dev/null +++ b/packages/react-openapi/src/formatOpenAPIMethod.ts @@ -0,0 +1,14 @@ +/** + * Format an HTTP method for display in a method badge. + * Uppercasing is left to the `.openapi-method` CSS. + */ +export function formatOpenAPIMethod(method: string): string { + switch (method) { + case 'delete': + return 'DEL'; + case 'options': + return 'OPTS'; + default: + return method; + } +} diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index c12194f2b..914283f30 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -1,4 +1,6 @@ export * from './schemas'; +export * from './formatOpenAPIMethod'; +export * from './OpenAPIMethodBadge'; export * from './OpenAPIOperation'; export * from './OpenAPIWebhook'; export * from './OpenAPIOperationContext';