Compare commits

..

27 Commits

Author SHA1 Message Date
Zeno Kapitein 365f60d2ca First try 2025-12-17 15:19:07 +01:00
Zeno Kapitein c716df39ba Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-16 19:49:33 +01:00
Zeno Kapitein 8ee84f130f Update SpaceLayout.tsx 2025-12-12 11:53:46 +01:00
Zeno Kapitein aa86711d5a More spacing 2025-12-11 19:33:38 +01:00
Zeno Kapitein 5347917a47 Spacing 2025-12-11 19:16:24 +01:00
Zeno Kapitein 43e8deca0f Add margin to first document item 2025-12-11 17:56:49 +01:00
Zeno Kapitein 4cdab37eac Rework table of contents to accommodate faded edges 2025-12-11 17:27:49 +01:00
Zeno Kapitein 8a887fd890 Fix tests 2025-12-11 14:39:00 +01:00
Zeno Kapitein 2f6ea5b1f0 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-11 14:28:55 +01:00
Zeno Kapitein dcd03554bd Update TOC to use normal scrollcontainer 2025-12-11 14:28:26 +01:00
Zeno Kapitein 9281b5e3b7 Update SideSheet.tsx 2025-12-11 14:26:46 +01:00
Zeno Kapitein ec0b6075b0 Update AnnouncementBanner.tsx 2025-12-11 10:24:15 +01:00
Zeno Kapitein e372dae552 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-11 10:11:28 +01:00
Zeno Kapitein e329b41764 Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-05 14:13:46 +01:00
Zeno Kapitein c8956720d0 Update Header.tsx 2025-12-04 18:32:37 +01:00
Zeno Kapitein 843635225d Make sidesheet work well in docs embed 2025-12-04 18:17:31 +01:00
Zeno Kapitein f78e9ca34e Merge branch 'main' into zeno/rnd-7450-mobile-toc-chat-layout 2025-12-04 17:36:31 +01:00
Zeno Kapitein 72a06718e2 Layout 2025-12-02 19:22:21 +01:00
Zeno Kapitein 0a7979ede2 More layout fixes 2025-12-02 17:49:51 +01:00
Zeno Kapitein fb49a27cc1 Layout tweaks 2025-12-02 16:04:39 +01:00
Zeno Kapitein 555a1da3ab Changeset 2025-12-02 14:10:13 +01:00
Zeno Kapitein b9f9623970 Fix scroll gutter 2025-12-02 14:08:23 +01:00
Zeno Kapitein a7b80c3c4f Small fixes 2025-12-02 13:54:32 +01:00
Zeno Kapitein e05f262f46 Get ready for supporting side sections list
(not enabled for now)
2025-12-02 13:50:38 +01:00
Zeno Kapitein f53e0427e4 Naming and docs 2025-12-02 12:52:02 +01:00
Zeno Kapitein 0d44191666 Fix trademark & other visual bugs 2025-12-02 11:40:28 +01:00
Zeno Kapitein 9e669f162a Add SideSheet component, refactor TOC and AIChat to use it
Also fixes RND-7803
2025-12-02 10:09:24 +01:00
359 changed files with 3092 additions and 14019 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix missing properties in allOf/oneOf
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add Input component
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Enhance discriminator handling in OpenAPISchema
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
---
Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Variant dropdown not showing in GitBook embed
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix overflowing section groups
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search results cursor
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/embed": minor
"gitbook": patch
---
Improve Docs Embed with separate Assistant and Docs tabs
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Use localized titles/descriptions for site entities
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Import site redirect patterns from @gitbook/api
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": minor
---
Update to FontAwesome 7.1.0
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add customizable suggested questions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI basic auth placeholder
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces and root page in embeddable view.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support `greeting` and fix suggested questions if there are no custom ones defined
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI oneOf/allOf merge
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Support x-gitbook-token-placeholder as TryIt prefill fallback for HTTP auth (basic and bearer)
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add a RSS feed to all pages with update blocks.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Remove hidden site spaces from navigation
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix hidden section not found
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI circular references
@@ -26,7 +26,7 @@ inputs:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs:
@@ -52,8 +52,6 @@ runs:
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
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_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
@@ -109,7 +107,7 @@ runs:
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
+1 -3
View File
@@ -20,7 +20,7 @@ inputs:
description: 'Environment to deploy to'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
@@ -51,8 +51,6 @@ runs:
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
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_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
+7 -2
View File
@@ -5,6 +5,8 @@ on:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -34,10 +36,13 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build:
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build
name: Build (Open Source)
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+11 -42
View File
@@ -1,25 +1,15 @@
name: Preview
on:
pull_request_target:
pull_request:
push:
branches:
- main
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
name: Approve Preview Deployment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
environment:
name: preview-approval
steps:
- name: Approval gate
run: echo "Preview deployment approved."
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -28,8 +18,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Vercel
id: deploy
uses: ./.github/composite/deploy-vercel
@@ -43,8 +31,6 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -53,8 +39,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Cloudflare
id: deploy
uses: ./.github/composite/deploy-cloudflare
@@ -81,7 +65,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: always() && github.event_name != 'push'
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -118,13 +102,10 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -137,18 +118,15 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-vercel
ARGOS_BUILD_NAME: 'v2-vercel'
visual-testing-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -161,18 +139,15 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-cloudflare
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v2:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -185,18 +160,15 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-vercel
ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -209,17 +181,14 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-cloudflare
ARGOS_BUILD_NAME: 'customers-v2'
browserless-testing-v2-vercel:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -248,4 +217,4 @@ jobs:
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
+2
View File
@@ -3,6 +3,8 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
+3
View File
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
-3
View File
@@ -1,3 +0,0 @@
# AGENTS Instructions
- After making code changes, run `bun format`.
+17 -1
View File
@@ -77,7 +77,23 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
```
and then reinstall dependencies.
## Contributing
Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

After

Width:  |  Height:  |  Size: 622 KiB

+234 -905
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+5 -5
View File
@@ -6,11 +6,11 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.16",
"vercel": "^50.31.1"
"@changesets/cli": "^2.29.7",
"turbo": "^2.6.1",
"vercel": "^39.4.2"
},
"packageManager": "bun@1.3.7",
"packageManager": "bun@1.3.2",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.169.0",
"@gitbook/api": "0.154.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
-15
View File
@@ -1,20 +1,5 @@
# @gitbook/browser-types
## 0.1.5
### Patch Changes
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 2e495cb: Add Global Privacy Control (GPC) support
## 0.1.4
### Patch Changes
- 7a11861: Add support for custom cookie banner registration
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
}
},
"sideEffects": false,
"version": "0.1.5",
"version": "0.1.3",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
-24
View File
@@ -46,11 +46,6 @@ export type GitBookAssistant = {
ui?: boolean;
};
export type GitBookCookieBannerHandler = (options: {
onApprove: () => void;
onReject: () => void;
}) => void;
export type GitBookGlobal = {
/**
* Register an event listener.
@@ -77,25 +72,6 @@ export type GitBookGlobal = {
* Register a custom assistant to be available on the site.
*/
registerAssistant: (assistant: GitBookAssistant) => () => void;
/**
* Register a custom cookie banner handler.
* When registered, the built-in cookie banner will not be displayed.
*/
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
/**
* Indicates whether cookies tracking is disabled for the current user.
* Returns `true` when tracking is disabled, `false` when tracking is enabled,
* and `undefined` when the tracking preference is unknown or not yet determined.
*/
isCookiesTrackingDisabled: () => boolean | undefined;
/**
* Indicates whether global privacy control is enabled for the current user.
* Returns `true` when global privacy control is enabled, `false` when it is disabled.
*/
isGlobalPrivacyControlEnabled: () => boolean;
};
declare global {
-38
View File
@@ -1,43 +1,5 @@
# @gitbook/embed
## 0.4.0
### Minor Changes
- b3875a1: Support `assistantName` property to override Assistant name
## 0.3.0
### Minor Changes
- 370b731: Docs Embed: Make trademark optional
## 0.2.2
### Patch Changes
- b4b471f: Fix unexpected error thrown when using the embed in standalone mode
## 0.2.1
### Patch Changes
- 96e24a1: Fix signed JWT token usage
- 0aa34cc: Fix Embed options in React
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.2.0
### Minor Changes
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
### Patch Changes
- Updated dependencies [62438f4]
- @gitbook/icons@0.4.0
## 0.1.5
### Patch Changes
+6 -71
View File
@@ -49,10 +49,8 @@ GitBook('configure', {
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */],
closeButton: true,
tools: [/* ... */]
});
```
@@ -101,10 +99,8 @@ frame.configure({
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */],
closeButton: true
tools: [/* ... */]
});
// Listen to events
@@ -128,7 +124,6 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
@@ -138,7 +133,6 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}
]}
tools={[/* ... */]}
closeButton
/>
</GitBookProvider>
```
@@ -229,62 +223,15 @@ Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'docs']
```
### `closeButton`
Available in: Standalone script, NPM package, React components
Display a close (`x`) button in the embed sidebar.
- **Type**: `boolean`
- **Default**: `false`
```javascript
closeButton: true
```
Behavior:
- When clicked, the embed sends a `close` event to the parent page.
- In the standalone script, this event is handled automatically and the widget closes.
- In custom iframe integrations (NPM package), you must listen for the `close` event and decide how to hide/collapse your UI.
- In React, this works automatically when using the standard widget flow. If you build custom frame wiring, handle `close` the same way as the NPM package.
NPM package example:
```typescript
const frame = gitbook.createFrame(iframe);
frame.configure({
closeButton: true
});
const unsubscribe = frame.on('close', () => {
// Hide your modal/drawer/container
container.classList.add('hidden');
});
```
Notes:
- The close button is rendered in the same sidebar area as tabs/actions.
- If your UI hides that sidebar or doesn't render it, the button will not be visible.
### `trademark`
Available in: Standalone script, NPM package, React components
Display GitBook branding in the embed. Defaults to true.
- **Type**: `boolean`
- **Default**: `true`
```javascript
trademark: true
```
### `actions`
Available in: Standalone script, NPM package, React components
@@ -331,18 +278,6 @@ greeting: {
}
```
### `assistantName`
Available in: Standalone script, NPM package, React components
Override the assistant name displayed in the chat header and assistant entry points (for example, sidebar tabs and action labels). This name will be limited to 32 characters to prevent text overflow.
- **Type**: `string`
```javascript
assistantName: 'Support Assistant'
```
### `suggestions`
Available in: Standalone script, NPM package, React components
+1 -3
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.4.0",
"version": "0.1.5",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -23,7 +23,6 @@
"react": "*"
},
"devDependencies": {
"bun-types": "catalog:",
"@types/react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
@@ -34,7 +33,6 @@
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { createGitBook } from './createGitBook';
describe('createGitBook.getFrameURL', () => {
it('builds the embed URL when the site URL has no trailing slash', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs' });
const url = new URL(client.getFrameURL({}));
expect(url.origin).toBe('https://example.com');
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.toString()).toBe('');
});
it('handles a trailing slash and adds visitor parameters', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
visitor: {
token: 'signed-token',
unsignedClaims: {
role: 'editor',
count: 3,
enabled: false,
},
},
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('jwt_token')).toBe('signed-token');
expect(url.searchParams.get('visitor.role')).toBe('editor');
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ export function createGitBook(options: CreateGitBookOptions) {
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token);
url.searchParams.set('token', frameOptions.visitor.token);
}
if (frameOptions.visitor?.unsignedClaims) {
@@ -69,7 +69,6 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
trademark: true,
};
return {
-16
View File
@@ -62,27 +62,11 @@ export type GitBookEmbeddableConfiguration = {
subtitle: string;
};
/**
* Override the assistant name displayed in the UI.
* Limited to 32 characters.
*/
assistantName?: string;
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
/**
* Display GitBook branding in the embed.
*/
trademark?: boolean;
/**
* Display a close button inside the assistant.
*/
closeButton?: boolean;
};
/**
-1
View File
@@ -1,2 +1 @@
export * from './client';
export * from './standalone';
+4 -28
View File
@@ -11,24 +11,13 @@ import { useGitBook } from './GitBookProvider';
export type GitBookFrameProps = {
className?: string;
} & GetFrameURLOptions &
Partial<GitBookEmbeddableConfiguration>;
GitBookEmbeddableConfiguration;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const {
className,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
trademark = true,
closeButton = false,
assistantName,
} = props;
const { className, visitor, actions, greeting, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
@@ -44,26 +33,13 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs,
tabs: ['assistant', 'docs'],
actions,
greeting,
suggestions,
tools,
closeButton,
trademark,
assistantName,
});
}, [
gitbookFrame,
actions,
greeting,
suggestions,
tools,
tabs,
closeButton,
trademark,
assistantName,
]);
}, [gitbookFrame, actions, greeting, suggestions, tools]);
return (
<iframe
+2 -18
View File
@@ -65,7 +65,6 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
trademark: true,
};
const widgetButton = document.createElement('button');
@@ -107,10 +106,6 @@ function getIframe() {
widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe);
_frame.on('close', () => {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -157,19 +152,6 @@ const GitBook = (...args: StandaloneCalls) => {
break;
case 'configure': {
const settings = args[1];
// If trademark is disabled, change the (branded) icon to the sparkle icon
if (
settings.trademark === false &&
!settings.button?.icon &&
frameConfiguration.button.icon === 'assistant'
) {
settings.button = {
label: frameConfiguration.button.label,
icon: 'sparkle',
};
}
frameConfiguration = {
...frameConfiguration,
...settings,
@@ -211,3 +193,5 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
+1 -3
View File
@@ -15,9 +15,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
+2 -2
View File
@@ -11,8 +11,8 @@
},
"sideEffects": false,
"dependencies": {
"acorn": "^8.15.0",
"acorn-loose": "^8.5.2",
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0",
-170
View File
@@ -1,175 +1,5 @@
# gitbook
## 0.22.3
### Patch Changes
- f182680: Add backward compatibility for OpenAPI spec content URL
- bdc6cd9: Use OpenAPI publicURL as Scalar specUrl
- b3875a1: Refactor embeddable context to merge local & site properties in one unified way
- eceec6f: Move ~scalar/proxy route to site route group
- 887c3f8: Add token verification on OpenAPI proxy
- d666212: Add support for localized site section titles
- Updated dependencies [b3875a1]
- Updated dependencies [bdc6cd9]
- Updated dependencies [8ab419a]
- Updated dependencies [eceec6f]
- Updated dependencies [887c3f8]
- @gitbook/embed@0.4.0
- @gitbook/react-openapi@1.5.10
- @gitbook/openapi-parser@3.0.10
## 0.22.2
### Patch Changes
- e074283: Add server-side proxy for Scalar API client to bypass CORS
- afa868e: Show "Ask Assistant" search result immediately
- 0803db0: Track opening of search results for records and in the assistant.
- cb71511: Wrap tracking call with waitUntil
- 4a6f91a: Show social accounts in header
- 370b731: Docs Embed: Make trademark optional
- afa476c: Add server-side insight event tracking for MCP route
- Updated dependencies [e074283]
- Updated dependencies [370b731]
- @gitbook/openapi-parser@3.0.9
- @gitbook/react-openapi@1.5.9
- @gitbook/embed@0.3.0
## 0.22.1
### Patch Changes
- 9d5e397: Remove code syntax highlighting patch for Safari
- 3d8e89c: Show favicon for record search results in the assistant.
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 426aabe: Fix TOC header logo on bold themes
- ae30a57: Integrate Ask AI with upcoming insights by passing visitor session IDs.
- f7c251e: Update Reddit social account rendering
- e31a8f4: Hide cookie banner for AI user agents
- 2e495cb: Add Global Privacy Control (GPC) support
- Updated dependencies [1a7ef78]
- Updated dependencies [93eea0b]
- Updated dependencies [b4b471f]
- Updated dependencies [5f66860]
- Updated dependencies [2ebb18d]
- Updated dependencies [11d9b80]
- Updated dependencies [2e495cb]
- Updated dependencies [f517035]
- @gitbook/react-openapi@1.5.8
- @gitbook/embed@0.2.2
- @gitbook/browser-types@0.1.5
## 0.22.0
### Minor Changes
- e00d1c5: Support code themes customization
- f294818: Serve a markdown version of the page when the "Accept" header contains "text/markdown".
### Patch Changes
- 9d6457f: OpenAPI styling tweaks
- d738e11: Prevent form submission during IME composition
- 1e53376: Add sidesheet component, use it for TOC and AIChat
- f7f6f9d: Set <html lang> based on translated language
- 89de966: Support social links
- b53cb1d: Align styling of PageLinkItem and PageDocumentItem
- 27a34b7: Support `defaultExpanded` in Expandable block
- a5ef44a: Better conditional display of page actions
- 07c02eb: Header & TOC tweaks
- 29e2b22: Fix ordered list item index calculation
- f6cf62a: Standardise toggle chevrons across the app
- b57561d: Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
- e172b25: Grow InlineActionButton to take available width on line
- 7a11861: Add support for custom cookie banner registration
- 8f32f4f: Better handle huge section groups
- 0aa34cc: Fix Embed options in React
- Updated dependencies [e00d1c5]
- Updated dependencies [96e24a1]
- Updated dependencies [4e2d863]
- Updated dependencies [d3f6c38]
- Updated dependencies [3ba9e46]
- Updated dependencies [717ad0b]
- Updated dependencies [6f1db32]
- Updated dependencies [7a11861]
- Updated dependencies [0aa34cc]
- Updated dependencies [48ca21e]
- @gitbook/react-openapi@1.5.7
- @gitbook/embed@0.2.1
- @gitbook/openapi-parser@3.0.8
- @gitbook/icons@0.4.3
- @gitbook/browser-types@0.1.4
- @gitbook/react-contentkit@0.7.12
## 0.21.3
### Patch Changes
- Updated dependencies [bf63045]
- @gitbook/react-openapi@1.5.6
## 0.21.2
### Patch Changes
- ca68236: Fix 404 when accessing rss.xml for the root page.
- 21851d3: Round page cover corners on extrawide screens
- 695e99f: Update button sizing
- 7517e98: Clean up button and input across the app
- 99d788e: Update page cover styling
- ebf3b2b: Fix an issue where content refs to spaces outside of site was resolving with share links
- 35efc9d: Fix TOCPageIcons backgrounds on gradient and bold themes
- a7f58ab: Fix embed tab button size regression
- 360b525: Switch icon masking method to fix visual cutoffs in FA7
- Updated dependencies [985c1b4]
- Updated dependencies [360b525]
- @gitbook/openapi-parser@3.0.7
- @gitbook/icons@0.4.2
- @gitbook/react-openapi@1.5.5
- @gitbook/react-contentkit@0.7.11
## 0.21.1
### Patch Changes
- Updated dependencies [e7fb771]
- @gitbook/icons@0.4.1
- @gitbook/react-contentkit@0.7.10
## 0.21.0
### Minor Changes
- 3553db0: Add a RSS feed to all pages with update blocks.
### Patch Changes
- f478ddc: Add Input component
- 3817289: Fix overflowing section groups
- 86e7aa6: Fix search results cursor
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
- ed9e8fe: Add support for button actions
- 9c69cc4: Add customizable suggested questions
- c51076e: Fix links to other spaces and root page in embeddable view.
- 6278a18: Support `greeting` and fix suggested questions if there are no custom ones defined
- 04a4175: Remove hidden site spaces from navigation
- 74f8931: Fix hidden section not found
- 344842f: Improve OpenAPI circular references
- Updated dependencies [4766092]
- Updated dependencies [8761cee]
- Updated dependencies [461e15f]
- Updated dependencies [1430cee]
- Updated dependencies [62438f4]
- Updated dependencies [3e40b4d]
- Updated dependencies [87d68ea]
- Updated dependencies [344842f]
- @gitbook/react-openapi@1.5.4
- @gitbook/openapi-parser@3.0.6
- @gitbook/embed@0.2.0
- @gitbook/icons@0.4.0
- @gitbook/react-contentkit@0.7.9
## 0.20.0
### Minor Changes
@@ -1,76 +0,0 @@
import { expect } from '@playwright/test';
import { type TestsCase, getCustomizationURL, runTestCases } from './util';
const testCases: TestsCase[] = [
{
name: 'Cookie Banner',
contentBaseURL: 'https://gitbook.com/docs/',
tests: [
{
name: 'should show built-in banner when no custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Check that built-in banner is visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when UserAgent is AI',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Override navigator.userAgent to simulate AI crawler (affects isAIUserAgent())
await page.addInitScript(() => {
Object.defineProperty(navigator, 'userAgent', {
get: () =>
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot',
configurable: true,
});
});
await page.reload();
// Check that built-in banner is not visible for AI UserAgent
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Register a custom cookie banner handler
await page.waitForFunction(() => {
return typeof window !== 'undefined' && window.GitBook !== undefined;
});
await page.evaluate(() => {
window.GitBook?.registerCookieBanner(() => {
// Custom cookie banner handler - no-op for testing to avoid reload
});
});
// Check that built-in banner is not visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
],
},
];
runTestCases(testCases);
+18 -12
View File
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
],
},
{
name: 'Nexthink',
contentBaseURL: 'https://docs.nexthink.com',
tests: [
{
name: 'Home',
url: '/',
screenshot: { waitForTOCScrolling: false },
run: waitForCookiesDialog,
},
],
},
// {
// name: 'Nexthink',
// contentBaseURL: 'https://docs.nexthink.com',
// tests: [
// {
// name: 'Home',
// url: '/',
// screenshot: { waitForTOCScrolling: false },
// run: waitForCookiesDialog,
// },
// ],
// },
{
name: 'asiksupport-stg.dto.kemkes.go.id',
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
@@ -37,6 +37,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://faq.deltaemulator.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dify.ai',
contentBaseURL: 'https://docs.dify.ai',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'seeddao.gitbook.io',
contentBaseURL: 'https://seeddao.gitbook.io',
@@ -232,6 +237,7 @@ const testCases: TestsCase[] = [
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - e2e - all pages', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - e2e - single-page', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
await argosScreenshot(page, 'pdf - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
+8 -32
View File
@@ -2,7 +2,6 @@ import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCodeTheme,
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
@@ -155,19 +154,9 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
export async function setTimeToMorning(page: Page) {
const now = new Date();
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
await page.clock.install({ time: now });
}
export async function waitForCoverImages(page: Page, options?: { darkMode?: boolean }) {
const selector = options?.darkMode
? 'img[alt="Page cover"].dark\\:inline'
: 'img[alt="Page cover"]:not(.dark\\:inline)';
export async function waitForCoverImages(page: Page) {
// Wait for cover images to exist (not the shimmer placeholder)
await expect(page.locator(selector)).toBeVisible({
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
timeout: 10_000,
});
}
@@ -309,16 +298,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
links: CustomizationLinksStyle.Default,
codeTheme: {
default: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
openapi: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
},
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
@@ -376,7 +355,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
socialAccounts: [],
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
@@ -439,15 +417,14 @@ export async function waitForIcons(page: Page) {
return true;
}
const maskImage = icon.querySelector('[data-testid="mask-image"]');
if (!maskImage) {
throw new Error('No mask-image element');
}
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch?.[1];
const url = maskImage.getAttribute('href');
// If URL is invalid we throw an error.
if (!url) {
throw new Error('No mask-image url');
throw new Error('No mask-image');
}
// If the URL is already queued for loading, we return the state.
@@ -477,8 +454,7 @@ export async function waitForIcons(page: Page) {
*/
async function waitForTOCScrolling(page: Page) {
const viewport = await page.viewportSize();
if (viewport && viewport.width >= 1024 && !page.url().includes('~gitbook/embed/demo')) {
// The embed demo is an iframe, which means the viewport is only a fraction of the main document. So there is no open TOC to scroll to.
if (viewport && viewport.width >= 1024) {
const toc = page.getByTestId('table-of-contents');
await expect(toc).toBeVisible();
await page.evaluate(() => {
-3
View File
@@ -4,7 +4,6 @@
* @type {import('next').NextConfig}
*/
const nextConfig = {
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -26,8 +25,6 @@ const nextConfig = {
// 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_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
@@ -8,9 +8,6 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
@@ -8,9 +8,6 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"env": {
"preview": {
"vars": {
@@ -8,9 +8,6 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"assets": {
"directory": "../../.open-next/assets",
"binding": "ASSETS"
@@ -6,13 +6,12 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
// We can set a long TTL for the regional cache, as we invalidate it on update
regionalCacheTtlSec: 24 * 60 * 60, // 1 day
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
// Because we invalidate the Cache API on update, we can safely set this to true
regionalCacheDangerouslyPersistMissingTags: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 2,
numberOfHardReplicas: 1,
regionalReplication: {
defaultRegion: 'enam',
},
+8 -13
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.22.3",
"version": "0.20.0",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -17,7 +17,6 @@
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
@@ -29,6 +28,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2",
"assert-never": "catalog:",
@@ -47,12 +47,10 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.12.3",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "15.4.11",
"next": "15.4.10",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -63,7 +61,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
@@ -71,7 +68,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.21.0",
"shiki": "^3.2.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
@@ -81,12 +78,11 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zod-to-json-schema": "^3.25.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^6.4.2",
"@playwright/test": "^1.58.2",
"@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2",
"@scalar/api-client-react": "catalog:",
"@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6",
@@ -99,7 +95,6 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
@@ -109,7 +104,7 @@
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "^50.15.1",
"vercel": "^39.3.0",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
},
@@ -124,7 +119,7 @@
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
@@ -1,163 +0,0 @@
import type {
ComputedPageResult,
ComputedSectionResult,
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { GitBookBaseContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
import type {
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
Space,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const body = (await request.json()) as SearchSiteContentRequest;
const { query, scope } = body;
if (query.length <= 1) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
(async () => {
const result = await throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: organization,
siteId: site,
query,
scope,
})
);
return result;
})(),
(async () => {
const result = await throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: organization,
siteId: site,
siteShareKey: shareKey,
})
);
return result;
})(),
]);
const results = searchResults
.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
};
return result;
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return resultItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
);
})
.flat(2);
return NextResponse.json(results);
}
function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
const currentLanguage = siteSpace?.space.language;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
},
siteSection && {
icon: siteSection?.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
},
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection?.siteSpaces?.filter(
(s) => s.space.language === space.space.language
).length > 1
).length ?? 0) > 1 && siteSpace
? {
label: getLocalizedTitle(siteSpace, currentLanguage),
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
].filter((item) => item !== undefined),
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? [];
return [page, ...pageSections];
}
@@ -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();
}
@@ -18,14 +18,6 @@ export async function GET(
<html>
<head>
<meta name="color-scheme" content="light dark">
<style type="text/css">
.control {
display: none;
}
.custom-trigger .control {
display: inline-flex;
}
</style>
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -113,29 +105,17 @@ export async function GET(
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script>
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'docs'],
closeButton: useCustomTrigger
});
if (useCustomTrigger) {
document.body.classList.add('custom-trigger');
window.GitBook('hide');
} else {
window.GitBook('open');
}
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
});
window.GitBook('open');
</script>
</html>
`,
@@ -24,22 +24,10 @@ export async function GET(
(function () {
const w = window;
const gb = w.GitBook;
function getScriptSearchParams() {
const script = document.currentScript;
if (!script) return new URLSearchParams();
const url = new URL(script.src);
return url.searchParams;
}
const searchParams = getScriptSearchParams()
const token = searchParams.get('jwt_token');
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
const initFrameOptions = token ? { visitor: { token } } : undefined;
if (typeof gb === "function") {
gb('init', initOptions, initFrameOptions);
gb('init', initOptions);
} else {
var d = document;
@@ -52,7 +40,7 @@ export async function GET(
};
w.GitBook = g;
g('init', initOptions, initFrameOptions);
g('init', initOptions);
const load = function () {
const style = document.createElement('link');
@@ -1,11 +1,7 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
@@ -35,49 +31,18 @@ async function handler(
})
);
// Track the search event server-side
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request: nextRequest,
})
);
return {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
content: results.flatMap((spaceResult) => {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === result.id
(siteSpace) => siteSpace.space.id === spaceResult.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
return spaceResult.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
-5
View File
@@ -83,11 +83,6 @@ export function getPagePathFromParams(params: RouteParams) {
// If decoding the param fails, return a 404 instead of crashing
try {
const decoded = decodeURIComponent(params.pagePath);
// For the root page, we encode '/' to avoid an empty param being passed.
if (decoded === '/') {
return '';
}
return decoded;
} catch (error) {
console.error(
-2
View File
@@ -11,7 +11,6 @@ import {
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
GITBOOK_URL,
GITBOOK_USER_AGENT,
@@ -26,7 +25,6 @@ export async function GET(_req: NextRequest) {
GITBOOK_APP_URL,
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -21,15 +21,16 @@ export async function getSpacePDFContext(
const apiToken = await getAPITokenFromMiddleware();
const basePath = getPDFRoutePath(params);
const linker = createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
});
const dataFetcher = createDataFetcher({
apiToken: apiToken,
});
const baseContext: GitBookBaseContext = {
linker: createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
}),
linker,
dataFetcher,
};
@@ -1,55 +0,0 @@
'use client';
import { Button } from '@/components/primitives';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
export const ConfirmControlOutputSchema = z.object({
result: z.enum(['confirmed', 'cancelled']),
});
export const ConfirmControlDef = createAIControl({
name: 'confirm',
exposeAsTool: false,
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({
icon: z
.string()
.optional()
.describe('Icon to be shown in the confirm button (any Fontawesome icon name).'),
label: z.string().describe('Label to be shown in the confirm button.'),
}),
outputSchema: ConfirmControlOutputSchema,
render: (props) => {
return <ConfirmControl {...props} />;
},
});
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
const { label, icon, onSubmit } = props;
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<Button
data-testid="ai-chat-tool-confirm-cancel"
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
variant="secondary"
icon="xmark"
label="Cancel"
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
className="flex-1"
variant="primary"
icon={icon}
label={label}
/>
</AIToolContainer>
);
}
@@ -1,203 +0,0 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
const OTHER_OPTION_ID = '$other';
export const SingleChoiceControlOutputSchema = z.object({
id: z.string().describe('The identifier of the option selected by the user.'),
input: z
.string()
.optional()
.describe('The custom text entered by the user when "Other" is enabled and selected.'),
});
export const SingleChoiceControlDef = createAIControl({
name: 'single-choice',
exposeAsTool: true,
description:
'Use this control when you need the user to choose exactly one option from a predefined list.',
inputSchema: z.object({
prompt: z
.string()
.describe(
'Provide the question or instruction that tells the user what single choice they need to make.'
),
options: z
.array(
z
.object({
id: z
.string()
.describe(
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
),
label: z
.string()
.describe('Provide the short label the user sees for this option.'),
description: z
.string()
.optional()
.describe(
'Optionally provide supporting details to help the user understand this option.'
),
})
.describe('Define one selectable option the user can pick.')
)
.describe(
'Provide the list of options the user can choose from. The user must select exactly one.'
),
allowOther: z
.boolean()
.optional()
.describe(
'Set to true to let the user select an "Other" option and enter a custom text response.'
),
}),
outputSchema: SingleChoiceControlOutputSchema,
render: (props) => {
return <SingleChoiceControl {...props} />;
},
});
function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControlDef>) {
const { prompt, options, allowOther, onSubmit } = props;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [otherInput, setOtherInput] = React.useState('');
const canSubmit =
selectedId !== null &&
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<div className="no-scrollbar flex flex-1 flex-col gap-1 overflow-auto">
<p className="mb-1 font-semibold text-sm">{prompt}</p>
{options.map((option) => {
const isSelected = selectedId === option.id;
return (
<button
key={option.id}
type="button"
data-testid={`ai-chat-tool-single-choice-option-${option.id}`}
onClick={() => {
setSelectedId(option.id);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
isSelected
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
)}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
isSelected
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
isSelected ? 'bg-primary-original' : 'bg-transparent'
)}
/>
</span>
<span className="min-w-0">
<p className="font-medium text-sm">{option.label}</p>
{option.description ? (
<p className="mt-0.5 text-sm text-tint-subtle">
{option.description}
</p>
) : null}
</span>
</div>
</button>
);
})}
{allowOther ? (
<button
type="button"
data-testid="ai-chat-tool-single-choice-option-other"
onClick={() => {
setSelectedId(OTHER_OPTION_ID);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
)}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
selectedId === OTHER_OPTION_ID
? 'bg-primary-original'
: 'bg-transparent'
)}
/>
</span>
<div className="flex flex-1 flex-col gap-1">
<p className="font-medium text-sm">Other</p>
{allowOther && selectedId === OTHER_OPTION_ID ? (
<Input
label="Enter your answer"
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-single-choice-other-input"
autoFocus
className="w-full"
sizing="small"
/>
) : null}
</div>
</div>
</button>
) : null}
</div>
<Button
data-testid="ai-chat-tool-single-choice-submit"
variant="primary"
label="Submit answer"
disabled={!canSubmit}
onClick={() => {
if (!canSubmit || !selectedId) {
return;
}
if (selectedId === OTHER_OPTION_ID) {
onSubmit({
id: OTHER_OPTION_ID,
input: otherInput.trim(),
});
return;
}
onSubmit({ id: selectedId });
}}
/>
</AIToolContainer>
);
}
@@ -1,15 +0,0 @@
import { tcls } from '@/lib/tailwind';
import type { ComponentPropsWithRef } from 'react';
export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
return (
<div
{...props}
className={tcls(
'min-h-0 animate-present-slow circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-subtle p-2',
props.className
)}
style={{ animationDelay: '0.5s', ...props.style }}
/>
);
}
@@ -1,80 +0,0 @@
import type {
AIStreamResponseToolCallPending,
AIToolCallResult,
AIToolDefinition,
} from '@gitbook/api';
import type { ZodType, z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
type AIUIToolContext = Pick<AIStreamResponseToolCallPending, 'toolCall' | 'toolCallId'>;
type AIControlDefinition<
Name extends string = any,
Input extends Record<string, unknown> = any,
Output extends Record<string, unknown> = any,
> = AIToolDefinition & {
createControl: (args: {
context: AIUIToolContext;
input: Input;
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
}) => AIControl<Name, Input, Output>;
exposeAsTool: boolean;
};
type AIControlProps<Input = Record<string, unknown>, Output = Record<string, unknown>> = Input & {
onSubmit: (output: Output) => Promise<void>;
};
type AIControl<Name = string, Input = Record<string, unknown>, Output = Record<string, unknown>> = {
props: AIControlProps<Input, Output>;
name: Name;
context: AIUIToolContext;
render: () => React.ReactNode;
};
type AIControlFromDef<T extends AIControlDefinition> = ReturnType<T['createControl']>;
export type GetAIControlProps<T extends AIControlDefinition> = AIControlFromDef<T>['props'];
export function createAIControl<
Name extends string,
InputSchema extends ZodType<Record<string, unknown>>,
OutputSchema extends ZodType<Record<string, unknown>>,
>(def: {
name: Name;
description: string;
inputSchema: InputSchema;
outputSchema: OutputSchema;
render: (props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>>) => React.ReactNode;
/**
* Indicates if the control should be exposed as a tool or not.
*/
exposeAsTool: boolean;
}): AIControlDefinition<Name, z.infer<InputSchema>, z.infer<OutputSchema>> {
return {
name: `ui--${def.name}`,
description: def.description,
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
createControl: ({ context, input, send }) => {
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
...input,
onSubmit: async (output) => {
await send({
output,
summary: {
icon: 'check',
text: 'Submitted',
},
});
},
};
return {
props,
name: def.name,
context,
render: () => def.render(props),
};
},
exposeAsTool: def.exposeAsTool,
};
}
@@ -1,11 +0,0 @@
import { ConfirmControlDef } from './ConfirmControl';
import { SingleChoiceControlDef } from './SingleChoiceControl';
const CONTROLS = [SingleChoiceControlDef, ConfirmControlDef];
export type AnyAIControlTool = (typeof CONTROLS)[number];
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
export function getControlTools(): AnyAIControlTool[] {
return CONTROLS.filter((control) => control.exposeAsTool);
}
@@ -1,6 +1,5 @@
import { HighlightQuery } from '@/components/Search/HighlightQuery';
import { Link, StyledLink } from '@/components/primitives';
import { Favicon } from '@/components/utils';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
@@ -16,7 +15,6 @@ import type {
ContentRef,
} from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import type * as React from 'react';
/**
@@ -41,10 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props;
return (
<div
data-testid="ai-chat-tool-summary"
className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle"
>
<div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon
icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -140,37 +135,25 @@ async function DescriptionForSearchToolCall(props: {
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
toolCall.results.map(async (result) => {
switch (result.type) {
case 'page': {
const resolved = await resolveContentRef(
result.anchor
? {
kind: 'anchor',
page: result.pageId,
space: result.spaceId,
anchor: result.anchor,
}
: {
kind: 'page',
page: result.pageId,
space: result.spaceId,
},
context
);
return {
...result,
href: resolved?.href || '#',
};
}
case 'record':
return {
...result,
href: result.url ?? '#',
};
default:
assertNever(result);
}
const resolved = await resolveContentRef(
result.anchor
? {
kind: 'anchor',
page: result.pageId,
space: result.spaceId,
anchor: result.anchor,
}
: {
kind: 'page',
page: result.pageId,
space: result.spaceId,
},
context
);
return {
...result,
href: resolved?.href || '#',
};
})
);
@@ -206,80 +189,45 @@ async function DescriptionForSearchToolCall(props: {
{hasResults ? (
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => {
const resultKey = (() => {
switch (result.type) {
case 'page':
return `${result.spaceId}/${result.pageId}`;
case 'record':
return result.recordId;
default:
assertNever(result);
}
})();
const iconClassName = 'mt-1 size-3 shrink-0 text-tint-subtle';
const icon = <Icon icon="memo" className={iconClassName} />;
return (
<li
key={`${resultKey}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
>
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
insights={
result.type === 'record'
? {
type: 'search_open_result',
query: toolCall.query,
result: {
recordId: result.recordId,
},
}
: {
type: 'search_open_result',
query: toolCall.query,
result: {
spaceId: result.spaceId,
pageId: result.pageId,
},
}
}
>
{result.type === 'record' ? (
<Favicon url={result.href} className={iconClassName} />
) : (
icon
)}
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<Icon
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.title}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.title}
text={result.description}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.description}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
);
})}
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</ol>
</div>
) : null}
@@ -9,7 +9,6 @@ import {
AIModel,
type AIToolCallResult,
type AIToolDefinition,
type SiteInsightsSession,
} from '@gitbook/api';
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
@@ -23,7 +22,6 @@ export async function* streamAIChatResponse({
previousResponseId,
toolCall,
tools,
session,
options,
}: {
message?: string;
@@ -31,7 +29,6 @@ export async function* streamAIChatResponse({
previousResponseId?: string;
toolCall?: AIToolCallResult;
tools?: AIToolDefinition[];
session: SiteInsightsSession;
options?: RenderAIMessageOptions;
}) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
@@ -60,7 +57,6 @@ export async function* streamAIChatResponse({
previousResponseId,
toolCall,
tools,
session,
}
);
@@ -1,8 +0,0 @@
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...integrationTools];
}
+7 -8
View File
@@ -10,14 +10,13 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
import { useSearch } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place
export type AIConfig = {
aiMode: CustomizationAIMode;
suggestions?: string[];
trademark: boolean;
assistantName?: string;
greeting?: {
title: string;
subtitle: string;
@@ -55,10 +54,10 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
const AIContext = React.createContext<AIConfig | null>(null);
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
const { aiMode, trademark, suggestions, greeting, assistantName, children } = props;
const { aiMode, trademark, suggestions, greeting, children } = props;
const value = React.useMemo(
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
[aiMode, trademark, suggestions, greeting, assistantName]
() => ({ aiMode, trademark, suggestions, greeting }),
[aiMode, trademark, suggestions, greeting]
);
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
@@ -85,19 +84,19 @@ export function useAI(): AIContext {
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
const assistants: Assistant[] = [];
if (config.aiMode === CustomizationAIMode.Assistant) {
assistants.push({
id: 'gitbook-assistant',
label: config.assistantName ?? getAIChatName(language, config.trademark),
label: getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-text-lg"
className="size-4"
/>
),
open: (query?: string) => {
@@ -9,15 +9,12 @@ import {
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { getVisitor, useTrackEvent } from '../Insights';
import { getSession } from '../Insights/sessions';
import { useSetSearchState } from '../Search';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { useTrackEvent } from '../Insights';
import { integrationsAssistantTools } from '../Integrations';
import { useSearch } from '../Search';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
export type AIChatMessage = {
@@ -26,6 +23,21 @@ export type AIChatMessage = {
query?: string;
};
export type AIChatPendingTool = {
icon?: IconName;
label: string;
/**
* Confirm the tool call by calling this function.
*/
confirm: () => Promise<void>;
/**
* Tool call result to cancel it.
*/
cancelToolCall: AIToolCallResult;
};
export type AIChatState = {
/**
* If true, the chat is open.
@@ -58,9 +70,9 @@ export type AIChatState = {
followUpSuggestions: string[];
/**
* Control to be displayed to the user.
* Tools that are pending confirmation to be executed.
*/
control: AnyAIControl | null;
pendingTools: AIChatPendingTool[];
/**
* If true, the session is in progress.
@@ -114,7 +126,7 @@ const globalState = zustand.create<AIChatState>(() => {
messages: [],
query: null,
followUpSuggestions: [],
control: null,
pendingTools: [],
loading: false,
error: false,
initialQuery: null,
@@ -151,7 +163,7 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
const language = useLanguage();
// Event listeners storage
@@ -200,7 +212,7 @@ export function AIChatProvider(props: {
return {
...state,
followUpSuggestions: [],
control: null,
pendingTools: [],
loading: true,
error: false,
messages: [
@@ -215,10 +227,10 @@ export function AIChatProvider(props: {
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools();
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
const integrationTools = integrationsAssistantTools.getState().tools;
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
@@ -251,20 +263,15 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools();
const integrationTools = integrationsAssistantTools.getState().tools;
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
},
tools: tools.map((tool) => ({
tools: integrationTools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
@@ -308,84 +315,46 @@ export function AIChatProvider(props: {
break;
}
case 'response_tool_call_pending': {
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
const toolDef = integrationTools.find(
(tool) => tool.name === event.toolCall.tool
);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
const confirmation = toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
pendingTools: [
...state.pendingTools,
{
icon: confirmation.icon,
label: confirmation.label,
cancelToolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
cancelled: 'User did not confirm the tool call',
},
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
confirm: async () => {
await executeToolCall(event);
},
},
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
],
}));
break;
} else {
toolToExecute = event;
}
toolToExecute = event;
break;
}
}
@@ -434,11 +403,7 @@ export function AIChatProvider(props: {
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
const { query, messages, pendingTools } = globalState.getState();
// For first message, update the ask parameter in URL
if (messages.length === 0) {
@@ -483,9 +448,15 @@ export function AIChatProvider(props: {
};
});
streamResponse({ message: input.message });
const pendingTool = pendingTools[0];
streamResponse({
message: input.message,
// If we had a pending tool call, we need to send it as being cancelled
// otherwise the AI will fail to process the message
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
});
},
[setSearchState, trackEvent, streamResponse, language]
[setSearchState, trackEvent, streamResponse]
);
// Clear the conversation and reset ask parameter
@@ -496,7 +467,7 @@ export function AIChatProvider(props: {
messages: [],
query: null,
followUpSuggestions: [],
control: null,
pendingTools: [],
responseId: null,
error: false,
initialQuery: null,
@@ -28,7 +28,6 @@ import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControl } from './AIChatControl';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
@@ -81,18 +80,19 @@ export function AIChat() {
chatController.close();
}
}}
withOverlay={true}
data-testid="ai-chat"
withScrim={true}
className={tcls(
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader className="not-embed:px-4">
<EmbeddableFrameMain>
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{config.assistantName ?? getAIChatName(language, config.trademark)}
{getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
@@ -104,15 +104,15 @@ export function AIChat() {
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody className="not-embed:px-4">
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={config.suggestions}
trademark={config.trademark}
/>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
@@ -143,7 +143,7 @@ export function AIChatDynamicIcon(props: {
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.control
? chat.pendingTools.length > 0
? 'confirm'
: 'done'
: 'default'
@@ -201,13 +201,13 @@ export function AIChatBody(props: {
chat: AIChatState;
welcomeMessage?: string;
suggestions?: string[];
trademark?: boolean;
greeting?: {
title: string;
subtitle: string;
};
}) {
const { chatController, chat, suggestions, greeting, trademark } = props;
const { chatController, chat, suggestions, greeting } = props;
const { trademark } = useAI().config;
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -225,8 +225,8 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4"
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
@@ -248,14 +248,13 @@ export function AIChatBody(props: {
<h5
className="animate-blur-in-slow font-bold text-lg text-tint-strong leading-tight [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.5s' }}
data-testid="ai-chat-greeting-title"
data-testid="ai-chat-time-greeting"
>
{greeting?.title || timeGreeting}
</h5>
<p
className="animate-blur-in-slow text-tint leading-tight [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.6s' }}
data-testid="ai-chat-greeting-subtitle"
>
{greeting?.subtitle ||
t(language, 'ai_chat_assistant_description')}
@@ -274,21 +273,17 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex min-h-0 flex-col gap-2 pb-4">
<div className="flex flex-col gap-2 px-4 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
{chat.control ? (
<AIChatControl control={chat.control} />
) : (
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
)}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
</div>
</>
);
@@ -2,12 +2,9 @@
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button to open/close the AI chat.
*/
@@ -18,15 +15,15 @@ export function AIChatButton(props: {
}) {
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
icon={assistant.icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
iconOnly={!showLabel}
size="medium"
variant="header"
className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
@@ -1,8 +0,0 @@
'use client';
import type { AnyAIControl } from '../AI/controls';
export function AIChatControl(props: { control: AnyAIControl }) {
const { control } = props;
return control.render();
}
@@ -22,6 +22,7 @@ export function AIChatControlButton() {
icon="trash-can"
label={t(language, 'ai_chat_clear_conversation')}
variant="blank"
size="default"
className="animate-blur-in-slow"
/>
) : null;
@@ -55,7 +55,6 @@ export function AIChatInput(props: {
placeholder={tString(language, 'ai_chat_input_placeholder')}
onSubmit={(val) => onSubmit(val as string)}
submitButton={{
size: 'small',
label: tString(language, 'send'),
}}
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
@@ -3,6 +3,7 @@ import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -66,6 +67,7 @@ export function AIChatMessages(props: {
'break-words',
'group/message',
'animate-blur-in-slow',
isLastMessage ? 'basis-full' : '',
message.role === AIMessageRole.User
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong'
@@ -94,13 +96,14 @@ export function AIChatMessages(props: {
!chat.error &&
chat.query &&
chat.responseId &&
chat.control ? (
chat.pendingTools.length === 0 ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
@@ -19,18 +19,13 @@ export default function AIChatSuggestedQuestions(props: {
];
return (
<div
className="flex max-w-full max-w-full flex-col items-start gap-2 self-start"
data-testid="ai-chat-suggested-questions"
>
<div className="flex flex-col items-start gap-2 self-start">
{suggestions.map((question, index) => (
<Button
truncate={false}
data-testid="ai-chat-suggested-question"
key={question}
variant="blank"
size="small"
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 hover:bg-primary-hover"
size="medium"
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 px-3 py-1.5 hover:bg-primary-hover"
style={{
animationDelay: `${1000 + index * 100}ms`,
}}
@@ -0,0 +1,69 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { useHotkeys } from 'react-hotkeys-hook';
import type { AIChatState } from '../AI';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
/**
* Display buttons to confirm tool calls.
*/
export function AIChatToolConfirmations(props: {
chat: AIChatState;
}) {
const { chat } = props;
const language = useLanguage();
useHotkeys(
'mod+enter',
(e) => {
e.preventDefault();
chat.pendingTools[0]?.confirm();
},
{
enableOnFormTags: true,
},
[chat.pendingTools]
);
return (
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
{chat.pendingTools.map((tool, index) => (
<div
className="flex w-full animate-present-slow flex-col gap-1"
key={index}
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
>
<Button
onClick={() => {
tool.confirm();
}}
tabIndex={index}
label={tool.label}
className="w-full justify-center"
size={index === 0 ? 'default' : 'medium'}
variant={index === 0 ? 'primary' : 'secondary'}
icon={tool.icon}
/>
{index === 0 && (
<div
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
style={{ animationDelay: '1000ms' }}
>
{t(
language,
'press_to_confirm',
<KeyboardShortcut
keys={['mod', 'enter']}
className="mx-0 text-tint"
/>
)}
</div>
)}
</div>
))}
</div>
);
}
@@ -36,7 +36,7 @@ export function AIResponseFeedback(props: {
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
)}
size="small"
size="medium"
style={{ animationDelay: '.5s', animationDuration: '.5s' }}
onClick={() => handleRating(1)}
disabled={rating !== null}
@@ -52,7 +52,7 @@ export function AIResponseFeedback(props: {
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
)}
size="small"
size="medium"
style={{ animationDelay: '.7s', animationDuration: '.5s' }}
onClick={() => handleRating(-1)}
disabled={rating !== null}
@@ -17,20 +17,19 @@ export function AIChatFollowupSuggestions(props: {
return (
<div className="flex grow flex-col">
<div
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
data-testid="ai-chat-followup-suggestions"
>
{chat.followUpSuggestions.map((suggestion, index) => (
<Button
truncate={false}
data-testid="ai-chat-followup-suggestion"
key={index}
onClick={() => {
chatController.postMessage({ message: suggestion });
}}
label={suggestion}
className="starting:h-0 max-w-full origin-left animate-blur-in-slow border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 hover:bg-primary-hover"
size="small"
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 px-3 py-1.5 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
size="medium"
variant="blank"
style={{
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
@@ -1,9 +1,10 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig, motion } from 'motion/react';
import { MotionConfig } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitor } from '../Insights';
import { useVisitorSession } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -13,6 +14,7 @@ import {
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSeparator,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
@@ -20,7 +22,6 @@ import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
@@ -40,7 +41,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
onToggleMinify,
});
const visitor = useVisitor();
const visitorSession = useVisitorSession();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
@@ -80,7 +81,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitor?.organizationId === context.organizationId) {
if (visitorSession?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
@@ -131,7 +132,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -140,13 +141,14 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
<DateRelative value={changeRequest.updatedAt} /> by {author}
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -205,11 +207,18 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<ToolbarBody>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Open commit in Git client */}
<ToolbarButton
@@ -271,11 +280,22 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -285,18 +305,18 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open site in GitBook */}
<ToolbarButton
title="View site configuration"
title="Open site in GitBook"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="folder-gear"
icon="gears"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize site"
title="Customize in GitBook"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
@@ -307,7 +327,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights"
title="Open insights in GitBook"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
@@ -341,13 +361,13 @@ function EditPageButton(props: {
return (
<ToolbarButton
title="Edit this page"
title="Edit in GitBook"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pen-to-square"
icon="pencil"
motionValues={motionValues}
/>
);
@@ -8,21 +8,10 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
// Params for the expanding arc defined separately for easier tweaking.
const ARC_PARAMS = {
arcWidth: 505,
arcHeight: 400,
arcRadius: 34,
startDistance: -240,
spreadDistance: 45,
fromDistance: -286,
fromSpread: 0,
durationSeconds: 0.6,
staggerMs: 80,
baseRotationDeg: 95,
rotationStepDeg: 18,
offsetAnchorY: 40,
} as const;
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
@@ -34,56 +23,30 @@ interface HideToolbarButtonProps {
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls();
const closingTimeoutRef = useRef(0);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const closeArcMenu = React.useCallback(() => {
if (!open || closing) return;
setClosing(true);
// Clear any existing timeout
window.clearTimeout(closingTimeoutRef.current);
// Wait for the exit animation to complete before unmounting
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
closingTimeoutRef.current = window.setTimeout(() => {
setOpen(false);
setClosing(false);
}, totalDuration);
}, [open, closing]);
// Clean up timeout on unmount
React.useEffect(() => {
return () => window.clearTimeout(closingTimeoutRef.current);
}, []);
const handleClickOutsideArcMenu = (event: Event) => {
// Don't close the arc if we are clicking on the button itself
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
closeArcMenu();
setOpen(false);
};
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll or resize
// Close arc menu on scroll
React.useEffect(() => {
if (!open) return;
const handleClose = () => closeArcMenu();
window.addEventListener('scroll', handleClose, { passive: true });
window.addEventListener('resize', handleClose, { passive: true });
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleClose);
window.removeEventListener('resize', handleClose);
};
}, [open, closeArcMenu]);
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
const items = [
controls?.minimize
@@ -121,36 +84,18 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
return (
<ToolbarButton
ref={buttonRef}
title={open ? undefined : 'Hide toolbar'}
className={
open || closing
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
: undefined
}
title={open ? 'Hide options' : 'Hide toolbar'}
onClick={() => {
if (open || closing) {
closeArcMenu();
} else {
setOpen(true);
}
setOpen((v) => !v);
}}
motionValues={motionValues}
icon="gear"
icon="eye-slash"
>
{/* Expanding arc menu */}
{(open || closing) && (
{open && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={
{
...sharedMotionStyle,
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
'--start-distance': `${ARC_PARAMS.startDistance}px`,
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
} as React.CSSProperties
}
style={sharedMotionStyle as React.CSSProperties | undefined}
>
<div
className={tcls(
@@ -162,12 +107,11 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={closing ? index : items.length - 1 - index}
key={item.id}
closing={closing}
staggerIndex={items.length - 1 - index}
key={item.icon}
{...item}
onClick={() => {
closeArcMenu();
setOpen(false);
item.onClick?.();
}}
/>
@@ -183,13 +127,13 @@ type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
onClick?: () => void;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
closing?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
@@ -199,36 +143,40 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
closing = false,
label,
disabled,
className,
onClick,
onClick = () => {},
icon,
iconClassName,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = calculateRotation();
return (
<div className="pointer-events-none">
<button
type="button"
onClick={onClick ? () => onClick() : undefined}
onClick={() => {
onClick();
}}
style={
{
'--from-offset-distance': fromOffset,
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: fromOffset,
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
offsetRotate: 'auto 90deg',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
} as React.CSSProperties
}
className={tcls(
@@ -237,43 +185,48 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
styles.arcMenuItem,
className
)}
>
<div className="flex items-center gap-2">
<div
className={tcls(
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls(
'size-3.5 shrink-0 group-hover:scale-110',
iconClassName
)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
)}
>
{label}
</span>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</div>
);
@@ -17,7 +17,7 @@
.arcMenuItem {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-fill-mode: forwards;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
@@ -28,53 +28,13 @@
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
}
to {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
transform: scale(1);
opacity: 1;
}
}
@keyframes hide-toolbar-arc-exit {
from {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
}
to {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
}
.arcMenuItemExit {
animation-name: hide-toolbar-arc-exit;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.3s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
offset-path: border-box;
offset-anchor: 0% 0%;
offset-rotate: auto var(--rotation-offset, 0deg);
}
/* ── Arc label fade ── */
@keyframes arc-label-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes arc-label-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.arcLabelFadeIn {
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
}
.arcLabelFadeOut {
animation: arc-label-fade-out 0.2s ease-out forwards;
}
@@ -9,7 +9,6 @@ import {
import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -20,20 +19,15 @@ import { useMagnificationEffect } from './useMagnificationEffect';
const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
const PILL_STYLE = {
'--toolbar-bg': '#1f1d1b',
borderRadius: '100px', // Set on `style` so Framer Motion can correct for distortions
zIndex: 1, // Ensure pill stacks above the peek label sibling
} as React.CSSProperties;
interface ToolbarProps {
label: React.ReactNode;
children: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, minified, onMinifiedChange } = props;
const { children, label, minified, onMinifiedChange } = props;
const controls = useToolbarControls();
const [isReady, setIsReady] = React.useState(false);
const autoExpandTriggeredRef = React.useRef(false);
@@ -41,10 +35,6 @@ export function Toolbar(props: ToolbarProps) {
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
// Track when the pill's layout animation finishes so the hint label
// only appears once the toolbar is fully expanded.
const [showHint, setShowHint] = React.useState(!minified);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
@@ -92,10 +82,6 @@ export function Toolbar(props: ToolbarProps) {
// Any manual expansion should stop the logo animation so the icon stays in its
// “settled” state once the toolbar is open.
setShouldAnimateLogo(false);
} else {
// Hide the hint immediately when minimizing — it will reappear
// once the next expand animation completes.
setShowHint(false);
}
}, [minified]);
@@ -105,57 +91,47 @@ export function Toolbar(props: ToolbarProps) {
}
return (
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<ToolbarVisibilityHint show={showHint} />
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
>
{/* Logo — double-click to minimize */}
<Tooltip label={label}>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'backdrop-blur-sm',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
)}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
}}
>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
);
}
@@ -222,51 +198,62 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
} = props;
const reduceMotion = useReducedMotion();
const anchor = (
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
</motion.div>
);
});
@@ -313,6 +300,10 @@ function ToolbarButtonWrapper(props: {
});
}
export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />;
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<div className="flex items-center gap-1 text-xs ">
@@ -337,7 +328,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -348,7 +339,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
@@ -1,94 +0,0 @@
'use client';
import React from 'react';
import { useLanguage } from '@/intl/client';
import { AnimatePresence, motion } from 'motion/react';
import { formatDateTime, formatDateWeekday, formatRelative } from '../utils/dates';
type DateFormat = 'relative' | 'weekday' | 'full';
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
/**
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
*/
export function ToolbarDate(props: { value: string }) {
const { value } = props;
const language = useLanguage();
const [formatIndex, setFormatIndex] = React.useState(0);
const [hovered, setHovered] = React.useState(false);
const format = DATE_FORMATS[formatIndex] as DateFormat;
const formatted = React.useMemo(() => {
const date = new Date(value);
switch (format) {
case 'relative':
return formatRelative(language.locale, Date.now() - date.getTime());
case 'weekday':
return formatDateWeekday(date, language.locale);
case 'full':
return formatDateTime(date, language.locale);
}
}, [format, language.locale, value]);
return (
<motion.div
className="inline-flex items-center gap-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Vertical dots indicating cycleable formats */}
<AnimatePresence>
{hovered && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="overflow-hidden"
>
<div className="flex flex-col items-center gap-[1px]">
{DATE_FORMATS.map((fmt, i) => (
<motion.span
key={fmt}
animate={{
opacity: i === formatIndex ? 1 : 0.3,
scale: i === formatIndex ? 1 : 0.75,
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="block size-[3px] rounded-full bg-current"
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<motion.time
layout="position"
data-visual-test="transparent"
suppressHydrationWarning={true}
dateTime={value}
onClick={(e) => {
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={formatIndex}
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
{formatted}
</motion.span>
</AnimatePresence>
</motion.time>
</motion.div>
);
}
@@ -1,51 +0,0 @@
'use client';
import { motion } from 'motion/react';
import React from 'react';
import { getVisibilityHintDismissed, setVisibilityHintDismissed } from './utils';
interface ToolbarVisibilityHintProps {
show: boolean;
}
export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
const { show } = props;
const [dismissed, setDismissed] = React.useState(() =>
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
);
if (dismissed) {
return null;
}
return (
<motion.div
initial={false}
animate={{
y: show ? 0 : 10,
opacity: show ? 1 : 0,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
This toolbar appears only when signed in to GitBook.
</span>
<button
type="button"
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
onClick={(e) => {
e.stopPropagation();
setVisibilityHintDismissed();
setDismissed(true);
}}
>
Dismiss
</button>
</motion.div>
);
}
@@ -11,7 +11,6 @@ import {
const STORAGE_KEY = 'gitbook_toolbar_closed';
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
type SessionHideReason = 'session' | 'persistent';
@@ -58,20 +57,6 @@ export const setStoredMinified = (value: boolean) => {
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
};
/**
* Check whether the user has dismissed the "only you can see this" hint.
*/
export const getVisibilityHintDismissed = (): boolean => {
return getSessionStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
};
/**
* Persist that the user dismissed the visibility hint for the current session.
*/
export const setVisibilityHintDismissed = () => {
setSessionStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
};
interface UseToolbarVisibilityOptions {
onPersistentClose?: () => void;
onSessionClose?: () => void;
@@ -77,8 +77,7 @@ export async function AdCoverRendering({
'shadow-md',
'max-h-32',
'group-hover/ad:max-h-16',
'transition-all',
'motion-reduce:transition-none'
'transition-all'
)}
/>
</div>
@@ -94,8 +93,7 @@ export async function AdCoverRendering({
'opacity-0',
'group-hover/ad:h-16',
'group-hover/ad:opacity-10',
'transition-all',
'motion-reduce:transition-none'
'transition-all'
)}
>
{ad.description}
@@ -30,71 +30,73 @@ export function AnnouncementBanner(props: {
return (
<div
data-gb-announcement-banner
id="announcement-banner"
className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet=""
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<Button
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="small"
onClick={dismissAnnouncement}
className={`absolute top-0 right-4 mt-1.5 mr-1.5 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
/>
) : null}
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls(CONTAINER_STYLE)}>
<div className="relative">
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<Button
iconOnly
icon="close"
label={tString(language, 'close')}
variant="blank"
size="default"
onClick={dismissAnnouncement}
className={`absolute top-0 right-0 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 ${style.close}`}
/>
) : null}
</div>
</div>
</div>
</div>
@@ -6,13 +6,7 @@ import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useCustomCookieBanner, useIntegrationsLoaded } from '@/components/Integrations';
import { isAIUserAgent } from '@/lib/browser';
import {
isCookiesTrackingDisabled,
isGlobalPrivacyControlEnabled,
setCookiesTracking,
} from '../Insights';
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
/**
* Toast to accept or reject the use of cookies.
@@ -21,26 +15,10 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
const { privacyPolicy = 'https://policies.gitbook.com/privacy/cookies' } = props;
const [show, setShow] = React.useState(false);
const language = useLanguage();
const integrationsLoaded = useIntegrationsLoaded();
const { hasCustomCookieBanner } = useCustomCookieBanner();
const isAI = isAIUserAgent();
const hasGlobalPrivacyControl = isGlobalPrivacyControlEnabled();
React.useEffect(() => {
// If global privacy control is enabled, reject cookies
if (hasGlobalPrivacyControl && !isCookiesTrackingDisabled()) {
setCookiesTracking(false);
return;
}
// Always wait for integrations to load, and if a custom banner is registered, hide the built-in banner
if (!integrationsLoaded || hasCustomCookieBanner || isAI) {
setShow(false);
return;
}
setShow(isCookiesTrackingDisabled() === undefined);
}, [hasCustomCookieBanner, integrationsLoaded, isAI, hasGlobalPrivacyControl]);
}, []);
if (!show) {
return null;
@@ -83,12 +61,10 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'lg:chat-open:mr-80',
'xl:chat-open:mr-100',
'transition-all',
'motion-reduce:transition-none',
'duration-300',
'text-sm'
'duration-300'
)}
>
<p id={describedById}>
<p id={describedById} className={tcls('text-sm')}>
{t(
language,
'cookies_prompt',
@@ -100,13 +76,12 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
<Button
iconOnly
icon="close"
size="small"
label={tString(language, 'close')}
variant="blank"
onClick={() => setShow(false)}
className={tcls('absolute', 'top-2', 'right-2', 'hover:bg-tint-hover')}
/>
<div className="mt-3 flex flex-row gap-2">
<div className={tcls('mt-3', 'flex', 'flex-row', 'gap-2')}>
<Button
variant="primary"
size="small"

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