diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index b2214d5069..dbc6cfa3c0 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -31,9 +31,11 @@ import { ScrollContainer } from '../primitives/ScrollContainer'; import { SideSheet } from '../primitives/SideSheet'; import { AIChatControl } from './AIChatControl'; import { AIChatControlButton } from './AIChatControlButton'; +import { AIChatExpandButton } from './AIChatExpandButton'; import { AIChatIcon } from './AIChatIcon'; import { AIChatInput } from './AIChatInput'; import { AIChatMessages } from './AIChatMessages'; +import { AIChatResizeHandle } from './AIChatResizeHandle'; import AIChatSuggestedQuestions from './AIChatSuggestedQuestions'; export function AIChat() { @@ -85,9 +87,10 @@ export function AIChat() { withOverlay={true} data-ai-chat className={tcls( - 'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80' + 'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:w-(--ai-chat-width)' )} > + @@ -100,6 +103,7 @@ export function AIChat() { + chatController.close()} iconOnly diff --git a/packages/gitbook/src/components/AIChat/AIChatExpandButton.tsx b/packages/gitbook/src/components/AIChat/AIChatExpandButton.tsx new file mode 100644 index 0000000000..11d12c7da6 --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AIChatExpandButton.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { tString, useLanguage } from '@/intl/client'; +import { Icon } from '@gitbook/icons'; +import { Button } from '../primitives'; +import { useAIChatWidthStore, useIsAIChatMaxWidth } from './useAIChatWidthStore'; + +export function AIChatExpandButton() { + const language = useLanguage(); + const toggleWidth = useAIChatWidthStore((state) => state.toggleWidth); + const isMaxWidth = useIsAIChatMaxWidth(); + + return ( + + } + label={tString( + language, + isMaxWidth ? 'ai_chat_collapse_panel' : 'ai_chat_expand_panel' + )} + variant="blank" + className="max-lg:hidden" + /> + ); +} diff --git a/packages/gitbook/src/components/AIChat/AIChatResizeHandle.tsx b/packages/gitbook/src/components/AIChat/AIChatResizeHandle.tsx new file mode 100644 index 0000000000..6c61faf31e --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AIChatResizeHandle.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { tcls } from '@/lib/tailwind'; +import React from 'react'; +import { useAIChatWidthStore } from './useAIChatWidthStore'; + +function setResizing(active: boolean) { + document.documentElement.dataset.aiChatResizing = String(active); +} + +export function AIChatResizeHandle() { + const setWidth = useAIChatWidthStore((state) => state.setWidth); + const frameRef = React.useRef(null); + const widthRef = React.useRef(0); + + React.useEffect(() => { + const onResize = () => useAIChatWidthStore.getState().syncWidth(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + } + setResizing(false); + }; + }, []); + + const stopResizing = (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + setResizing(false); + }; + + const handlePointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + widthRef.current = useAIChatWidthStore.getState().width; + setResizing(true); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) { + return; + } + // Panel is right-anchored, so its width is the distance from the cursor to the right edge. + widthRef.current = window.innerWidth - event.clientX; + if (frameRef.current === null) { + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + widthRef.current = setWidth(widthRef.current); + }); + } + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) { + return; + } + setWidth(widthRef.current); + stopResizing(event); + }; + + return ( + + + + ); +} diff --git a/packages/gitbook/src/components/AIChat/useAIChatWidthStore.ts b/packages/gitbook/src/components/AIChat/useAIChatWidthStore.ts new file mode 100644 index 0000000000..79afebf24e --- /dev/null +++ b/packages/gitbook/src/components/AIChat/useAIChatWidthStore.ts @@ -0,0 +1,78 @@ +'use client'; + +import { + getLocalStorageItem, + removeLocalStorageItem, + setLocalStorageItem, +} from '@/lib/browser/local-storage'; +import { create } from 'zustand'; +import { type StorageValue, persist } from 'zustand/middleware'; + +const AI_CHAT_MIN_WIDTH = 384; +const AI_CHAT_MAX_WIDTH = 640; +const MIN_CONTENT_WIDTH = 720; + +type AIChatWidthStore = { + width: number; + toggleWidth: () => void; + setWidth: (width: number) => number; + syncWidth: () => void; +}; + +type PersistedState = Pick; + +export const useAIChatWidthStore = create()( + persist( + (set, get) => ({ + width: AI_CHAT_MIN_WIDTH, + toggleWidth: () => + get().setWidth( + get().width >= AI_CHAT_MAX_WIDTH ? AI_CHAT_MIN_WIDTH : AI_CHAT_MAX_WIDTH + ), + setWidth: (width) => { + const clamped = clampWidth(width); + if (get().width !== clamped) { + set({ width: clamped }); + } + setWidthOnViewport(clamped); + return clamped; + }, + syncWidth: () => setWidthOnViewport(get().width), + }), + { + name: '@gitbook/ai-chat-width', + storage: { + getItem: (name) => + getLocalStorageItem | null>(name, null), + setItem: (name, value) => setLocalStorageItem(name, value), + removeItem: (name) => removeLocalStorageItem(name), + }, + partialize: (state) => ({ width: state.width }), + onRehydrateStorage: () => (state) => state?.syncWidth(), + } + ) +); + +/** + * Whether the panel is at its maximum width. + */ +export const useIsAIChatMaxWidth = () => + useAIChatWidthStore((state) => state.width >= AI_CHAT_MAX_WIDTH); + +// Hoisted so the synchronous persist rehydrate (during create() above) can call them before this point. +function setWidthOnViewport(width: number) { + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--ai-chat-width', `${capToViewport(width)}px`); + } +} + +function clampWidth(width: number) { + return Math.min(AI_CHAT_MAX_WIDTH, Math.max(AI_CHAT_MIN_WIDTH, Math.round(width))); +} + +// Cap a width so the remaining content keeps a usable minimum at the current viewport. +function capToViewport(width: number) { + return typeof window === 'undefined' + ? width + : Math.min(width, Math.max(AI_CHAT_MIN_WIDTH, window.innerWidth - MIN_CONTENT_WIDTH)); +} diff --git a/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx index 08b4971c0c..59c1e6f50c 100644 --- a/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx +++ b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx @@ -42,7 +42,7 @@ export function AnnouncementBanner(props: { className="theme-bold:bg-header-background pt-4 pb-2" data-nosnippet="" > - + - + - + {visibleSections && withSections ? ( - + {variants.translations.length > 1 ? ( +