Compare commits

...

26 Commits

Author SHA1 Message Date
Nicolas Dorseuil b307b09acd Add urlLookup parameter to restrict URL lookup alternatives 2026-07-28 13:00:10 +02:00
Tomek fa9c9b38b7 Force software rendering in Playwright for consistent images (#4444)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-27 14:01:55 +00:00
conico974 b4d9c6b95e Fix BUILD_VERSION assignment to ensure fallback to current timestamp (#4445) 2026-07-27 15:47:54 +02:00
Tomek 588964279e Remove search value from URL on unfocus (#4443)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:46:02 +00:00
Nolann B. a80d41200a Run the OpenAPI Scalar proxy on GitBook's origin (#4440) 2026-07-24 18:36:27 +02:00
Nolann B. ccb9d7bda4 Submit sendFeedback MCP tool findings through submitSiteAgentFeedback (#4437) 2026-07-24 10:34:50 +02:00
Nolann B. 14562e9e5b Serve noindex on internal search/assistant URLs (?q= / ?ask=) (#4433) 2026-07-23 15:00:13 +02:00
spastorelli 580d186ddd Clean up old forward resume authorize to sites oauth server (#4436) 2026-07-23 14:08:56 +02:00
Tomek 038008c853 Fix heading anchor links on touch devices (#4435)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-23 11:56:21 +00:00
conico974 8c890522ed Patch next for dev, and fix issue in dev with inline icon (#4432) 2026-07-22 16:00:36 +02:00
conico974 e9558a6df7 Revert "Simplify search query handling to in-memory storage only (#4419)" (#4431) 2026-07-22 14:53:29 +02:00
Brett Jephson b5f3c1416a RND-11857: Prompt block with default expanded options (#4429) 2026-07-22 13:33:06 +01:00
conico974 b9d383bed3 Bump dependencies for @opennextjs/aws, @opennextjs/cloudflare, and next to latest versions (#4430) 2026-07-22 11:01:17 +02:00
spastorelli b6e7f2d2db Render the Site OAuth consent screen from the server's signed marker (#4428) 2026-07-22 09:59:30 +02:00
Tomek 94ef1769ec Simplify search query handling to in-memory storage only (#4419)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 07:54:50 +00:00
Zeno Kapitein ae9367dafe Only show the assistant activity heading when there's activity to reveal (#4424)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:47:58 +02:00
Zeno Kapitein cef18701be Add an assistant tool to submit page feedback (#4408)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:25:14 +02:00
Zeno Kapitein bf674a47d9 Add supporting context to AI tool confirmation dialogs (#4422)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:45:51 +02:00
conico974 1eb763f9c5 Implement API token cookie handling with chunking support (#4421) 2026-07-20 15:38:07 +02:00
Nolann B. db176ba0ea Add "On this page" on models page (#4417) 2026-07-20 13:17:02 +02:00
Nolann B. 03bbacf319 Add missing link reference to OpenAPI models (#4416) 2026-07-20 12:53:28 +02:00
Tomek 484cc11627 Fix ScrollContainer scroll buttons not reflecting overflow immediately (#4414)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-20 08:47:04 +00:00
spastorelli 0dee4155a2 Add consent flow env vars to next config (#4413) 2026-07-17 15:16:46 +00:00
spastorelli 4d7c01587e Add Sites OAuth consent screen (#4405) 2026-07-17 15:58:27 +02:00
Greg Bergé 6083a88845 Expose the current page context to integration block webframes (#4411)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:06:38 +02:00
Nolann B. bf6a7af72b Defer search index loading until search is opened (#4407) 2026-07-16 21:53:24 +02:00
141 changed files with 2870 additions and 572 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Force software rendering (SwiftShader) in Playwright Chromium to eliminate image downscaling drift between GPU-equipped local runs and headless CI runs in Argos screenshots.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add an assistant tool to submit feedback about the current page on behalf of the user.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Assistant: the "Explored briefly" activity heading no longer appears when there's nothing to show. It now renders only when the answer is preceded by a real preamble or one or more tool calls, so a simple answer with an empty reasoning step no longer surfaces an empty collapsible.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add configurable default visibility to Prompt block
+7
View File
@@ -0,0 +1,7 @@
---
"@gitbook/openapi-parser": patch
"@gitbook/react-openapi": patch
"gitbook": patch
---
Add missing link reference to OpenAPI models
+7
View File
@@ -0,0 +1,7 @@
---
"@gitbook/browser-types": patch
"@gitbook/embed": patch
"gitbook": patch
---
Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Submit `sendFeedback` MCP tool findings through the dedicated `submitSiteAgentFeedback` API endpoint. The `pageUrl` is now required and an optional `goal` can be provided.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve `X-Robots-Tag: noindex` on internal search/assistant URLs (`?q=` / `?ask=`) and stop disallowing them in robots.txt, so Google can crawl the directive and drop them from the index instead of reporting "Indexed, though blocked by robots.txt".
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add an "On this page" table of contents on OpenAPI models pages. Each model in a grouped/multi-model "Models" section is now listed as its own section, matching operations and webhooks.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve the OpenAPI "Test it" request proxy from GitBook's own domain.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results).
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep the last search query visible after closing search, and restore it when reopening, without breaking navigation when clicking a search result.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix heading anchor links being unreachable on touch devices by adding a tap-to-reveal state. The anchor icon now appears after the heading text without wrapping onto an orphan line while retaining its existing desktop placement. Use `pointerup` for the dismiss listener to fix unreliable dismissal on iOS Safari, and enlarge the anchor's touch tap target to a square 24px area (meeting the WCAG 2.5.8 minimum) so the icon stays centered instead of overflowing shorter headings' line height.
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-contentkit": patch
"gitbook": patch
---
Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims.
@@ -53,6 +53,7 @@ runs:
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
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
@@ -55,6 +55,7 @@ runs:
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
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
+18 -18
View File
@@ -127,8 +127,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@opennextjs/aws": "4.1.0",
"@opennextjs/cloudflare": "1.20.2",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -165,7 +165,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"next": "^16.2.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -346,8 +346,8 @@
},
},
"patchedDependencies": {
"next@16.2.11": "patches/next@16.2.11.patch",
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.2.6": "patches/next@16.2.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -360,7 +360,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.189.0",
"@gitbook/api": "0.191.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.189.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-QezuW8dMScSTJ+2IL/GGixkhGEsMRFRoO/W2r80pKRjoWyI9Ebai9oZsoN/pbRZz3cTnwa7hpRDtzaqHeeOAdQ=="],
"@gitbook/api": ["@gitbook/api@0.191.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Bizv/lGBeUqUCajS4AOu9uAtc9mDJBUh6BjEmgoCzolxVAa68rW/T4GugyiRl0JrAKIimLVmkYCPevPzXa6lLg=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -924,23 +924,23 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -976,9 +976,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.0.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.21 <16 || >=16.2.11" }, "bin": { "open-next": "dist/index.js" } }, "sha512-GuKkdUbnJhLvtwTiJlytNtWIcSYN3+Dzi+OusUlRgf+Ur7dGoXdzBopd+VFiSdTBduPOvr9lPFDg6BYNjEV3wQ=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.1", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.2", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.0", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.21 <16 || >=16.2.11", "rclone.js": "^0.6.6", "wrangler": "^4.86.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-iFBjABnaDk3be27F5EpxyMLMGPbVnnArFx5I3Y8Rf6BSx5nBV8h0UuJiMKrx3+whDU5ahIy4d8sfbvWvMiF1Kg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2866,7 +2866,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
"next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+2 -2
View File
@@ -43,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.189.0",
"@gitbook/api": "0.191.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -62,6 +62,6 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.6": "patches/next@16.2.6.patch"
"next@16.2.11": "patches/next@16.2.11.patch"
}
}
+18 -4
View File
@@ -5,14 +5,28 @@ export type GitBookIntegrationEvent = 'load' | 'unload';
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
export type GitBookIntegrationToolConfirmation = {
icon?: IconName;
label: string;
/**
* Supporting context displayed to the user above the confirmation dialog,
* to help them understand what they are approving or rejecting.
* Limited to 512 characters.
*/
context?: string;
};
export type GitBookIntegrationTool = AIToolDefinition & {
/**
* Confirmation action to be displayed to the user before executing the tool.
* Provide a static object, or a function that receives the input provided by
* the AI assistant and returns the confirmation — useful to display dynamic
* context based on the arguments the tool is about to be executed with.
*/
confirmation?: {
icon?: IconName;
label: string;
};
confirmation?:
| GitBookIntegrationToolConfirmation
| ((input: object) => GitBookIntegrationToolConfirmation);
/**
* Callback when the tool is executed.
+19 -4
View File
@@ -1,17 +1,32 @@
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
/**
* Confirmation action to be displayed to the user before executing a tool.
*/
export type GitBookToolConfirmation = {
icon?: IconName;
label: string;
/**
* Supporting context displayed to the user above the confirmation dialog,
* to help them understand what they are approving or rejecting.
* Limited to 512 characters.
*/
context?: string;
};
/**
* Custom tool definition to be passed to the AI assistant.
*/
export type GitBookToolDefinition = AIToolDefinition & {
/**
* Confirmation action to be displayed to the user before executing the tool.
* Provide a static object, or a function that receives the input provided by
* the AI assistant and returns the confirmation — useful to display dynamic
* context based on the arguments the tool is about to be executed with.
*/
confirmation?: {
icon?: IconName;
label: string;
};
confirmation?: GitBookToolConfirmation | ((input: object) => GitBookToolConfirmation);
/**
* Callback when the tool is executed.
-5
View File
@@ -282,11 +282,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://vimeo.com',
tests: [{ name: 'Home', url: '/legal' }],
},
{
name: 'help.platipomiru.com',
contentBaseURL: 'https://help.platipomiru.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'help.aikido.dev',
contentBaseURL: 'https://help.aikido.dev',
+28 -1
View File
@@ -1,5 +1,26 @@
// @ts-check
import { networkInterfaces } from 'node:os';
// Next blocks its dev client and HMR when a physical device opens the server over a LAN address.
// Needed to hydrate the dev client on physical phones/tablets over the internal network
const allowedDevOrigins =
process.env.NODE_ENV === 'development'
? [
...new Set(
Object.values(networkInterfaces())
.flat()
.filter(
(networkInterface) =>
networkInterface &&
!networkInterface.internal &&
networkInterface.family === 'IPv4'
)
.map((networkInterface) => networkInterface?.address)
),
]
: undefined;
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
let deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
@@ -21,6 +42,7 @@ if (VERCEL_TARGET_ENV === 'preview') {
* @type {import('next').NextConfig}
*/
const nextConfig = {
allowedDevOrigins,
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
@@ -40,12 +62,17 @@ const nextConfig = {
},
env: {
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (
process.env.GITBOOK_HEAD_SHA ||
process.env.GITHUB_SHA ||
Date.now().toString()
).slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SITE_OAUTH_SIGNING_SECRET: process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
@@ -3,6 +3,7 @@
"name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2026-04-02",
"minify": true,
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
+3 -3
View File
@@ -19,8 +19,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@opennextjs/aws": "4.1.0",
"@opennextjs/cloudflare": "1.20.2",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -57,7 +57,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"next": "^16.2.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
+6
View File
@@ -26,6 +26,12 @@ export default defineConfig({
'--disable-lcd-text',
// Disable font hinting so glyph rasterization is platform-independent.
'--font-render-hinting=none',
// Force software rendering everywhere so image compositing/downscaling
// always goes through the same filter, whether or not a GPU is present —
// a machine with a real GPU renders images more sharply than headless CI
// (no GPU, SwiftShader fallback), causing smooth-vs-pixelated diffs.
'--disable-gpu',
'--use-gl=swiftshader',
],
},
},
@@ -1,8 +1,4 @@
import {
CustomizationPageActionType,
SiteFindingType,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
@@ -350,11 +346,6 @@ export async function handleMcpRequest(
'sendFeedback',
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
{
category: z
.nativeEnum(SiteFindingType)
.describe(
'The kind of issue. "content-outdated": the content was correct at some point but no longer matches the current product or reality. "incoherence": the content contradicts itself or another page. "content-gap": information the reader needs is missing entirely, whether or not it was ever documented. "other": only as a last resort when none of the above fits.'
),
content: z
.string()
.min(1)
@@ -365,7 +356,7 @@ export async function handleMcpRequest(
pageUrl: z
.string()
.describe(
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started). Provide it whenever you can so the finding is linked to the exact page.`
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started), so the finding is linked to the exact page.`
)
.transform((value, ctx) => {
const candidate = URL.canParse(value)
@@ -386,8 +377,13 @@ export async function handleMcpRequest(
}
return candidate.toString();
})
.optional(),
}),
goal: z
.string()
.optional()
.describe(
'The broader end goal you were ultimately trying to accomplish (as/on behalf of the user) when you hit this issue. Gives the team the context you were working towards. Optional.'
),
},
{
title: 'Send feedback',
@@ -396,62 +392,64 @@ export async function handleMcpRequest(
idempotentHint: false,
openWorldHint: true,
},
async ({ category, content, pageUrl }) => {
async ({ content, pageUrl, goal }) => {
try {
let pageLocation:
| { page: string; space: string; revision: string }
| undefined;
if (pageUrl) {
const match = findSiteSpaceByUrl(context.structure, pageUrl);
if (!match) {
return {
content: [
{ type: 'text', text: `Page not found: "${pageUrl}"` },
],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
if (!resolved) {
return {
content: [
{ type: 'text', text: `Page not found: "${pageUrl}"` },
],
isError: true,
};
}
pageLocation = {
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
const match = findSiteSpaceByUrl(context.structure, pageUrl);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
isError: true,
};
}
trackMcpEvent({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'agent_feedback',
feedback: { content, category },
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
...pageLocation,
},
},
],
request,
});
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
isError: true,
};
}
const trimmedGoal = goal?.trim() || undefined;
//!! DISABLED FOR NOW: We'll add this back in when we have a way to track agent feedback.
// trackMcpEvent({
// organizationId: context.organizationId,
// siteId: site.id,
// events: [
// {
// type: 'agent_feedback',
// feedback: { content, category },
// location: {
// displayContext: SiteInsightsDisplayContext.Mcp,
// page: resolved.page.id,
// space: match.siteSpace.space.id,
// revision: match.siteSpace.space.revision,
// },
// },
// ],
// request,
// });
const apiClient = await dataFetcher.api();
await apiClient.orgs.submitSiteAgentFeedback(
context.organizationId,
site.id,
{
feedback: content,
url: pageUrl,
spaceId: match.siteSpace.space.id,
pageId: resolved.page.id,
...(trimmedGoal ? { goal: trimmedGoal } : {}),
}
);
return {
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
@@ -0,0 +1,20 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout/CustomizationRootLayout';
import { getThemeFromMiddleware } from '@/lib/middleware';
/**
* Layout for the sites OAuth consent screen.
*/
export default async function Layout({
params,
children,
}: React.PropsWithChildren<{ params: Promise<RouteLayoutParams> }>) {
const { context } = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout context={context} forcedTheme={forcedTheme}>
{children}
</CustomizationRootLayout>
);
}
@@ -0,0 +1,64 @@
import { cookies, headers } from 'next/headers';
import { notFound } from 'next/navigation';
import {
type RouteLayoutParams,
getDynamicSiteContext,
getSiteURLDataFromParams,
} from '@/app/utils';
import { ConsentError, ConsentScreen } from '@/components/SiteOAuthConsent';
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import { SiteOAuthConsentError, startSiteOAuthConsent } from '@/lib/site-oauth';
import { getVisitorToken } from '@/lib/visitors';
// The consent screen depends on the request (visitor, one-time interaction) and must never be cached.
export const dynamic = 'force-dynamic';
type PageParams = RouteLayoutParams & { siteId: string };
/**
* Render the sites OAuth consent screen for a post-login authorize resume.
*/
export default async function Page(props: {
params: Promise<PageParams>;
searchParams: Promise<{ gb_oauth_state?: string }>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const { siteId } = params;
// Only a post-login resume (carrying the interaction id) legitimately reaches this route.
if (!searchParams.gb_oauth_state) {
notFound();
}
const { context } = await getDynamicSiteContext(params);
const siteBasePath = withTrailingSlash(
withLeadingSlash(getSiteURLDataFromParams(params).siteBasePath)
);
const authorizeURL = new URL(
`${siteBasePath}~gitbook/oauth2/v1/${siteId}/authorize`,
context.linker.toAbsoluteURL('/')
);
const visitorToken = getVisitorToken({
cookies: (await cookies()).getAll(),
headers: await headers(),
url: authorizeURL,
});
const jwtToken = visitorToken?.token;
const interactionId = searchParams.gb_oauth_state;
if (!interactionId || !jwtToken) {
return <ConsentError />;
}
try {
const consent = await startSiteOAuthConsent({ siteId, interactionId, jwtToken });
return <ConsentScreen siteId={siteId} siteTitle={context.site.title} consent={consent} />;
} catch (error) {
if (error instanceof SiteOAuthConsentError) {
return <ConsentError />;
}
throw error;
}
}
@@ -1,32 +0,0 @@
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function POST(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PUT(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function HEAD(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function OPTIONS() {
return handleOpenAPIProxyOptions();
}
@@ -0,0 +1,13 @@
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
export const dynamic = 'force-dynamic';
export {
handleOpenAPIProxyRequest as GET,
handleOpenAPIProxyRequest as POST,
handleOpenAPIProxyRequest as PUT,
handleOpenAPIProxyRequest as DELETE,
handleOpenAPIProxyRequest as PATCH,
handleOpenAPIProxyRequest as HEAD,
handleOpenAPIProxyOptions as OPTIONS,
};
@@ -16,6 +16,13 @@ export const ConfirmControlDef = createAIControl({
description:
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the users click.',
inputSchema: z.object({
context: z
.string()
.max(512)
.optional()
.describe(
'Supporting context shown above the prompt to help the user understand what they are approving or rejecting.'
),
icon: z
.string()
.optional()
@@ -29,30 +36,37 @@ export const ConfirmControlDef = createAIControl({
});
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
const { label, icon, onSubmit } = props;
const { label, icon, context, onSubmit } = props;
const language = useLanguage();
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<Button
data-testid="ai-chat-tool-confirm-cancel"
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
truncate={false}
variant="blank"
icon="xmark"
label={tString(language, 'cancel')}
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
variant="primary"
truncate={false}
icon={icon}
label={label}
/>
{context ? (
<p className="whitespace-pre-line px-2 pt-1 text-sm text-tint">{context}</p>
) : null}
<div className="flex w-full flex-wrap gap-2">
<Button
data-testid="ai-chat-tool-confirm-cancel"
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
truncate={false}
variant="secondary"
icon="xmark"
label={tString(language, 'cancel')}
className="shrink-0 grow justify-center"
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
variant="primary"
truncate={false}
icon={icon}
label={label}
className="min-w-1/2 shrink-0 grow justify-center"
/>
</div>
</AIToolContainer>
);
}
@@ -11,6 +11,7 @@ import {
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
SiteInsightsDisplayContext,
} from '@gitbook/api';
import assertNever from 'assert-never';
import * as React from 'react';
@@ -24,6 +25,7 @@ import { type RenderAIMessageOptions, streamAIChatResponse } from './server-acti
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import { useSubmitPageFeedbackTool } from './useSubmitPageFeedbackTool';
export type AIChatMessage = {
role: AIMessageRole;
@@ -220,9 +222,11 @@ function notify(
*/
export function AIChatProvider(props: {
renderMessageOptions?: RenderAIMessageOptions;
/** Whether page feedback is enabled for the site (gates the submit-feedback tool). */
withPageFeedback?: boolean;
children: React.ReactNode;
}) {
const { renderMessageOptions, children } = props;
const { renderMessageOptions, withPageFeedback = false, children } = props;
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
@@ -230,9 +234,23 @@ export function AIChatProvider(props: {
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
// identity, so it can be referenced directly from the streaming callback.
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page
// feedback). Each tool has a stable identity, so it can be referenced directly from the
// streaming callback.
const navigateToPageTool = useNavigateToPageTool();
const submitPageFeedbackTool = useSubmitPageFeedbackTool({
displayContext: renderMessageOptions?.asEmbeddable
? SiteInsightsDisplayContext.Embed
: SiteInsightsDisplayContext.Site,
});
// Only expose the submit-feedback tool when the site has page feedback enabled, mirroring the
// "Was this helpful?" widget's visibility.
const builtInTools = React.useMemo(
() =>
withPageFeedback ? [navigateToPageTool, submitPageFeedbackTool] : [navigateToPageTool],
[navigateToPageTool, submitPageFeedbackTool, withPageFeedback]
);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
@@ -312,7 +330,7 @@ export function AIChatProvider(props: {
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools([navigateToPageTool]);
const tools = getTools(builtInTools);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
@@ -348,7 +366,7 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools([navigateToPageTool]);
const tools = getTools(builtInTools);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
@@ -434,6 +452,18 @@ export function AIChatProvider(props: {
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
// The confirmation can be a static object or a function that
// derives it from the AI-provided input (e.g. dynamic context).
// The function call is awaited because, for embed-registered
// tools, it arrives as an async proxy over the postMessage channel.
const resolvedConfirmation =
typeof confirmation === 'function'
? await confirmation(event.toolCall.input)
: confirmation;
const supportingContext =
typeof resolvedConfirmation.context === 'string'
? resolvedConfirmation.context.slice(0, 512)
: undefined;
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
@@ -442,8 +472,9 @@ export function AIChatProvider(props: {
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
icon: confirmation.icon,
label: resolvedConfirmation.label,
icon: resolvedConfirmation.icon,
context: supportingContext,
},
language,
send: async (result) => {
@@ -462,7 +493,7 @@ export function AIChatProvider(props: {
text: tString(
language,
'tool_call_skipped',
confirmation.label
resolvedConfirmation.label
),
},
},
@@ -555,7 +586,7 @@ export function AIChatProvider(props: {
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
builtInTools,
]
);
@@ -832,12 +863,22 @@ function updateAIChatMessageActivity(
return {
...activity,
currentPhase: event.phase,
hasCommentary:
activity.hasCommentary || event.phase === AIMessageStepPhase.Commentary,
hasFinalAnswer:
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
};
}
case 'response_document': {
// A commentary phase can start without ever producing anything visible. Only a
// commentary step that emits document content is a real preamble worth collapsing
// behind the activity heading, so flag it here rather than on phase start.
return {
...activity,
hasCommentary:
activity.hasCommentary ||
(activity.currentPhase === AIMessageStepPhase.Commentary &&
event.blocks.length > 0),
};
}
case 'response_tool_call': {
return {
...activity,
@@ -0,0 +1,147 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
type AIToolDefinition,
PageFeedbackRating,
type SiteInsightsDisplayContext,
} from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { type InsightsEventPageContext, useTrackEvent } from '../Insights';
import { type PagePointer, useCurrentPage } from '../hooks';
// Matches the comment cap the "Was this helpful?" widget enforces (PageFeedbackForm).
const MAX_COMMENT_LENGTH = 512;
const SubmitPageFeedbackInputSchema = z.object({
rating: z.enum(['good', 'ok', 'bad']).describe(
`The user's overall sentiment about the current page:
- 'good' if it was helpful. Only use it when the user actively expresses positive sentiment.
- 'ok' if it was fine but could be improved. Default to 'ok' for neutral feedback (typos, incoherent or missing content, etc).
- 'bad' if it was unhelpful. Only use it when the user actively expresses negative sentiment.
Infer it from the conversation; if it is unclear, ask the user before submitting.`
),
comment: z
.string()
.max(MAX_COMMENT_LENGTH)
.optional()
.describe(
`The user's feedback about the page, in their own words (e.g. what was confusing or missing). Optional — omit it when the user only expressed a rating.`
),
});
const ratingByInput: Record<
z.infer<typeof SubmitPageFeedbackInputSchema>['rating'],
PageFeedbackRating
> = {
good: PageFeedbackRating.Good,
ok: PageFeedbackRating.Ok,
bad: PageFeedbackRating.Bad,
};
/**
* Build the built-in `submitPageFeedback` tool exposed to the assistant.
*
* The tool records the user's feedback about the page they are currently viewing, reusing the same
* insights pipeline as the "Was this helpful?" widget (a `page_post_feedback` event, plus a
* `page_post_feedback_comment` event when the user left a comment). Because it acts on the user's
* behalf, it asks for confirmation before submitting.
*
* The events are tracked with an explicit page context so they attribute to the current page even
* on pathnames whose ambient insights context has no page (e.g. the embed's assistant tab).
*/
export function useSubmitPageFeedbackTool(options: {
/** Display context recorded with the feedback events (e.g. `site` vs. `embed`). */
displayContext: SiteInsightsDisplayContext;
}): GitBookIntegrationTool {
const { displayContext } = options;
const trackEvent = useTrackEvent();
const language = useLanguage();
const currentPage = useCurrentPage();
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef<{
trackEvent: typeof trackEvent;
language: typeof language;
currentPage: PagePointer | null;
displayContext: SiteInsightsDisplayContext;
}>({ trackEvent, language, currentPage, displayContext });
React.useEffect(() => {
ref.current = { trackEvent, language, currentPage, displayContext };
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'submitPageFeedback',
description:
"Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words.",
confirmation: (input) => {
const parsed = SubmitPageFeedbackInputSchema.safeParse(input);
const comment = parsed.success ? parsed.data.comment?.trim() : undefined;
return {
icon: 'paper-plane',
label: tString(language, 'ai_chat_tools_submit_feedback', ''),
context: comment ? `"${comment}"` : undefined,
};
},
inputSchema: zodToJsonSchema(
SubmitPageFeedbackInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const { trackEvent, language, currentPage, displayContext } = ref.current;
const { rating, comment } = SubmitPageFeedbackInputSchema.parse(input);
if (!currentPage) {
throw new Error(
'No documentation page is currently open to submit feedback for.'
);
}
const pageFeedbackRating = ratingByInput[rating];
const trimmedComment = comment?.trim() || undefined;
const pageContext: InsightsEventPageContext = {
pageId: currentPage.pageId,
displayContext,
};
trackEvent(
{ type: 'page_post_feedback', feedback: { rating: pageFeedbackRating } },
pageContext,
{ immediate: !trimmedComment }
);
if (trimmedComment) {
trackEvent(
{
type: 'page_post_feedback_comment',
feedback: { rating: pageFeedbackRating, comment: trimmedComment },
},
pageContext,
{ immediate: true }
);
}
return {
output: {
submitted: true,
rating,
comment: trimmedComment ?? null,
pageId: currentPage.pageId,
},
summary: {
icon: 'comment-check',
text: `${tString(language, 'ai_chat_tools_submitted_feedback')}${trimmedComment ? `: "${trimmedComment}"` : ''}`,
},
};
},
}),
// Rebuild when the locale changes so the confirmation label (read at memo time, not from
// the ref) stays translated.
[language]
);
}
@@ -21,6 +21,7 @@ export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'st
inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
themes?: CustomizationThemedCodeTheme;
embedded?: boolean;
};
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
@@ -30,7 +31,7 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables, themes } = props;
const { block, mode, style, inlines, inlineExprVariables, themes, embedded } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
@@ -142,6 +143,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
theme={theme ?? plainTheme}
id={codeBlockBodyId}
isPrint={mode === 'print'}
embedded={embedded}
/>
);
@@ -22,6 +22,7 @@ export async function CodeBlock(
props: BlockProps<DocumentBlockCode> & {
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
embedded?: boolean;
}
) {
const {
@@ -32,6 +33,7 @@ export async function CodeBlock(
context,
themeKey = 'default',
themes: providedThemes,
embedded,
} = props;
const inlines = getInlines(block);
const isMermaid = block.data.syntax?.toLowerCase() === 'mermaid';
@@ -87,6 +89,7 @@ export async function CodeBlock(
style={style}
theme={theme}
isPrint={context.mode === 'print'}
embedded={embedded}
/>
);
}
@@ -108,6 +111,7 @@ export async function CodeBlock(
inlineExprVariables: variables,
mode: context.mode,
themes,
embedded,
};
return (
@@ -19,6 +19,7 @@ type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'sty
* When true, the "Ask AI" button is not rendered.
*/
isPrint?: boolean;
embedded?: boolean;
};
/**
@@ -28,11 +29,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
const { block, style, theme, 'aria-busy': ariaBusy, isPrint, embedded } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const withTitle = Boolean(title && !embedded);
const isSingleLine = theme.lines.length <= 1 && !withWrap;
const id = useId();
@@ -42,6 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
So we parse the foreground & background into objects that can be passed into React's `style` prop. */
const bg = parseShikiColorString(theme.bg);
const fg = parseShikiColorString(theme.fg);
const codeBackground = embedded ? undefined : bg;
return (
<div
@@ -50,8 +53,10 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
aria-busy={ariaBusy}
className={tcls(
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
!embedded &&
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
!embedded &&
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
style
)}
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
@@ -65,7 +70,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
className="pointer-events-none absolute size-0 outline-none"
/>
<div className="flex items-center justify-start gap-2 text-sm">
{title ? (
{withTitle ? (
<div
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
style={{
@@ -80,31 +85,37 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null}
</div>
<div className="relative flex min-h-0 flex-col">
<div
className={tcls(
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
isSingleLine ? 'inset-y-0' : 'top-2'
)}
>
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
title={title}
syntax={block.data.syntax}
style="backdrop-blur-md"
/>
) : null}
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
</div>
{!embedded ? (
<div
className={tcls(
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
isSingleLine ? 'inset-y-0' : 'top-2'
)}
>
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
title={title}
syntax={block.data.syntax}
style="backdrop-blur-md"
/>
) : null}
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
</div>
) : null}
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2.5 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
'relative overflow-auto py-2.5 text-tint-strong print:overflow-visible',
embedded
? 'bg-tint-base'
: 'border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base contrast-more:border-tint contrast-more:bg-tint-base',
!embedded &&
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
withTitle && 'rounded-ss-none!'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
backgroundColor: codeBackground?.color,
...codeBackground?.vars,
color: fg?.color,
...fg?.vars,
}}
@@ -118,7 +129,8 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
>
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
bg={codeBackground}
embedded={embedded}
fg={fg}
key={index}
line={line}
@@ -136,11 +148,13 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
function CodeHighlightLine(props: {
line: HighlightLine;
bg?: ShikiColorDefinition;
embedded?: boolean;
fg?: ShikiColorDefinition;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers, bg, fg } = props;
const { line, isLast, withLineNumbers, bg, embedded, fg } = props;
const embeddedPlainLine = Boolean(embedded && !line.diff && !line.highlighted);
const lineStyle = {
color: fg?.color,
...fg?.vars,
@@ -151,6 +165,7 @@ function CodeHighlightLine(props: {
<span
className={tcls(
'highlight-line',
embeddedPlainLine && 'bg-transparent! hover:bg-tint-subtle!',
line.diff === 'added' && 'diff-added',
line.diff === 'deleted' && 'diff-deleted',
line.highlighted && 'highlighted'
@@ -19,29 +19,38 @@ export function HashLinkButton(props: {
}) {
const { id, block, className, iconClassName, label } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
<span
className={tcls(
'relative',
'hash',
'grid',
'inline-grid',
'grid-area-1-1',
'h-[1em]',
'pointer-fine:h-[1em]',
'border-0',
'opacity-0',
'pointer-events-none',
'site-background',
'rounded',
'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]',
'md:group-hover/hash:opacity-[1]',
'md:group-focus/hash:opacity-[1]',
'transition-opacity',
'pointer-fine:pointer-events-auto',
'pointer-fine:group-hover/hash:opacity-100',
'pointer-fine:group-focus-within/hash:opacity-100',
'[.hash-revealed_&]:opacity-100',
'[.hash-revealed_&]:pointer-events-auto',
className
)}
>
<Link
href={`#${id}`}
aria-label={label}
className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
className={tcls(
'inline-flex items-center',
'p-1',
'pointer-fine:h-full pointer-fine:p-0',
textStyle.lineHeight
)}
>
<Icon
icon="hashtag"
@@ -50,12 +59,16 @@ export function HashLinkButton(props: {
'self-center',
'transition-colors',
'text-transparent',
'group-hover/hash:text-tint-subtle',
'contrast-more:group-hover/hash:text-tint-strong',
'pointer-fine:group-hover/hash:text-tint-subtle',
'contrast-more:pointer-fine:group-hover/hash:text-tint-strong',
'pointer-fine:group-focus-within/hash:text-tint-subtle',
'contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong',
'[.hash-revealed_&]:text-tint-subtle',
'contrast-more:[.hash-revealed_&]:text-tint-strong',
iconClassName
)}
/>
</Link>
</div>
</span>
);
}
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { HeadingRevealWrapper } from './HeadingRevealWrapper';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
import { getTextAlignment } from './utils';
@@ -25,36 +26,28 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
: defaultLanguage;
return (
<Tag
<HeadingRevealWrapper
as={Tag}
id={id}
className={tcls(
textStyle.textSize,
'heading',
'pdf-heading',
'flex',
'items-baseline',
'block',
'pr-6',
'pointer-fine:flex',
'pointer-fine:items-baseline',
'pointer-fine:pr-0',
'scroll-mt-(--content-scroll-margin)',
getTextAlignment(block.data.align),
hashLinkButtonWrapperStyles,
style,
textStyle.marginTop
)}
data-pdf-heading
>
<HashLinkButton
id={id}
block={block}
<span
className={tcls(
'-ml-6 self-center pr-2',
'[.flip-heading-hash_&]:order-last [.flip-heading-hash_&]:ml-1 [.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
<div
className={tcls(
'flex-1',
'pointer-fine:flex-1',
'z-1',
'justify-self-start',
'max-w-full',
@@ -67,8 +60,23 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
)}
>
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
</div>
</Tag>
</span>
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
</HeadingRevealWrapper>
);
}
@@ -0,0 +1,98 @@
'use client';
import { useSyncExternalStore } from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
const HEADING_ATTR = 'data-heading-reveal-wrapper';
/**
* Shared store tracking which single heading (by id) currently has its anchor
* icon revealed, so tapping one heading hides any other that was revealed.
*/
let revealedId: string | null = null;
const listeners = new Set<() => void>();
function handleDocumentPointerUp(event: PointerEvent) {
const target = event.target;
if (!(target instanceof Element) || !target.closest(`[${HEADING_ATTR}]`)) {
setRevealedId(null);
}
}
function subscribe(onStoreChange: () => void) {
listeners.add(onStoreChange);
if (listeners.size === 1) {
document.addEventListener('pointerup', handleDocumentPointerUp);
}
return () => {
listeners.delete(onStoreChange);
if (listeners.size === 0) {
document.removeEventListener('pointerup', handleDocumentPointerUp);
}
};
}
function getSnapshot() {
return revealedId;
}
function getServerSnapshot() {
return null;
}
function setRevealedId(id: string | null) {
if (revealedId === id) {
return;
}
revealedId = id;
for (const listener of listeners) {
listener();
}
}
/**
* Wraps a heading tag with tap-to-reveal state for its anchor-link icon on
* touch devices, where hover isn't available to reveal it.
* Only one heading can be revealed at a time, and tapping outside of any
* heading clears it.
*/
export function HeadingRevealWrapper(props: {
as: React.ElementType;
id: string;
className?: ClassValue;
children: React.ReactNode;
}) {
const { as: Tag, id, className, children, ...rest } = props;
const currentRevealedId = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const revealed = currentRevealedId === id;
const handlePointerUp = (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType === 'mouse' && window.matchMedia('(hover: hover)').matches) {
return;
}
const target = event.target;
if (
target instanceof Element &&
target.closest('a, button, input, select, textarea, [role="button"]')
) {
return;
}
setRevealedId(revealed ? null : id);
};
return (
<Tag
id={id}
className={tcls(className, revealed && 'hash-revealed')}
onPointerUp={handlePointerUp}
{...{ [HEADING_ATTR]: true }}
{...rest}
>
{children}
</Tag>
);
}
@@ -6,6 +6,7 @@ import { type GitBookLinker, createLinker } from '@/lib/links';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import { useRouter } from 'next/navigation';
import React from 'react';
import type { WebframePageContext } from './adaptive';
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
@@ -17,18 +18,20 @@ export type WebframeLinkerData = Pick<
/**
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
* access them).
* the current page, navigation to other pages, and adaptive visitor claims (only when the
* integration is allowed to access them).
*/
export function ContentKitWithClientContext<RenderContext>(
props: ContentKitProps<RenderContext> & {
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */
canAccessVisitorClaims: boolean;
/** Current page to inject into the webframe, or `null` when unknown. */
page: WebframePageContext | null;
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */
linkerData: WebframeLinkerData;
}
) {
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props;
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
@@ -56,6 +59,7 @@ export function ContentKitWithClientContext<RenderContext>(
getVisitorContext: canAccessVisitorClaims
? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined,
getPageContext: page ? () => ({ page }) : undefined,
navigate: ({ path, anchor }) => {
// Resolve the requested path relative to the site root so a webframe can navigate
// to any section or space within the site (and nowhere outside it).
@@ -63,7 +67,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + suffix);
},
}),
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -10,7 +10,7 @@ import {
ContentKitWithClientContext,
type WebframeLinkerData,
} from './ContentKitWithClientContext';
import { integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
@@ -77,8 +77,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
// visitor claims when the integration is allowed them.
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
const page = getWebframePageContext(context.contentContext);
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
// exposes the current page, plus visitor claims when the integration is allowed them.
const useClientContext = containsWebframe;
const contentKitProps = {
@@ -106,6 +109,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import { integrationBlockContainsWebframe } from './adaptive';
import type { GitBookAnyContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
const webframe: ContentKitWebFrame = {
type: 'webframe',
@@ -38,3 +40,47 @@ describe('integrationBlockContainsWebframe', () => {
expect(integrationBlockContainsWebframe(output)).toBe(true);
});
});
describe('getWebframePageContext', () => {
it('returns null when the context has no page', () => {
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
expect(getWebframePageContext(context)).toBeNull();
});
it('resolves the page path relative to the site root, including the section slug', () => {
const context = {
page: {
id: 'page-1',
path: 'guides/getting-started',
title: 'Getting started',
slug: 'getting-started',
},
// Site served at /docs, with the page's space mounted under the `api` section.
linker: createLinker({ siteBasePath: '/docs/', spaceBasePath: '/docs/api/' }),
} as unknown as GitBookAnyContext;
expect(getWebframePageContext(context)).toEqual({
id: 'page-1',
path: 'api/guides/getting-started',
title: 'Getting started',
});
});
it('leaves the path unprefixed when the space is served at the site root', () => {
const context = {
page: {
id: 'page-2',
path: 'guides/getting-started',
title: 'Getting started',
slug: 'getting-started',
},
linker: createLinker({ siteBasePath: '/', spaceBasePath: '/' }),
} as unknown as GitBookAnyContext;
expect(getWebframePageContext(context)).toEqual({
id: 'page-2',
path: 'guides/getting-started',
title: 'Getting started',
});
});
});
@@ -1,3 +1,4 @@
import type { GitBookAnyContext } from '@/lib/context';
import type {
ContentKitDescendantElement,
ContentKitRenderOutput,
@@ -7,9 +8,19 @@ import type {
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Current page exposed to a webframe through the client-only webframe state.
*/
export type WebframePageContext = {
id: string;
/** Path of the page relative to the site root (includes the section and variant). */
path: string;
title: string;
};
/**
* Whether an integration block's output contains a webframe that can consume client-only context
* (navigation and/or visitor claims).
* (navigation, visitor claims and/or the current page).
*/
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
@@ -19,6 +30,31 @@ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput)
return doesContentKitElementContainWebframe(output.element);
}
/**
* Extract the current page to expose to a webframe, or `null` when it is unknown
* (e.g. a non-page context, or reusable content resolved from another source).
*
* The exposed `path` is resolved relative to the site root — so it carries the section and
* variant, unlike the space-relative `page.path` — matching how `@webframe.navigate` resolves a
* path. A webframe can pass `page.path` straight back to the navigate action.
*/
export function getWebframePageContext(
contentContext: GitBookAnyContext
): WebframePageContext | null {
if (!('page' in contentContext) || !contentContext.page) {
return null;
}
const { linker } = contentContext;
const { id, path, title } = contentContext.page;
return {
id,
path: linker.toRelativePathInSite(linker.toPathInSpace(path)),
title,
};
}
/**
* Check whether a ContentKit element tree contains a webframe element.
*/
@@ -10,6 +10,7 @@ import { Heading } from '../Heading';
import './style.css';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import type { GitBookAnyContext } from '@/lib/context';
import { GITBOOK_URL } from '@/lib/env';
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
import type {
AnyOpenAPIOperationsBlock,
@@ -17,6 +18,12 @@ import type {
OpenAPIWebhookBlock,
} from '@/lib/openapi/types';
// Serve the proxy from GitBook's own origin rather than the customer domain, so a proxied
// response can never execute as HTML under a customer's trusted origin.
const OPEN_ORIGIN_PROXY_URL = GITBOOK_URL
? new URL('/~scalar/proxy', GITBOOK_URL).toString()
: null;
/**
* Get the OpenAPI context to render a block.
*/
@@ -34,16 +41,22 @@ export function getOpenAPIContext(args: {
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
const siteId = context && 'site' in context ? context.site.id : undefined;
// Fall back to the site's own host root when GITBOOK_URL is unset (self-hosted single-origin);
// the proxy route is only ever mounted at the host root, never under the site base path.
const proxyUrl =
context && props.context.mode !== 'print'
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
context && siteId && props.context.mode !== 'print'
? (OPEN_ORIGIN_PROXY_URL ?? context.linker.toAbsoluteURL('/~scalar/proxy'))
: undefined;
return {
specUrl,
resolveProxyUrl: proxyUrl
? (allowedOrigins: string[]) => buildSignedProxyUrl(proxyUrl, allowedOrigins)
: undefined,
resolveProxyUrl:
proxyUrl && siteId
? (allowedOrigins: string[]) =>
buildSignedProxyUrl(proxyUrl, allowedOrigins, siteId)
: undefined,
icons: {
chevronDown: <Icon icon="chevron-down" />,
chevronRight: <Icon icon="chevron-right" />,
@@ -52,6 +65,7 @@ export function getOpenAPIContext(args: {
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
mcp: <Icon icon="mcp" />,
hashtag: <Icon icon="hashtag" />,
},
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
@@ -865,6 +865,30 @@ body:has(.openapi-select-popover) {
@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;
}
/* Model name + its hover hash-link, inline. Carries the anchor id / deep-link scroll target. */
.openapi-schemas-model-title {
@apply flex items-center gap-2 min-w-0;
/* Offset by the trigger's p-5 so a deep-link lands the model's top edge, not its title, below the header. */
scroll-margin-top: calc(var(--content-scroll-margin) + 1.25rem);
}
.openapi-schemas-model-title-name {
@apply min-w-0 truncate;
}
.openapi-schemas-anchor-link {
@apply shrink-0 flex items-center opacity-0 transition-opacity;
}
.openapi-schemas-disclosure:hover .openapi-schemas-anchor-link,
.openapi-schemas-anchor-link:focus-visible {
@apply opacity-100;
}
.openapi-schemas-anchor-link svg {
@apply size-3 text-tint-subtle hover:text-tint-strong;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
}
@@ -965,7 +989,7 @@ body:has(.openapi-select-popover) {
}
}
.openapi-disclosure-trigger[aria-expanded="true"] svg {
.openapi-disclosure-trigger[aria-expanded="true"] .openapi-disclosure-trigger-label svg {
@apply rotate-45;
}
@@ -1,25 +1,46 @@
import {
CustomizationPageActionType,
type DocumentBlockCode,
type DocumentBlockPrompt,
type SiteCustomizationSettings,
} from '@gitbook/api';
import { validateIconName } from '@gitbook/icons/icons';
import type { BlockProps } from '../Block';
import { CodeBlock } from '../CodeBlock';
import { getPlainCodeBlock } from '../CodeBlock/highlight';
import { PromptClient } from './PromptClient';
const PROMPT_COLLAPSED_LINE_COUNT = 10;
export function Prompt(props: BlockProps<DocumentBlockPrompt>) {
const { block } = props;
const { block, document, ancestorBlocks, context, isEstimatedOffscreen } = props;
const contentIcon =
block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null;
const prompt = getPromptText(block);
const defaultExpanded = block.data.defaultExpanded ?? 'hidden';
const promptContent = block.nodes.map((node, index) => (
<CodeBlock
key={node.key ?? index}
block={getPromptCodeBlock(node, { defaultExpanded })}
document={document}
ancestorBlocks={[...ancestorBlocks, block]}
context={context}
isEstimatedOffscreen={isEstimatedOffscreen}
embedded
/>
));
return (
<PromptClient
{...block.data}
contentIcon={contentIcon}
description={block.data.description}
prompt={getPromptText(block)}
defaultExpanded={defaultExpanded}
openInAIProviders={getOpenInAIProviders(props)}
/>
prompt={prompt}
>
{promptContent}
</PromptClient>
);
}
@@ -49,3 +70,22 @@ function isExternalAIPageActionEnabled(
function getPromptText(block: DocumentBlockPrompt): string {
return (block.nodes ?? []).map((node) => getPlainCodeBlock(node)).join('\n');
}
function getPromptCodeBlock(
block: DocumentBlockCode,
options: { defaultExpanded: 'hidden' | 'partial' | 'full' }
): DocumentBlockCode {
const isPartiallyExpanded = options.defaultExpanded === 'partial';
return {
...block,
data: {
...block.data,
syntax: block.data.syntax ?? 'markdown',
expandable: isPartiallyExpanded,
collapsedLineCount: isPartiallyExpanded ? PROMPT_COLLAPSED_LINE_COUNT : undefined,
lineNumbers: false,
overflow: 'wrap',
},
};
}
@@ -4,67 +4,83 @@ import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/compone
import { getURLForLLM } from '@/components/utils';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockPrompt } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
export function PromptClient(props: {
type PromptClientProps = DocumentBlockPrompt['data'] & {
contentIcon: IconName | null;
description: string;
prompt: string;
openInAIProviders: boolean;
}) {
const { contentIcon, description, prompt, openInAIProviders } = props;
children?: React.ReactNode;
};
export function PromptClient(props: PromptClientProps) {
const {
contentIcon,
description,
openInAIProviders = false,
defaultExpanded = 'hidden',
prompt,
children,
} = props;
const language = useLanguage();
const promptId = React.useId();
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(defaultExpanded === 'full');
const isPartiallyExpanded = defaultExpanded === 'partial';
const expanded = isPartiallyExpanded || open;
React.useEffect(() => {
setOpen(defaultExpanded === 'full');
}, [defaultExpanded]);
return (
<div
className={tcls(
'relative flex w-full flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs text-tint-strong',
'border border-tint-subtle contrast-more:border-tint',
'transition',
open ? 'bg-tint depth-subtle:shadow-xs' : 'bg-tint-base'
expanded ? 'bg-tint depth-subtle:shadow-xs' : 'bg-tint-base'
)}
>
<div
className={tcls(
'group/prompt-header relative flex min-h-9 flex-row items-center justify-between gap-4 p-3 transition-colors',
open ? 'hover:bg-tint-hover' : 'hover:bg-tint-subtle'
!isPartiallyExpanded && (open ? 'hover:bg-tint-hover' : 'hover:bg-tint-subtle')
)}
>
<button
type="button"
aria-controls={promptId}
aria-expanded={open}
aria-label={tString(language, 'view')}
className={tcls(
'absolute inset-0 z-10 cursor-pointer outline-hidden',
'focus-visible:ring-2 focus-visible:ring-primary-hover'
)}
disabled={!prompt}
onClick={() => setOpen((prev) => !prev)}
/>
<div className="pointer-events-none relative z-0 flex min-w-0 flex-row items-center gap-2 text-tint-strong">
<ToggleChevron
open={open}
orientation="right-to-down"
className="size-3 shrink-0 text-tint-subtle transition-colors group-hover/prompt-header:text-tint-strong"
{!isPartiallyExpanded ? (
<button
type="button"
aria-controls={promptId}
aria-expanded={open}
aria-label={tString(language, 'view')}
className={tcls(
'absolute inset-0 z-10 cursor-pointer outline-hidden',
'focus-visible:ring-2 focus-visible:ring-primary-hover'
)}
disabled={!prompt}
onClick={() => setOpen((prev) => !prev)}
/>
) : null}
<div className="pointer-events-none relative z-0 flex min-w-0 flex-row items-center gap-2 text-tint-strong">
{!isPartiallyExpanded ? (
<ToggleChevron
open={open}
orientation="right-to-down"
className="size-3 shrink-0 text-tint-subtle transition-colors group-hover/prompt-header:text-tint-strong"
/>
) : null}
{contentIcon ? <Icon icon={contentIcon} className="size-4 shrink-0" /> : null}
<span className="min-w-0 truncate">{description}</span>
</div>
<PromptActions prompt={prompt} openInAIProviders={openInAIProviders} />
</div>
{open ? (
{isPartiallyExpanded || open ? (
<div id={promptId} className="border-tint-subtle border-t bg-tint-base">
<pre className="overflow-auto p-4 text-sm text-tint-strong">
<code className="language-markdown whitespace-pre-wrap font-mono">
{prompt}
</code>
</pre>
{children}
</div>
) : null}
</div>
@@ -1,2 +1 @@
export * from './Prompt';
export type * from './types';
@@ -1,14 +0,0 @@
import type { DocumentBlockCode } from '@gitbook/api';
export type PromptBlock = {
object: 'block';
type: 'prompt';
key?: string;
data: {
icon?: string;
description?: string;
openInAIProviders?: boolean;
};
nodes?: DocumentBlockCode[];
isVoid?: false;
};
@@ -197,8 +197,9 @@ export function EmbeddableIframeTabs(props: {
active?: string;
baseURL: string;
siteTitle: string;
onNavigate?: (href: string) => void;
}) {
const { ref, active = 'assistant', baseURL, siteTitle } = props;
const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props;
const actions = useEmbeddableConfiguration((state) => state.actions);
const tabs = useEmbeddableTabs();
@@ -261,6 +262,10 @@ export function EmbeddableIframeTabs(props: {
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={() => {
if (tab.key !== active && onNavigate) {
onNavigate(tab.href);
return;
}
router.push(tab.href);
}}
tooltipProps={{
@@ -274,7 +279,8 @@ export function EmbeddableIframeTabs(props: {
) : null;
}
export function EmbeddableIframeCloseButton() {
export function EmbeddableIframeCloseButton(props: { onClose?: () => void }) {
const { onClose } = props;
const { closeButton } = useEmbeddableConfiguration();
if (!closeButton) {
@@ -291,6 +297,7 @@ export function EmbeddableIframeCloseButton() {
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={() => {
onClose?.();
getChannel()?.send({ type: 'close' });
}}
tooltipProps={{
@@ -38,10 +38,12 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
const tabsRef = React.useRef<HTMLDivElement>(null);
const {
askQuery,
close,
cursor,
error,
fetching,
onInputKeyDown,
onResultSelect,
query,
results,
resultsId,
@@ -51,7 +53,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
showAsk,
withSearchAI,
scopeControl,
} = useSearchController({ ...searchProps, asEmbeddable: hasDocsTab });
} = useSearchController(
{ ...searchProps, asEmbeddable: hasDocsTab },
{ restoreLastQueryOnMount: true }
);
return (
<LinkContext value={linkContext}>
@@ -66,6 +71,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
results={results}
resultsId={resultsId}
resultsRef={resultsRef}
onResultSelect={onResultSelect}
showAsk={showAsk}
dataTestId="embed-search"
input={
@@ -97,9 +103,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
active="search"
baseURL={baseURL}
siteTitle={siteTitle}
onNavigate={close}
/>
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
<EmbeddableIframeCloseButton onClose={close} />
</>
}
scopeControl={
@@ -154,7 +154,11 @@ export function InsightsProvider(props: InsightsProviderProps) {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
eventsRef.current[pathname] = {
pageContext: previous?.pageContext ?? ctx,
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
// as the embed's assistant tab, whose view records a null page. Events that pass no
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
events: [
...(previous?.events ?? []),
@@ -3,6 +3,7 @@
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { CustomizationSearchStyle } from '@gitbook/api';
import dynamic from 'next/dynamic';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { AIChatButton } from '../AIChat';
@@ -10,13 +11,16 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { Button, Popover } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SideSheet } from '../primitives/SideSheet';
import { SearchFrame } from './SearchFrame';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import type { SearchBaseProps } from './search-props';
import { useSearchController } from './useSearchController';
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
ssr: false,
});
interface SearchContainerProps extends SearchBaseProps {
style: CustomizationSearchStyle;
className?: string;
@@ -40,12 +44,12 @@ export function SearchContainer({
askQuery,
askCount,
askInAssistant,
abort,
close,
cursor,
error,
fetching,
onInputKeyDown,
onResultSelect,
open,
query,
results,
@@ -113,8 +117,20 @@ export function SearchContainer({
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
const isSearchOpen = Boolean(visible && (state?.open ?? false));
const shouldFillHeight = Boolean(query || showAsk);
// The SideSheet always renders its children (it hides them with CSS, unlike the desktop
// Popover which mounts its content on open). Mounting the frame only after the first open
// keeps the dynamic SearchFrame chunk off the mobile startup path, while leaving it mounted
// afterwards so the sheet's exit animation isn't cut short.
const [wasSearchOpened, setWasSearchOpened] = React.useState(false);
React.useEffect(() => {
if (isSearchOpen) {
setWasSearchOpened(true);
}
}, [isSearchOpen]);
const shouldShowSearchFrame = usesSideSheet
? Boolean(state?.open || state?.query || withAI)
? Boolean(state?.open || state?.query || wasSearchOpened)
: Boolean(state?.query || withAI);
const scopeControlNode =
searchProps.withVariants || searchProps.withSections ? (
@@ -153,7 +169,7 @@ export function SearchContainer({
results={results}
resultsId={resultsId}
resultsRef={resultsRef}
onResultSelect={abort}
onResultSelect={onResultSelect}
showAsk={showAsk}
scopeControl={scopeControlNode}
fillHeight={usesSideSheet || shouldFillHeight}
@@ -0,0 +1,52 @@
'use client';
import React from 'react';
type LastSearchQueryBySiteSpace = Record<string, string>;
const listeners = new Set<() => void>();
let globalLastSearchQuery: LastSearchQueryBySiteSpace = {};
function emitChange() {
listeners.forEach((listener) => listener());
}
export function getLastSearchQuery(siteSpaceId: string): string | null {
return globalLastSearchQuery[siteSpaceId] ?? null;
}
export function setLastSearchQuery(siteSpaceId: string, query: string | null): void {
if (!siteSpaceId) {
return;
}
const normalizedQuery = query?.trim() || null;
const nextState = { ...globalLastSearchQuery };
if (normalizedQuery) {
nextState[siteSpaceId] = normalizedQuery;
} else {
delete nextState[siteSpaceId];
}
globalLastSearchQuery = nextState;
emitChange();
}
export function clearLastSearchQuery(siteSpaceId: string): void {
setLastSearchQuery(siteSpaceId, null);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useLastSearchQuery(siteSpaceId: string): string | null {
return React.useSyncExternalStore(
subscribe,
() => globalLastSearchQuery[siteSpaceId] ?? null,
() => null
);
}
@@ -0,0 +1,79 @@
'use client';
/**
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
*
* Loading strategy: the download starts as early as possible (server-rendered
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
* text so the multi-MB `JSON.parse` main-thread work is only paid when a
* consumer actually needs the pages.
*/
export interface SiteIndexBreadcrumb {
label: string;
icon?: string;
emoji?: string;
}
/** Raw entry from the `~gitbook/site-index` JSON response */
export interface SiteIndexPage {
id: string;
title: string;
pathname: string;
siteSpaceId: string;
/** BCP-47 language code emitted by the index route, absent when no language is set. */
lang?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: SiteIndexBreadcrumb[];
}
let siteIndexText: Promise<string> | null = null;
/**
* Start (or reuse) the single-flight download of the raw index. Errors clear the
* cache so the next consumer retries.
*/
export function prefetchSiteIndex(indexURL: string): void {
fetchSiteIndexText(indexURL).catch(() => {
// Ignored: consumers surface errors when they actually read the index.
});
}
/**
* Get the parsed index pages. Parses per call (cheap for the rare second
* consumer) so the parsed object graph is never retained at module scope.
*/
export async function fetchSiteIndex(
indexURL: string
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
return JSON.parse(await fetchSiteIndexText(indexURL));
}
/**
* Drop the cached raw text (several MB for large sites) once a consumer has
* turned it into a longer-lived form. Purely a memory release: a later consumer
* re-fetches, hitting the HTTP cache.
*/
export function releaseSiteIndex(): void {
siteIndexText = null;
}
function fetchSiteIndexText(indexURL: string): Promise<string> {
if (!siteIndexText) {
siteIndexText = fetch(indexURL).then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
return response.text();
});
siteIndexText.catch(() => {
siteIndexText = null;
});
}
return siteIndexText;
}
@@ -1,27 +1,14 @@
'use client';
import { Document, type DocumentValue } from 'flexsearch';
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
interface Breadcrumb {
label: string;
icon?: string;
emoji?: string;
}
/** Raw entry from the `~gitbook/index` JSON response */
interface RawIndexPage {
id: string;
title: string;
pathname: string;
siteSpaceId: string;
/** BCP-47 language code emitted by the index route, absent when no language is set. */
lang?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: Breadcrumb[];
}
import {
type SiteIndexBreadcrumb,
type SiteIndexPage,
fetchSiteIndex,
prefetchSiteIndex,
releaseSiteIndex,
} from './site-index';
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
interface IndexPage {
@@ -41,7 +28,7 @@ export interface LocalPageResult {
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: Breadcrumb[];
breadcrumbs?: SiteIndexBreadcrumb[];
}
type LocalSearchState = {
@@ -58,13 +45,16 @@ const cachedIndexes = new Map<string, Document<IndexPage>>();
// Keyed by page id, shared across all language groups.
const cachedPageData = new Map<
string,
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: Breadcrumb[] }
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: SiteIndexBreadcrumb[] }
>();
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
const index = new Document<IndexPage>({
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: SiteIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
id: 'id',
index: ['title', 'description'],
@@ -110,15 +100,14 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
}
pendingFetch = (async () => {
const response = await fetch(indexURL);
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
const data: { version: 1; pages: RawIndexPage[] } = await response.json();
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
const [{ Document }, data] = await Promise.all([
import('flexsearch'),
fetchSiteIndex(indexURL),
]);
// Group pages by their `lang` value (empty string for pages without one)
const pagesByLang = new Map<string, RawIndexPage[]>();
const pagesByLang = new Map<string, SiteIndexPage[]>();
for (const page of data.pages) {
const key = page.lang ?? '';
const bucket = pagesByLang.get(key);
@@ -131,9 +120,11 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
// Build one FlexSearch Document per language group
for (const [lang, pages] of pagesByLang) {
cachedIndexes.set(lang, buildLangIndex(pages));
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
releaseSiteIndex();
return cachedIndexes;
})();
@@ -156,8 +147,10 @@ export function useLocalSearchResults(props: {
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
filterSiteSpaceIds?: string[];
disabled?: boolean;
/** Whether the search surface is open. */
open: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false } = props;
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
@@ -168,6 +161,8 @@ export function useLocalSearchResults(props: {
// Track whether the indexes are loaded so the search effect re-runs after load
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
const active = open || Boolean(query);
// Load the indexes once
React.useEffect(() => {
if (cachedIndexes.size > 0) {
@@ -175,6 +170,12 @@ export function useLocalSearchResults(props: {
return;
}
prefetchSiteIndex(indexURL);
if (!active) {
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, fetching: true, error: false }));
@@ -194,7 +195,7 @@ export function useLocalSearchResults(props: {
return () => {
cancelled = true;
};
}, [indexURL]);
}, [indexURL, active]);
// Perform instant local search whenever query, lang, or index readiness changes
React.useEffect(() => {
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'bun:test';
import { shouldKeepSearchState } from './useSearch';
describe('shouldKeepSearchState', () => {
it('discards an empty default search state', () => {
expect(
shouldKeepSearchState({
q: null,
ask: null,
scope: 'default',
})
).toBe(false);
});
it('keeps a non-default scope without a query', () => {
expect(
shouldKeepSearchState({
q: null,
ask: null,
scope: 'current',
})
).toBe(true);
});
});
@@ -61,6 +61,12 @@ function normalizeRawState(values: Values<typeof keyMap>) {
return values;
}
export function shouldKeepSearchState(
values: Pick<Values<typeof keyMap>, 'q' | 'ask' | 'scope'>
): boolean {
return values.q !== null || values.ask !== null || values.scope !== 'default';
}
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
const { children } = props;
@@ -74,7 +80,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
const state = React.useMemo<SearchState | null>(() => {
const normalized = normalizeRawState(rawState);
if (normalized.q === null && normalized.ask === null) {
if (!shouldKeepSearchState(normalized)) {
return null;
}
return {
@@ -7,6 +7,12 @@ import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useBodyLoaded } from '../primitives';
import type { SearchResultsRef } from './SearchResults';
import {
clearLastSearchQuery,
getLastSearchQuery,
setLastSearchQuery,
useLastSearchQuery,
} from './last-query';
import { addRecentSearchQuery } from './recent-queries';
import type { SearchBaseProps } from './search-props';
import { useSearchState, useSetSearchState } from './useSearch';
@@ -109,7 +115,10 @@ function useSearchKeyboardNavigation(props: {
};
}
export function useSearchController(props: SearchBaseProps) {
export function useSearchController(
props: SearchBaseProps,
options: { restoreLastQueryOnMount?: boolean } = {}
) {
const {
asEmbeddable,
siteSpace,
@@ -130,6 +139,32 @@ export function useSearchController(props: SearchBaseProps) {
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (
!options.restoreLastQueryOnMount ||
restoredLastQueryForSiteSpaceRef.current === siteSpace.id
) {
return;
}
restoredLastQueryForSiteSpaceRef.current = siteSpace.id;
const restoredQuery = getLastSearchQuery(siteSpace.id);
if (!restoredQuery) {
return;
}
void setSearchState(
(prev) =>
prev ?? {
ask: null,
query: restoredQuery,
scope: 'default',
open: true,
}
);
}, [options.restoreLastQueryOnMount, setSearchState, siteSpace.id]);
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
@@ -141,38 +176,46 @@ export function useSearchController(props: SearchBaseProps) {
const onClose = React.useCallback(
async (to?: string) => {
setSearchState((prev) =>
prev
? {
...prev,
open: false,
query: prev.query === '' ? null : prev.query,
}
: null
);
setSearchState((prev) => {
if (!prev) return null;
if (prev.query !== null) {
setLastSearchQuery(siteSpace.id, prev.query);
}
return { ...prev, open: false, query: null };
});
if (to) {
router.push(to);
}
},
[setSearchState, router]
[setSearchState, router, siteSpace.id]
);
const onOpen = React.useCallback(() => {
if (state?.open) {
return;
}
setSearchState((prev) => ({
ask: withAI ? (prev?.ask ?? null) : null,
scope: prev?.scope ?? 'default',
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
open: true,
}));
setSearchState((prev) => {
const query =
prev?.query ??
getLastSearchQuery(siteSpace.id) ??
(withSearchAI || !withAI ? prev?.ask : null) ??
'';
return {
ask: withAI ? (prev?.ask ?? null) : null,
scope: prev?.scope ?? 'default',
query,
open: true,
};
});
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
const setQuery = React.useCallback(
(value: string) => {
@@ -186,7 +229,8 @@ export function useSearchController(props: SearchBaseProps) {
[setSearchState, withAI, withSearchAI]
);
const normalizedQuery = state?.query?.trim() ?? '';
const lastSearchQuery = useLastSearchQuery(siteSpace.id);
const normalizedQuery = (state?.query ?? lastSearchQuery ?? '').trim();
const normalizedAsk = state?.ask?.trim() ?? '';
const showAsk = withSearchAI && normalizedAsk.length > 0;
@@ -199,7 +243,8 @@ export function useSearchController(props: SearchBaseProps) {
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(state?.query || withAI),
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -211,9 +256,19 @@ export function useSearchController(props: SearchBaseProps) {
withSections,
});
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
const searchValue =
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
const searchResultsId = `search-results-${React.useId()}`;
// Only clears the remembered last query and stops any in-flight fetch — it must
// NOT touch searchState here. Navigable results dismiss the popover for free via
// their own <Link> navigation; racing that with a searchState/URL update in the
// same click caused a Next.js App Router transition conflict (RND-11972 regression).
const onResultSelect = React.useCallback(() => {
clearLastSearchQuery(siteSpace.id);
abort();
}, [abort, siteSpace.id]);
const askInAssistant = React.useCallback(
(assistantIndex = 0) => {
const assistant = assistants[assistantIndex];
@@ -259,6 +314,7 @@ export function useSearchController(props: SearchBaseProps) {
abort,
open: onOpen,
close: onClose,
onResultSelect,
query: normalizedQuery,
results,
resultsId: searchResultsId,
@@ -45,6 +45,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
/** Whether the search surface is open. Gates building the local search index. */
open: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -52,7 +54,7 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
indexURL: string;
/** BCP-47 language code of the current site space, used to filter local search results. */
lang?: string;
@@ -62,6 +64,7 @@ export function useSearchResults(props: {
const {
asEmbeddable,
disabled,
open,
query,
siteSpaceId,
siteSpaceIds,
@@ -85,6 +88,7 @@ export function useSearchResults(props: {
indexURL,
lang,
disabled,
open,
filterSiteSpaceIds,
});
@@ -39,10 +39,13 @@ export async function SiteLayout(props: {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
// We also preload the site index
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
// index downloads twice — the omission was exactly that bug before.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
});
scripts.forEach(({ script }) => {
@@ -0,0 +1,93 @@
'use client';
import { useState, useTransition } from 'react';
import { Button } from '@/components/primitives/Button';
import { Checkbox } from '@/components/primitives/Checkbox';
import { tcls } from '@/lib/tailwind';
import { type SubmitConsentInput, submitSiteOAuthConsent } from './actions';
/**
* Site's OAuth consent form to present to the user the client's information requesting access to the site's MCP.
*/
export function ConsentForm(props: {
siteId: string;
consentSessionId: string;
/** Whether the OAuth server recognizes the client as verified. */
verified: boolean;
}) {
const { siteId, consentSessionId, verified } = props;
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string>();
const [trusted, setTrusted] = useState(false);
// Unverified clients can only be approved once the visitor explicitly acknowledges they trust
// the app. The OAuth server re-checks this, so it can't be bypassed by tampering with the client.
const canApprove = verified || trusted;
const decide = (decision: SubmitConsentInput['decision']) => {
setError(undefined);
startTransition(async () => {
const result = await submitSiteOAuthConsent({
siteId,
consentSessionId,
decision,
trusted,
});
if ('redirectURL' in result) {
// Full-page navigation to the client's (external) redirect URI.
window.location.href = result.redirectURL;
} else {
setError(result.error);
}
});
};
return (
<div className="flex flex-col gap-3">
{error ? (
<p role="alert" className="text-danger-strong text-sm">
{error}
</p>
) : null}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
{verified ? (
<span />
) : (
<label
htmlFor="site-oauth-trusted"
className="flex items-center gap-2 text-sm text-tint"
>
<Checkbox
id="site-oauth-trusted"
checked={trusted}
onCheckedChange={(value) => setTrusted(value === true)}
/>
<span>I recognize and trust this client</span>
</label>
)}
<div className={tcls('ms-auto flex gap-2')}>
<Button
variant="secondary"
icon="xmark"
disabled={isPending}
onClick={() => decide('deny')}
>
Deny
</Button>
<Button
variant="primary"
icon="check"
disabled={isPending || !canApprove}
onClick={() => decide('approve')}
>
Approve
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
import { Icon } from '@gitbook/icons';
import { StyledLink } from '@/components/primitives/StyledLink';
import type { SiteOAuthConsentStart } from '@/lib/site-oauth';
import { tcls } from '@/lib/tailwind';
import { ConsentForm } from './ConsentForm';
/**
* Consent screen shown to a visitor when an MCP client requests authorization to a published site.
*/
export function ConsentScreen(props: {
siteId: string;
siteTitle: string;
consent: SiteOAuthConsentStart;
}) {
const { siteId, siteTitle, consent } = props;
const { client, redirectUri, consentSessionId } = consent;
const redirectParts = parseRedirectURI(redirectUri);
return (
<ConsentCard>
<div className="flex flex-col gap-6 p-6 sm:p-8">
{/* Client identity */}
<div className="flex items-start gap-3">
{client.logoUri ? (
<img
src={client.logoUri}
alt=""
className="size-10 shrink-0 rounded-corners:rounded-lg straight-corners:rounded-none object-contain"
referrerPolicy="no-referrer"
/>
) : (
<span className="flex size-10 shrink-0 items-center justify-center rounded-corners:rounded-lg straight-corners:rounded-none bg-tint-subtle text-tint">
<Icon icon="key" className="size-5" />
</span>
)}
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="font-semibold text-tint-strong">{client.name}</h1>
<ClientTrustBadge verified={client.verified} />
</div>
{client.uri ? (
<StyledLink
href={client.uri}
className="inline-flex w-fit items-center gap-1 text-sm text-tint"
>
Website
<Icon icon="arrow-up-right" className="size-3" />
</StyledLink>
) : null}
</div>
</div>
{/* Request statement — toned down, with the client and site names emphasized. */}
<p className="text-base text-tint leading-snug">
<span className="font-semibold text-tint-strong">{client.name}</span> wants to
access <span className="font-semibold text-tint-strong">{siteTitle} MCP</span>{' '}
on your behalf.
</p>
{/* Redirect URI, shown in full with the destination host emphasized. */}
<div className="flex flex-col gap-2">
<span className="text-sm text-tint">
After approving, an authorization code will be sent to:
</span>
<div
className={tcls(
'flex items-center gap-2.5',
'rounded-corners:rounded-md straight-corners:rounded-none',
'border border-tint-subtle bg-tint-subtle px-3 py-2'
)}
>
<Icon icon="link" className="size-4 shrink-0 text-tint" />
<code className="break-all font-mono text-sm">
{redirectParts ? (
<>
<span className="text-tint">{redirectParts.prefix}</span>
<span className="font-semibold text-tint-strong">
{redirectParts.host}
</span>
<span className="text-tint">{redirectParts.rest}</span>
</>
) : (
<span className="text-tint-strong">{redirectUri}</span>
)}
</code>
</div>
</div>
{client.verified ? null : (
<div
className={tcls(
'flex gap-3',
'rounded-corners:rounded-md straight-corners:rounded-none',
'bg-warning p-3 text-sm text-warning-strong'
)}
>
<Icon icon="triangle-exclamation" className="mt-0.5 size-4 shrink-0" />
<div className="flex flex-col gap-1">
<span className="font-semibold">
GitBook has not verified this client
</span>
<span>
Only approve if you recognize this application and trust it with
access to {siteTitle}.
</span>
</div>
</div>
)}
</div>
{/* Footer: trust acknowledgement + decision */}
<div className="border-tint-subtle border-t p-4 sm:px-8">
<ConsentForm
siteId={siteId}
consentSessionId={consentSessionId}
verified={client.verified}
/>
</div>
</ConsentCard>
);
}
/**
* Centered, branded card shell shared by the consent screen and its error state.
*/
function ConsentCard(props: { children: React.ReactNode }) {
return (
<main className="flex min-h-screen items-center justify-center bg-tint-subtle p-4">
<div
className={tcls(
'w-full max-w-lg',
'flex flex-col',
'rounded-corners:rounded-lg straight-corners:rounded-none',
'border border-tint-subtle bg-tint-base',
'shadow-lg'
)}
>
{props.children}
</div>
</main>
);
}
/**
* Error state shown when the consent flow cannot be started (e.g. a refreshed or expired link).
*/
export function ConsentError(props: { title?: string; message?: string }) {
const {
title = 'This authorization link has expired',
message = 'Please start the sign-in again from the application.',
} = props;
return (
<ConsentCard>
<div className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<span className="flex size-12 items-center justify-center rounded-corners:rounded-full straight-corners:rounded-none bg-danger text-danger-strong">
<Icon icon="circle-exclamation" className="size-6" />
</span>
<h1 className="font-semibold text-lg text-tint-strong">{title}</h1>
<p className="text-tint">{message}</p>
</div>
</ConsentCard>
);
}
/**
* Split a redirect URI so the destination host (the trust-relevant part) can be emphasized while
* the scheme and path are shown muted. Returns null if the URI can't be parsed.
*/
function parseRedirectURI(uri: string): { prefix: string; host: string; rest: string } | null {
try {
const url = new URL(uri);
return {
prefix: `${url.protocol}//`,
host: url.host,
rest: `${url.pathname}${url.search}${url.hash}`,
};
} catch {
return null;
}
}
/**
* Inline verified/unverified indicator shown next to the client name.
*/
function ClientTrustBadge(props: { verified: boolean }) {
const { verified } = props;
return (
<span
className={tcls(
'inline-flex items-center gap-1 font-medium text-xs',
verified ? 'text-success-strong' : 'text-warning-strong'
)}
>
<Icon icon={verified ? 'circle-check' : 'triangle-exclamation'} className="size-3" />
{verified ? 'Verified' : 'Unverified'}
</span>
);
}
@@ -0,0 +1,39 @@
'use server';
import { type SiteOAuthConsentDecision, submitSiteOAuthConsentDecision } from '@/lib/site-oauth';
export type SubmitConsentInput = {
siteId: string;
consentSessionId: string;
decision: SiteOAuthConsentDecision;
trusted: boolean;
};
export type SubmitConsentResult = { redirectURL: string } | { error: string };
/**
* Server action to submit the consent decision to the sites OAuth server's `consent/decision` endpoint.
*/
export async function submitSiteOAuthConsent(
input: SubmitConsentInput
): Promise<SubmitConsentResult> {
const { siteId, consentSessionId, decision, trusted } = input;
if (!siteId || !consentSessionId || (decision !== 'approve' && decision !== 'deny')) {
return { error: 'Invalid request. Please start again from the application.' };
}
try {
const { redirectURL } = await submitSiteOAuthConsentDecision({
siteId,
consentSessionId,
decision,
trusted,
});
return { redirectURL };
} catch (_error) {
return {
error: 'We could not complete the authorization. The request may have expired — please start again from the application.',
};
}
}
@@ -0,0 +1 @@
export { ConsentScreen, ConsentError } from './ConsentScreen';
@@ -11,6 +11,7 @@ import { useEffect, useState } from 'react';
import { useAI } from '../AI';
import { PreservePageLayout } from '../PageBody/PreservePageLayout';
import { useSetSearchState } from '../Search';
import { fetchSiteIndex } from '../Search/site-index';
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
import {
useSiteAdaptiveAuthLoginHref,
@@ -229,34 +230,21 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
) : null;
}
/** Minimal shape of an entry in the `~gitbook/site-index` response. */
type IndexPage = {
id: string;
title: string;
pathname: string;
siteSpaceId: string;
icon?: string;
emoji?: string;
};
/**
* Return the pages whose path is closest to the one that 404'd.
*
* Rather than asking the server (which would mean an extra request per 404), this reuses the
* search index served at `~gitbook/site-index` already preloaded and CDN-cached on every page
* so it's a cache hit, not an origin request. The ranking is a lighter, client-side cousin of
* `getSimilarPages` (which the Markdown 404 runs server-side from the full page tree).
* search index served at `~gitbook/site-index` preloaded on every page and shared with
* instant search via the module cache in `site-index.ts`, so at most one request is made.
* The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404
* runs server-side from the full page tree).
*/
async function getRelatedPages(
indexURL: string,
requestedPath: string,
siteSpaceId: string | null
): Promise<RelatedPage[]> {
const response = await fetch(indexURL);
if (!response.ok) {
return [];
}
const { pages } = (await response.json()) as { pages: IndexPage[] };
const { pages } = await fetchSiteIndex(indexURL);
return pages
.filter((page) => !siteSpaceId || page.siteSpaceId === siteSpaceId)
@@ -88,7 +88,10 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
<AIChatProvider
renderMessageOptions={aiChatRenderMessageOptions}
withPageFeedback={customization.feedback.enabled}
>
{children}
</AIChatProvider>
</InsightsProvider>
@@ -0,0 +1,104 @@
'use client';
import * as React from 'react';
import { useScrollListener } from './useScrollListener';
/**
* Track the scroll position and overflow amount of a scrollable container,
* keeping them in sync with scroll events, resizes, and content changes.
*/
export function useScrollOverflow(
orientation: 'horizontal' | 'vertical',
containerRef: React.RefObject<HTMLElement | null>
) {
const [scrollPosition, setScrollPosition] = React.useState(0);
const [scrollSize, setScrollSize] = React.useState(0);
const measure = React.useCallback(() => {
const container = containerRef.current;
if (!container) {
return;
}
const scrollDimension =
orientation === 'horizontal' ? container.scrollWidth : container.scrollHeight;
const clientDimension =
orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
setScrollSize(Math.max(scrollDimension - clientDimension - 1, 0));
setScrollPosition(
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
);
}, [orientation, containerRef]);
useScrollListener(measure, containerRef);
// Measure synchronously on mount (and when the container/orientation changes), so
// initial overflow is detected without waiting for a resize/scroll event. Subsequent
// content changes are picked up by the observers below instead of re-measuring on
// every render.
React.useLayoutEffect(() => {
measure();
}, [measure]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
// Children can overflow (or stop overflowing) without the container itself
// changing size, so we observe the direct children in addition to the container,
// and re-register observers as children are added/removed.
let frame: number | null = null;
const scheduleMeasure = () => {
if (frame !== null) {
return;
}
frame = requestAnimationFrame(() => {
frame = null;
measure();
});
};
const ro = new ResizeObserver(scheduleMeasure);
ro.observe(container);
for (const child of Array.from(container.children)) {
ro.observe(child);
}
const mo = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// Only re-register direct children with the resize observer; descendants
// deeper in the tree are covered by their parent's resize/mutation handling.
if (mutation.target !== container) {
continue;
}
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof Element) {
ro.observe(node);
}
}
for (const node of Array.from(mutation.removedNodes)) {
if (node instanceof Element) {
ro.unobserve(node);
}
}
}
scheduleMeasure();
});
// Also watch descendants (subtree/characterData) so text/content changes deeper in
// the tree that grow or shrink scrollHeight/scrollWidth still trigger a re-measure.
mo.observe(container, { childList: true, subtree: true, characterData: true });
return () => {
if (frame !== null) {
cancelAnimationFrame(frame);
}
ro.disconnect();
mo.disconnect();
};
}, [measure, containerRef]);
return { scrollPosition, scrollSize };
}
@@ -3,7 +3,7 @@
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { useScrollListener } from '../hooks/useScrollListener';
import { useScrollOverflow } from '../hooks/useScrollOverflow';
import { Button, type ButtonProps } from './Button';
/**
@@ -56,50 +56,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [scrollPosition, setScrollPosition] = React.useState(0);
const [scrollSize, setScrollSize] = React.useState(0);
const language = useLanguage();
useScrollListener(() => {
const container = containerRef.current;
if (!container) {
return;
}
setScrollSize(
orientation === 'horizontal'
? container.scrollWidth - container.clientWidth - 1
: container.scrollHeight - container.clientHeight - 1
);
setScrollPosition(
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
);
}, containerRef);
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
// Update max scroll position using resize observer
const ro = new ResizeObserver((entries) => {
const [entry] = entries;
if (entry) {
setScrollSize(
orientation === 'horizontal'
? entry.target.scrollWidth - entry.target.clientWidth - 1
: entry.target.scrollHeight - entry.target.clientHeight - 1
);
}
});
ro.observe(container);
return () => ro.disconnect();
}, [orientation]);
const { scrollPosition, scrollSize } = useScrollOverflow(orientation, containerRef);
React.useEffect(() => {
const container = containerRef.current;
@@ -145,6 +145,8 @@ export const ar: TranslationLanguage = {
ai_chat_tools_read_page: 'تمت قراءة الصفحة ${1}',
ai_chat_tools_navigated_to_page: 'تم فتح الصفحة',
ai_chat_tools_navigate_failed: 'تعذّر فتح الصفحة',
ai_chat_tools_submit_feedback: 'إرسال الملاحظات',
ai_chat_tools_submitted_feedback: 'تم إرسال ملاحظاتك',
ai_chat_tools_mcp_tool: 'تم استدعاء ${1}',
ai_chat_ask: 'اسأل ${1}',
ai_chat_ask_about: 'اسأل ${1} عن ${2}',
@@ -149,6 +149,8 @@ export const bg: TranslationLanguage = {
ai_chat_tools_read_page: 'Прочете страница ${1}',
ai_chat_tools_navigated_to_page: 'Страницата е отворена',
ai_chat_tools_navigate_failed: 'Страницата не може да бъде отворена',
ai_chat_tools_submit_feedback: 'Изпращане на обратна връзка',
ai_chat_tools_submitted_feedback: 'Обратната връзка е изпратена',
ai_chat_tools_mcp_tool: 'Извика ${1}',
ai_chat_ask: 'Попитайте ${1}',
ai_chat_ask_about: 'Попитайте ${1} за ${2}',
@@ -147,6 +147,8 @@ export const cs: TranslationLanguage = {
ai_chat_tools_read_page: 'Přečetl stránku ${1}',
ai_chat_tools_navigated_to_page: 'Stránka otevřena',
ai_chat_tools_navigate_failed: 'Stránku se nepodařilo otevřít',
ai_chat_tools_submit_feedback: 'Odeslat zpětnou vazbu',
ai_chat_tools_submitted_feedback: 'Zpětná vazba odeslána',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Zeptat se ${1}',
ai_chat_ask_about: 'Zeptat se ${1} na ${2}',
@@ -146,6 +146,8 @@ export const da: TranslationLanguage = {
ai_chat_tools_read_page: 'Læste side ${1}',
ai_chat_tools_navigated_to_page: 'Åbnede siden',
ai_chat_tools_navigate_failed: 'Kunne ikke åbne siden',
ai_chat_tools_submit_feedback: 'Send feedback',
ai_chat_tools_submitted_feedback: 'Feedback sendt',
ai_chat_tools_mcp_tool: 'Kaldte ${1}',
ai_chat_ask: 'Spørg ${1}',
ai_chat_ask_about: 'Spørg ${1} om ${2}',
@@ -153,6 +153,8 @@ export const de: TranslationLanguage = {
ai_chat_tools_read_page: 'Seite ${1} gelesen',
ai_chat_tools_navigated_to_page: 'Seite geöffnet',
ai_chat_tools_navigate_failed: 'Seite konnte nicht geöffnet werden',
ai_chat_tools_submit_feedback: 'Feedback senden',
ai_chat_tools_submitted_feedback: 'Feedback gesendet',
ai_chat_tools_mcp_tool: '${1} aufgerufen',
ai_chat_ask: '${1} fragen',
ai_chat_ask_about: '${1} zu ${2} befragen',
@@ -151,6 +151,8 @@ export const el: TranslationLanguage = {
ai_chat_tools_read_page: 'Διαβάστηκε η σελίδα ${1}',
ai_chat_tools_navigated_to_page: 'Άνοιξε η σελίδα',
ai_chat_tools_navigate_failed: 'Αποτυχία ανοίγματος της σελίδας',
ai_chat_tools_submit_feedback: 'Υποβολή σχολίων',
ai_chat_tools_submitted_feedback: 'Τα σχόλια υποβλήθηκαν',
ai_chat_tools_mcp_tool: 'Κλήθηκε ${1}',
ai_chat_ask: 'Ρωτήστε ${1}',
ai_chat_ask_about: 'Ρωτήστε ${1} για ${2}',
@@ -144,6 +144,8 @@ export const en = {
ai_chat_tools_read_page: 'Read page ${1}',
ai_chat_tools_navigated_to_page: 'Opened the page',
ai_chat_tools_navigate_failed: 'Failed to open the page',
ai_chat_tools_submit_feedback: 'Submit feedback',
ai_chat_tools_submitted_feedback: 'Submitted your feedback',
ai_chat_tools_mcp_tool: 'Called ${1}',
ai_chat_ask: 'Ask ${1}',
ai_chat_ask_about: 'Ask ${1} about ${2}',
@@ -151,6 +151,8 @@ export const es: TranslationLanguage = {
ai_chat_tools_read_page: 'Leyó la página ${1}',
ai_chat_tools_navigated_to_page: 'Página abierta',
ai_chat_tools_navigate_failed: 'No se pudo abrir la página',
ai_chat_tools_submit_feedback: 'Enviar comentarios',
ai_chat_tools_submitted_feedback: 'Comentarios enviados',
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
ai_chat_ask: 'Preguntar a ${1}',
ai_chat_ask_about: 'Preguntar a ${1} sobre ${2}',
@@ -146,6 +146,8 @@ export const et: TranslationLanguage = {
ai_chat_tools_read_page: 'Luges lehte ${1}',
ai_chat_tools_navigated_to_page: 'Leht avatud',
ai_chat_tools_navigate_failed: 'Lehe avamine ebaõnnestus',
ai_chat_tools_submit_feedback: 'Saada tagasiside',
ai_chat_tools_submitted_feedback: 'Tagasiside saadetud',
ai_chat_tools_mcp_tool: 'Kutsus ${1}',
ai_chat_ask: 'Küsi ${1}',
ai_chat_ask_about: 'Küsi ${1}: ${2}',
@@ -148,6 +148,8 @@ export const fi: TranslationLanguage = {
ai_chat_tools_read_page: 'Luettiin sivu ${1}',
ai_chat_tools_navigated_to_page: 'Sivu avattu',
ai_chat_tools_navigate_failed: 'Sivun avaaminen epäonnistui',
ai_chat_tools_submit_feedback: 'Lähetä palaute',
ai_chat_tools_submitted_feedback: 'Palaute lähetetty',
ai_chat_tools_mcp_tool: 'Kutsuttiin ${1}',
ai_chat_ask: 'Kysy ${1}',
ai_chat_ask_about: 'Kysy ${1}: ${2}',
@@ -147,6 +147,8 @@ export const fr: TranslationLanguage = {
ai_chat_tools_read_page: 'A consulté la page ${1}',
ai_chat_tools_navigated_to_page: 'Page ouverte',
ai_chat_tools_navigate_failed: "Échec de l'ouverture de la page",
ai_chat_tools_submit_feedback: 'Envoyer',
ai_chat_tools_submitted_feedback: 'Merci pour votre retour',
ai_chat_tools_mcp_tool: 'A appelé ${1}',
ai_chat_ask: 'Demander à ${1}',
ai_chat_ask_about: 'Demander à ${1} à propos de ${2}',
@@ -144,6 +144,8 @@ export const he: TranslationLanguage = {
ai_chat_tools_read_page: 'קרא את הדף ${1}',
ai_chat_tools_navigated_to_page: 'הדף נפתח',
ai_chat_tools_navigate_failed: 'פתיחת הדף נכשלה',
ai_chat_tools_submit_feedback: 'שליחת משוב',
ai_chat_tools_submitted_feedback: 'המשוב נשלח',
ai_chat_tools_mcp_tool: 'קרא ל-${1}',
ai_chat_ask: 'שאל את ${1}',
ai_chat_ask_about: 'שאל את ${1} על ${2}',
@@ -145,6 +145,8 @@ export const hi: TranslationLanguage = {
ai_chat_tools_read_page: 'पृष्ठ ${1} पढ़ा',
ai_chat_tools_navigated_to_page: 'पेज खोला गया',
ai_chat_tools_navigate_failed: 'पेज खोलने में विफल',
ai_chat_tools_submit_feedback: 'प्रतिक्रिया सबमिट करें',
ai_chat_tools_submitted_feedback: 'आपकी प्रतिक्रिया सबमिट कर दी गई',
ai_chat_tools_mcp_tool: '${1} को कॉल किया',
ai_chat_ask: '${1} से पूछें',
ai_chat_ask_about: '${1} से ${2} के बारे में पूछें',
@@ -147,6 +147,8 @@ export const hr: TranslationLanguage = {
ai_chat_tools_read_page: 'Pročitao stranicu ${1}',
ai_chat_tools_navigated_to_page: 'Stranica otvorena',
ai_chat_tools_navigate_failed: 'Stranicu nije moguće otvoriti',
ai_chat_tools_submit_feedback: 'Pošalji povratne informacije',
ai_chat_tools_submitted_feedback: 'Povratne informacije poslane',
ai_chat_tools_mcp_tool: 'Pozvao ${1}',
ai_chat_ask: 'Pitaj ${1}',
ai_chat_ask_about: 'Pitaj ${1} o ${2}',
@@ -148,6 +148,8 @@ export const hu: TranslationLanguage = {
ai_chat_tools_read_page: '${1} oldal elolvasva',
ai_chat_tools_navigated_to_page: 'Oldal megnyitva',
ai_chat_tools_navigate_failed: 'Az oldal megnyitása sikertelen',
ai_chat_tools_submit_feedback: 'Visszajelzés küldése',
ai_chat_tools_submitted_feedback: 'Visszajelzés elküldve',
ai_chat_tools_mcp_tool: '${1} meghívva',
ai_chat_ask: '${1} kérdezése',
ai_chat_ask_about: '${1} megkérdezése ${2} témában',
@@ -146,6 +146,8 @@ export const id: TranslationLanguage = {
ai_chat_tools_read_page: 'Membaca halaman ${1}',
ai_chat_tools_navigated_to_page: 'Halaman dibuka',
ai_chat_tools_navigate_failed: 'Gagal membuka halaman',
ai_chat_tools_submit_feedback: 'Kirim masukan',
ai_chat_tools_submitted_feedback: 'Masukan terkirim',
ai_chat_tools_mcp_tool: 'Memanggil ${1}',
ai_chat_ask: 'Tanya ${1}',
ai_chat_ask_about: 'Tanya ${1} tentang ${2}',
@@ -150,6 +150,8 @@ export const it: TranslationLanguage = {
ai_chat_tools_read_page: 'Ha letto la pagina ${1}',
ai_chat_tools_navigated_to_page: 'Pagina aperta',
ai_chat_tools_navigate_failed: 'Impossibile aprire la pagina',
ai_chat_tools_submit_feedback: 'Invia feedback',
ai_chat_tools_submitted_feedback: 'Feedback inviato',
ai_chat_tools_mcp_tool: 'Ha chiamato ${1}',
ai_chat_ask: 'Chiedi a ${1}',
ai_chat_ask_about: 'Chiedi a ${1} riguardo a ${2}',
@@ -147,6 +147,8 @@ export const ja: TranslationLanguage = {
ai_chat_tools_read_page: 'ページ ${1} を読みました',
ai_chat_tools_navigated_to_page: 'ページを開きました',
ai_chat_tools_navigate_failed: 'ページを開けませんでした',
ai_chat_tools_submit_feedback: 'フィードバックを送信',
ai_chat_tools_submitted_feedback: 'フィードバックを送信しました',
ai_chat_tools_mcp_tool: '${1} を呼び出しました',
ai_chat_ask: '${1} に質問する',
ai_chat_ask_about: '${1}に${2}について質問する',
@@ -146,6 +146,8 @@ export const ko: TranslationLanguage = {
ai_chat_tools_read_page: '${1} 페이지를 읽음',
ai_chat_tools_navigated_to_page: '페이지를 열었습니다',
ai_chat_tools_navigate_failed: '페이지를 열지 못했습니다',
ai_chat_tools_submit_feedback: '피드백 제출',
ai_chat_tools_submitted_feedback: '피드백을 제출했습니다',
ai_chat_tools_mcp_tool: '${1} 호출함',
ai_chat_ask: '${1}에게 질문',
ai_chat_ask_about: '${1}에게 ${2}에 대해 질문',
@@ -147,6 +147,8 @@ export const lt: TranslationLanguage = {
ai_chat_tools_read_page: 'Perskaitytas puslapis ${1}',
ai_chat_tools_navigated_to_page: 'Puslapis atidarytas',
ai_chat_tools_navigate_failed: 'Nepavyko atidaryti puslapio',
ai_chat_tools_submit_feedback: 'Siųsti atsiliepimą',
ai_chat_tools_submitted_feedback: 'Atsiliepimas išsiųstas',
ai_chat_tools_mcp_tool: 'Iškviesta ${1}',
ai_chat_ask: 'Klausti ${1}',
ai_chat_ask_about: 'Klausti ${1} apie ${2}',
@@ -145,6 +145,8 @@ export const lv: TranslationLanguage = {
ai_chat_tools_read_page: 'Izlasīja lapu ${1}',
ai_chat_tools_navigated_to_page: 'Lapa atvērta',
ai_chat_tools_navigate_failed: 'Neizdevās atvērt lapu',
ai_chat_tools_submit_feedback: 'Nosūtīt atsauksmi',
ai_chat_tools_submitted_feedback: 'Atsauksme nosūtīta',
ai_chat_tools_mcp_tool: 'Izsauca ${1}',
ai_chat_ask: 'Jautāt ${1}',
ai_chat_ask_about: 'Jautāt ${1} par ${2}',
@@ -146,6 +146,8 @@ export const ms: TranslationLanguage = {
ai_chat_tools_read_page: 'Membaca halaman ${1}',
ai_chat_tools_navigated_to_page: 'Halaman dibuka',
ai_chat_tools_navigate_failed: 'Gagal membuka halaman',
ai_chat_tools_submit_feedback: 'Hantar maklum balas',
ai_chat_tools_submitted_feedback: 'Maklum balas dihantar',
ai_chat_tools_mcp_tool: 'Memanggil ${1}',
ai_chat_ask: 'Tanya ${1}',
ai_chat_ask_about: 'Tanya ${1} tentang ${2}',
@@ -149,6 +149,8 @@ export const nl: TranslationLanguage = {
ai_chat_tools_read_page: 'Pagina ${1} gelezen',
ai_chat_tools_navigated_to_page: 'Pagina geopend',
ai_chat_tools_navigate_failed: 'Kan de pagina niet openen',
ai_chat_tools_submit_feedback: 'Feedback verzenden',
ai_chat_tools_submitted_feedback: 'Feedback verzonden',
ai_chat_tools_mcp_tool: '${1} aangeroepen',
ai_chat_ask: 'Vraag het aan ${1}',
ai_chat_ask_about: 'Stel ${1} een vraag over ${2}',
@@ -148,6 +148,8 @@ export const no: TranslationLanguage = {
ai_chat_tools_read_page: 'Leste side ${1}',
ai_chat_tools_navigated_to_page: 'Åpnet siden',
ai_chat_tools_navigate_failed: 'Kunne ikke åpne siden',
ai_chat_tools_submit_feedback: 'Send tilbakemelding',
ai_chat_tools_submitted_feedback: 'Tilbakemelding sendt',
ai_chat_tools_mcp_tool: 'Kalte ${1}',
ai_chat_ask: 'Spør ${1}',
ai_chat_ask_about: 'Spør ${1} om ${2}',
@@ -147,6 +147,8 @@ export const pl: TranslationLanguage = {
ai_chat_tools_read_page: 'Przeczytano stronę ${1}',
ai_chat_tools_navigated_to_page: 'Otwarto stronę',
ai_chat_tools_navigate_failed: 'Nie udało się otworzyć strony',
ai_chat_tools_submit_feedback: 'Wyślij opinię',
ai_chat_tools_submitted_feedback: 'Przesłano opinię',
ai_chat_tools_mcp_tool: 'Wywołano ${1}',
ai_chat_ask: 'Zapytaj ${1}',
ai_chat_ask_about: 'Zapytaj ${1} o ${2}',
@@ -151,6 +151,8 @@ export const pt_br: TranslationLanguage = {
ai_chat_tools_read_page: 'Leu a página ${1}',
ai_chat_tools_navigated_to_page: 'Página aberta',
ai_chat_tools_navigate_failed: 'Falha ao abrir a página',
ai_chat_tools_submit_feedback: 'Enviar feedback',
ai_chat_tools_submitted_feedback: 'Feedback enviado',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}',
@@ -148,6 +148,8 @@ export const pt: TranslationLanguage = {
ai_chat_tools_read_page: 'Leu a página ${1}',
ai_chat_tools_navigated_to_page: 'Página aberta',
ai_chat_tools_navigate_failed: 'Falha ao abrir a página',
ai_chat_tools_submit_feedback: 'Enviar feedback',
ai_chat_tools_submitted_feedback: 'Feedback enviado',
ai_chat_tools_mcp_tool: 'Chamou ${1}',
ai_chat_ask: 'Perguntar a ${1}',
ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}',
@@ -150,6 +150,8 @@ export const ro: TranslationLanguage = {
ai_chat_tools_read_page: 'A citit pagina ${1}',
ai_chat_tools_navigated_to_page: 'Pagina a fost deschisă',
ai_chat_tools_navigate_failed: 'Deschiderea paginii a eșuat',
ai_chat_tools_submit_feedback: 'Trimite feedback',
ai_chat_tools_submitted_feedback: 'Feedback trimis',
ai_chat_tools_mcp_tool: 'A apelat ${1}',
ai_chat_ask: 'Întreabă ${1}',
ai_chat_ask_about: 'Întreabă ${1} despre ${2}',
@@ -150,6 +150,8 @@ export const ru: TranslationLanguage = {
ai_chat_tools_read_page: 'Прочитана страница ${1}',
ai_chat_tools_navigated_to_page: 'Страница открыта',
ai_chat_tools_navigate_failed: 'Не удалось открыть страницу',
ai_chat_tools_submit_feedback: 'Отправить отзыв',
ai_chat_tools_submitted_feedback: 'Отзыв отправлен',
ai_chat_tools_mcp_tool: 'Вызван ${1}',
ai_chat_ask: 'Спросить у ${1}',
ai_chat_ask_about: 'Спросить у ${1} о ${2}',
@@ -149,6 +149,8 @@ export const sk: TranslationLanguage = {
ai_chat_tools_read_page: 'Prečítal stránku ${1}',
ai_chat_tools_navigated_to_page: 'Stránka otvorená',
ai_chat_tools_navigate_failed: 'Stránku sa nepodarilo otvoriť',
ai_chat_tools_submit_feedback: 'Odoslať spätnú väzbu',
ai_chat_tools_submitted_feedback: 'Spätná väzba odoslaná',
ai_chat_tools_mcp_tool: 'Zavolal ${1}',
ai_chat_ask: 'Opýtať sa ${1}',
ai_chat_ask_about: 'Opýtať sa ${1} na ${2}',
@@ -147,6 +147,8 @@ export const sl: TranslationLanguage = {
ai_chat_tools_read_page: 'Prebrana stran ${1}',
ai_chat_tools_navigated_to_page: 'Stran odprta',
ai_chat_tools_navigate_failed: 'Strani ni bilo mogoče odpreti',
ai_chat_tools_submit_feedback: 'Pošlji povratne informacije',
ai_chat_tools_submitted_feedback: 'Povratne informacije poslane',
ai_chat_tools_mcp_tool: 'Poklicano ${1}',
ai_chat_ask: 'Vprašaj ${1}',
ai_chat_ask_about: 'Vprašaj ${1} o ${2}',

Some files were not shown because too many files have changed in this diff Show More