Skip to content

Commit 51bd768

Browse files
authored
Make the AI Assistant panel resizable (#4330)
1 parent daadd91 commit 51bd768

49 files changed

Lines changed: 295 additions & 8 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/gitbook/src/components/AIChat/AIChat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ import { ScrollContainer } from '../primitives/ScrollContainer';
3131
import { SideSheet } from '../primitives/SideSheet';
3232
import { AIChatControl } from './AIChatControl';
3333
import { AIChatControlButton } from './AIChatControlButton';
34+
import { AIChatExpandButton } from './AIChatExpandButton';
3435
import { AIChatIcon } from './AIChatIcon';
3536
import { AIChatInput } from './AIChatInput';
3637
import { AIChatMessages } from './AIChatMessages';
38+
import { AIChatResizeHandle } from './AIChatResizeHandle';
3739
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
3840

3941
export function AIChat() {
@@ -85,9 +87,10 @@ export function AIChat() {
8587
withOverlay={true}
8688
data-ai-chat
8789
className={tcls(
88-
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
90+
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:w-(--ai-chat-width)'
8991
)}
9092
>
93+
<AIChatResizeHandle />
9194
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
9295
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
9396
<EmbeddableFrameHeader className="not-embed:px-4">
@@ -100,6 +103,7 @@ export function AIChat() {
100103
</EmbeddableFrameHeaderMain>
101104
<EmbeddableFrameButtons>
102105
<AIChatControlButton />
106+
<AIChatExpandButton />
103107
<Button
104108
onClick={() => chatController.close()}
105109
iconOnly
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client';
2+
3+
import { tString, useLanguage } from '@/intl/client';
4+
import { Icon } from '@gitbook/icons';
5+
import { Button } from '../primitives';
6+
import { useAIChatWidthStore, useIsAIChatMaxWidth } from './useAIChatWidthStore';
7+
8+
export function AIChatExpandButton() {
9+
const language = useLanguage();
10+
const toggleWidth = useAIChatWidthStore((state) => state.toggleWidth);
11+
const isMaxWidth = useIsAIChatMaxWidth();
12+
13+
return (
14+
<Button
15+
onClick={toggleWidth}
16+
iconOnly
17+
icon={
18+
<Icon
19+
icon={
20+
isMaxWidth
21+
? 'arrow-down-left-and-arrow-up-right-to-center'
22+
: 'arrow-up-right-and-arrow-down-left-from-center'
23+
}
24+
className="scale-90"
25+
/>
26+
}
27+
label={tString(
28+
language,
29+
isMaxWidth ? 'ai_chat_collapse_panel' : 'ai_chat_expand_panel'
30+
)}
31+
variant="blank"
32+
className="max-lg:hidden"
33+
/>
34+
);
35+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use client';
2+
3+
import { tcls } from '@/lib/tailwind';
4+
import React from 'react';
5+
import { useAIChatWidthStore } from './useAIChatWidthStore';
6+
7+
function setResizing(active: boolean) {
8+
document.documentElement.dataset.aiChatResizing = String(active);
9+
}
10+
11+
export function AIChatResizeHandle() {
12+
const setWidth = useAIChatWidthStore((state) => state.setWidth);
13+
const frameRef = React.useRef<number | null>(null);
14+
const widthRef = React.useRef(0);
15+
16+
React.useEffect(() => {
17+
const onResize = () => useAIChatWidthStore.getState().syncWidth();
18+
window.addEventListener('resize', onResize);
19+
return () => {
20+
window.removeEventListener('resize', onResize);
21+
if (frameRef.current !== null) {
22+
cancelAnimationFrame(frameRef.current);
23+
}
24+
setResizing(false);
25+
};
26+
}, []);
27+
28+
const stopResizing = (event: React.PointerEvent<HTMLDivElement>) => {
29+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
30+
event.currentTarget.releasePointerCapture(event.pointerId);
31+
}
32+
if (frameRef.current !== null) {
33+
cancelAnimationFrame(frameRef.current);
34+
frameRef.current = null;
35+
}
36+
setResizing(false);
37+
};
38+
39+
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
40+
event.preventDefault();
41+
event.currentTarget.setPointerCapture(event.pointerId);
42+
widthRef.current = useAIChatWidthStore.getState().width;
43+
setResizing(true);
44+
};
45+
46+
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
47+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
48+
return;
49+
}
50+
// Panel is right-anchored, so its width is the distance from the cursor to the right edge.
51+
widthRef.current = window.innerWidth - event.clientX;
52+
if (frameRef.current === null) {
53+
frameRef.current = requestAnimationFrame(() => {
54+
frameRef.current = null;
55+
widthRef.current = setWidth(widthRef.current);
56+
});
57+
}
58+
};
59+
60+
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
61+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
62+
return;
63+
}
64+
setWidth(widthRef.current);
65+
stopResizing(event);
66+
};
67+
68+
return (
69+
<div
70+
aria-hidden="true"
71+
onPointerDown={handlePointerDown}
72+
onPointerMove={handlePointerMove}
73+
onPointerUp={handlePointerUp}
74+
onPointerCancel={stopResizing}
75+
className={tcls(
76+
'group -translate-x-1/2 absolute inset-y-0 left-0 z-10 hidden w-3 cursor-col-resize touch-none lg:flex',
77+
'items-stretch justify-center'
78+
)}
79+
>
80+
<span className="h-full w-px rounded-full bg-transparent transition-all duration-150 ease-out group-hover:w-0.5 group-hover:bg-primary-solid/40 group-active:w-0.5 group-active:bg-primary-solid" />
81+
</div>
82+
);
83+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use client';
2+
3+
import {
4+
getLocalStorageItem,
5+
removeLocalStorageItem,
6+
setLocalStorageItem,
7+
} from '@/lib/browser/local-storage';
8+
import { create } from 'zustand';
9+
import { type StorageValue, persist } from 'zustand/middleware';
10+
11+
const AI_CHAT_MIN_WIDTH = 384;
12+
const AI_CHAT_MAX_WIDTH = 640;
13+
const MIN_CONTENT_WIDTH = 720;
14+
15+
type AIChatWidthStore = {
16+
width: number;
17+
toggleWidth: () => void;
18+
setWidth: (width: number) => number;
19+
syncWidth: () => void;
20+
};
21+
22+
type PersistedState = Pick<AIChatWidthStore, 'width'>;
23+
24+
export const useAIChatWidthStore = create<AIChatWidthStore>()(
25+
persist(
26+
(set, get) => ({
27+
width: AI_CHAT_MIN_WIDTH,
28+
toggleWidth: () =>
29+
get().setWidth(
30+
get().width >= AI_CHAT_MAX_WIDTH ? AI_CHAT_MIN_WIDTH : AI_CHAT_MAX_WIDTH
31+
),
32+
setWidth: (width) => {
33+
const clamped = clampWidth(width);
34+
if (get().width !== clamped) {
35+
set({ width: clamped });
36+
}
37+
setWidthOnViewport(clamped);
38+
return clamped;
39+
},
40+
syncWidth: () => setWidthOnViewport(get().width),
41+
}),
42+
{
43+
name: '@gitbook/ai-chat-width',
44+
storage: {
45+
getItem: (name) =>
46+
getLocalStorageItem<StorageValue<PersistedState> | null>(name, null),
47+
setItem: (name, value) => setLocalStorageItem(name, value),
48+
removeItem: (name) => removeLocalStorageItem(name),
49+
},
50+
partialize: (state) => ({ width: state.width }),
51+
onRehydrateStorage: () => (state) => state?.syncWidth(),
52+
}
53+
)
54+
);
55+
56+
/**
57+
* Whether the panel is at its maximum width.
58+
*/
59+
export const useIsAIChatMaxWidth = () =>
60+
useAIChatWidthStore((state) => state.width >= AI_CHAT_MAX_WIDTH);
61+
62+
// Hoisted so the synchronous persist rehydrate (during create() above) can call them before this point.
63+
function setWidthOnViewport(width: number) {
64+
if (typeof document !== 'undefined') {
65+
document.documentElement.style.setProperty('--ai-chat-width', `${capToViewport(width)}px`);
66+
}
67+
}
68+
69+
function clampWidth(width: number) {
70+
return Math.min(AI_CHAT_MAX_WIDTH, Math.max(AI_CHAT_MIN_WIDTH, Math.round(width)));
71+
}
72+
73+
// Cap a width so the remaining content keeps a usable minimum at the current viewport.
74+
function capToViewport(width: number) {
75+
return typeof window === 'undefined'
76+
? width
77+
: Math.min(width, Math.max(AI_CHAT_MIN_WIDTH, window.innerWidth - MIN_CONTENT_WIDTH));
78+
}

packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function AnnouncementBanner(props: {
4242
className="theme-bold:bg-header-background pt-4 pb-2"
4343
data-nosnippet=""
4444
>
45-
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
45+
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
4646
<div className={tcls('relative', CONTAINER_STYLE)}>
4747
<Tag
4848
href={contentRef?.href ?? ''}

packages/gitbook/src/components/Cookies/CookiesToast.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
8080
'max-w-md',
8181
'text-balance',
8282
'sm:left-auto',
83-
'lg:chat-open:mr-80',
84-
'xl:chat-open:mr-100',
83+
'lg:chat-open:mr-(--ai-chat-width)',
8584
'transition-all',
8685
'motion-reduce:transition-none',
8786
'duration-300',

packages/gitbook/src/components/Footer/Footer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
3535
mobileOnly ? 'xl:hidden' : null
3636
)}
3737
>
38-
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
38+
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
3939
<div
4040
className={tcls(
4141
CONTAINER_STYLE,

packages/gitbook/src/components/Header/Header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export async function Header(props: {
7575
'site-header:theme-bold:shadow-tint-12/2'
7676
)}
7777
>
78-
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
78+
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
7979
<div
8080
data-gb-header-content
8181
className={tcls(
@@ -206,7 +206,7 @@ export async function Header(props: {
206206
</div>
207207

208208
{visibleSections && withSections ? (
209-
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
209+
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
210210
<SiteSectionTabs sections={encodeClientSiteSections(context, visibleSections)}>
211211
{variants.translations.length > 1 ? (
212212
<TranslationsDropdown

packages/gitbook/src/components/RootLayout/globals.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@
211211
@apply leading-relaxed;
212212
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
213213
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
214+
--ai-chat-width: 24rem; /* Default AI chat panel width (= AI_CHAT_DEFAULT_WIDTH 384px); overridden client-side from local storage. */
214215
}
215216

216217
/* Modern browsers with `scrollbar-*` support */
@@ -305,6 +306,15 @@ html.dark {
305306
color-scheme: dark light;
306307
}
307308

309+
/** While the AI chat panel is being resized, suppress transitions */
310+
html[data-ai-chat-resizing="true"] {
311+
cursor: col-resize;
312+
user-select: none;
313+
}
314+
html[data-ai-chat-resizing="true"] * {
315+
transition-duration: 0s !important;
316+
}
317+
308318
html.announcement-hidden [data-gb-announcement-banner] {
309319
@apply hidden;
310320
}

packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
135135
) : null}
136136

137137
{/* Chat panel shifts content left when open */}
138-
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
138+
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-(--ai-chat-width)">
139139
<div
140140
className={tcls(
141141
'flex',

0 commit comments

Comments
 (0)