diff --git a/cypress/e2e/copilot/spec.cy.ts b/cypress/e2e/copilot/spec.cy.ts index 6852c125a4..53416913bb 100644 --- a/cypress/e2e/copilot/spec.cy.ts +++ b/cypress/e2e/copilot/spec.cy.ts @@ -1,11 +1,11 @@ import { - copilotShouldBeOpen, clearCopilotThreadId, + copilotShouldBeOpen, getCopilotThreadId, loadCopilotScript, mountCopilotWidget, openCopilot, - submitMessage, + submitMessage } from '../../support/testUtils'; describe('Copilot', { includeShadowDom: true }, () => { @@ -148,11 +148,7 @@ describe('Copilot', { includeShadowDom: true }, () => { openCopilot(); cy.step('Check input placeholder'); - cy.get('#chat-input').should( - 'have.attr', - 'placeholder', - placeholder - ); + cy.get('#chat-input').should('have.attr', 'placeholder', placeholder); }); }); }); @@ -164,4 +160,152 @@ describe('Copilot', { includeShadowDom: true }, () => { copilotShouldBeOpen(); }); + + describe('Sidebar mode', () => { + beforeEach(() => { + cy.window().then((win) => { + win.localStorage.removeItem('chainlit-copilot-displayMode'); + win.localStorage.removeItem('chainlit-copilot-sidebarWidth'); + }); + }); + + it('should open as sidebar and push body content', () => { + mountCopilotWidget({ displayMode: 'sidebar' }); + + cy.step('Open sidebar'); + cy.get('#chainlit-copilot-button').click(); + + cy.get('#chainlit-copilot-chat').should('exist'); + cy.document().then((doc) => { + expect(doc.body.style.marginRight).to.equal('400px'); + }); + }); + + it('should close sidebar and restore body margin', () => { + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.get('#chainlit-copilot-chat').should('exist'); + + cy.step('Close sidebar via close button'); + cy.get('#close-sidebar-button').click(); + + cy.get('#chainlit-copilot-chat').should('not.exist'); + cy.document().then((doc) => { + expect(doc.body.style.marginRight).to.not.equal('400px'); + }); + }); + + it('should resize sidebar via drag handle', () => { + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.get('#chainlit-copilot-chat').should('exist'); + + cy.step('Get initial sidebar width'); + cy.get('#chainlit-copilot-chat') + .parents('div.fixed') + .first() + .invoke('width') + .then((initialWidth) => { + expect(initialWidth).to.be.closeTo(400, 5); + + cy.step('Drag handle to resize'); + cy.get('[data-testid="sidebar-drag-handle"]').then(($handle) => { + const handleRect = $handle[0].getBoundingClientRect(); + const startX = handleRect.left + handleRect.width / 2; + const startY = handleRect.top + handleRect.height / 2; + const targetX = startX - 200; + + cy.wrap($handle) + .trigger('mousedown', { clientX: startX, clientY: startY }) + .then(() => { + cy.document().trigger('mousemove', { + clientX: targetX, + clientY: startY + }); + cy.document().trigger('mouseup'); + }); + }); + + cy.step('Verify sidebar width changed'); + cy.get('#chainlit-copilot-chat') + .parents('div.fixed') + .first() + .invoke('width') + .should('be.greaterThan', initialWidth); + + cy.step('Verify body margin matches new width'); + cy.get('#chainlit-copilot-chat') + .parents('div.fixed') + .first() + .invoke('width') + .then((newWidth) => { + cy.document().then((doc) => { + const margin = parseFloat(doc.body.style.marginRight); + expect(margin).to.be.closeTo(newWidth, 2); + }); + }); + }); + }); + + it('should switch from sidebar to floating mode', () => { + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.get('#chainlit-copilot-chat').should('exist'); + + cy.step('Switch to floating mode via dropdown'); + cy.get('#display-mode-button').click(); + + cy.contains('[role="menuitemradio"]', 'Floating').click(); + + cy.document().then((doc) => { + expect(doc.body.style.marginRight).to.not.equal('400px'); + }); + }); + + it('should restore body margin on widget unmount', () => { + cy.step('Set a custom body margin before mounting'); + cy.document().then((doc) => { + doc.body.style.marginRight = '20px'; + }); + + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.get('#chainlit-copilot-chat').should('exist'); + cy.document().then((doc) => { + expect(doc.body.style.marginRight).to.equal('400px'); + }); + + cy.step('Unmount widget and verify margin is restored'); + cy.window().then((win) => { + // @ts-expect-error is not a valid prop + win.unmountChainlitWidget(); + }); + + cy.document().then((doc) => { + expect(doc.body.style.marginRight).to.equal('20px'); + }); + }); + + it('should persist sidebar width across remounts', () => { + cy.step('Pre-set a custom width in localStorage'); + cy.window().then((win) => { + win.localStorage.setItem('chainlit-copilot-sidebarWidth', '500'); + }); + + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.step('Verify sidebar uses the persisted width'); + cy.get('#chainlit-copilot-chat') + .parents('div.fixed') + .first() + .invoke('width') + .should('be.closeTo', 500, 5); + + cy.step('Verify body margin matches persisted width'); + cy.document().then((doc) => { + const margin = parseFloat(doc.body.style.marginRight); + expect(margin).to.be.closeTo(500, 2); + }); + }); + }); }); diff --git a/libs/copilot/src/components/Header.tsx b/libs/copilot/src/components/Header.tsx index 1981e227c6..81b65c6b37 100644 --- a/libs/copilot/src/components/Header.tsx +++ b/libs/copilot/src/components/Header.tsx @@ -1,13 +1,21 @@ -import { Maximize, Minimize } from 'lucide-react'; +import { ChevronsRight, Maximize, Minimize, PanelRight } from 'lucide-react'; import AudioPresence from '@chainlit/app/src/components/AudioPresence'; import { Logo } from '@chainlit/app/src/components/Logo'; import ChatProfiles from '@chainlit/app/src/components/header/ChatProfiles'; import NewChatButton from '@chainlit/app/src/components/header/NewChat'; import { Button } from '@chainlit/app/src/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger +} from '@chainlit/app/src/components/ui/dropdown-menu'; import { IChainlitConfig, useAudio } from '@chainlit/react-client'; import { useCopilotInteract } from '../hooks'; +import { DisplayMode } from '../types'; interface IProjectConfig { config?: IChainlitConfig; @@ -20,12 +28,18 @@ interface Props { expanded: boolean; setExpanded: (expanded: boolean) => void; projectConfig: IProjectConfig; + displayMode?: DisplayMode; + setDisplayMode?: (mode: DisplayMode) => void; + setIsOpen?: (open: boolean) => void; } const Header = ({ expanded, setExpanded, - projectConfig + projectConfig, + displayMode, + setDisplayMode, + setIsOpen }: Props): JSX.Element => { const { config } = projectConfig; const { audioConnection } = useAudio(); @@ -52,17 +66,53 @@ const Header = ({ className="text-muted-foreground mt-[1.5px]" onConfirm={startNewChat} /> - + {setDisplayMode && ( + + + + + + setDisplayMode(v as DisplayMode)} + > + + Floating + + + Sidebar + + + + + )} + {displayMode === 'sidebar' && setIsOpen ? ( + + ) : ( + + )} ); diff --git a/libs/copilot/src/hooks/index.ts b/libs/copilot/src/hooks/index.ts index 9bb7ff72ce..8edb3cc493 100644 --- a/libs/copilot/src/hooks/index.ts +++ b/libs/copilot/src/hooks/index.ts @@ -1 +1,2 @@ export { useCopilotInteract } from './useCopilotInteract'; +export { useSidebarResize } from './useSidebarResize'; diff --git a/libs/copilot/src/hooks/useSidebarResize.ts b/libs/copilot/src/hooks/useSidebarResize.ts new file mode 100644 index 0000000000..c5e45c2fd0 --- /dev/null +++ b/libs/copilot/src/hooks/useSidebarResize.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { DisplayMode } from '../types'; + +const SIDEBAR_MIN_WIDTH = 300; +const SIDEBAR_DEFAULT_WIDTH = 400; +const SIDEBAR_MAX_WIDTH_RATIO = 0.5; +const LS_WIDTH_KEY = 'chainlit-copilot-sidebarWidth'; + +interface UseSidebarResizeOptions { + displayMode: DisplayMode; + isOpen: boolean; +} + +interface UseSidebarResizeReturn { + sidebarWidth: number; + handleMouseDown: () => void; +} + +export function useSidebarResize({ + displayMode, + isOpen +}: UseSidebarResizeOptions): UseSidebarResizeReturn { + const [sidebarWidth, setSidebarWidth] = useState(() => { + const stored = localStorage.getItem(LS_WIDTH_KEY); + return stored ? Number(stored) : SIDEBAR_DEFAULT_WIDTH; + }); + const isDragging = useRef(false); + const originalMarginRef = useRef(''); + + useEffect(() => { + if (displayMode === 'sidebar') { + localStorage.setItem(LS_WIDTH_KEY, String(sidebarWidth)); + } + }, [sidebarWidth, displayMode]); + + const stopDragging = useCallback(() => { + if (!isDragging.current) return; + isDragging.current = false; + document.body.style.userSelect = ''; + document.body.style.transition = 'margin-right 0.3s ease-in-out'; + }, []); + + const handleMouseDown = useCallback(() => { + isDragging.current = true; + document.body.style.userSelect = 'none'; + document.body.style.transition = ''; + }, []); + + useEffect(() => { + if (displayMode !== 'sidebar' || !isOpen) return; + + function onMouseMove(e: MouseEvent): void { + if (!isDragging.current) return; + const maxWidth = window.innerWidth * SIDEBAR_MAX_WIDTH_RATIO; + const newWidth = Math.min( + maxWidth, + Math.max(SIDEBAR_MIN_WIDTH, window.innerWidth - e.clientX) + ); + setSidebarWidth(newWidth); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', stopDragging); + window.addEventListener('blur', stopDragging); + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', stopDragging); + window.removeEventListener('blur', stopDragging); + if (isDragging.current) { + isDragging.current = false; + document.body.style.userSelect = ''; + } + }; + }, [stopDragging, displayMode, isOpen]); + + useEffect(() => { + if (displayMode === 'sidebar' && isOpen) { + originalMarginRef.current = document.body.style.marginRight; + document.body.style.transition = 'margin-right 0.3s ease-in-out'; + return () => { + document.body.style.marginRight = originalMarginRef.current; + document.body.style.transition = ''; + }; + } + }, [displayMode, isOpen]); + + useEffect(() => { + if (displayMode === 'sidebar' && isOpen) { + document.body.style.marginRight = `${sidebarWidth}px`; + } + }, [sidebarWidth, displayMode, isOpen]); + + return { sidebarWidth, handleMouseDown }; +} diff --git a/libs/copilot/src/types.ts b/libs/copilot/src/types.ts index aa4834b845..b71f9a44a0 100644 --- a/libs/copilot/src/types.ts +++ b/libs/copilot/src/types.ts @@ -1,3 +1,5 @@ +export type DisplayMode = 'floating' | 'sidebar'; + export interface IWidgetConfig { chainlitServer: string; showCot?: boolean; @@ -13,4 +15,5 @@ export interface IWidgetConfig { expanded?: boolean; language?: string; opened?: boolean; + displayMode?: DisplayMode; } diff --git a/libs/copilot/src/widget.tsx b/libs/copilot/src/widget.tsx index f41e72de3b..c930fb7ce5 100644 --- a/libs/copilot/src/widget.tsx +++ b/libs/copilot/src/widget.tsx @@ -14,21 +14,34 @@ import { useConfig } from '@chainlit/react-client'; import Header from './components/Header'; import ChatWrapper from './chat'; +import { useSidebarResize } from './hooks'; import { clearChainlitCopilotThreadId, getChainlitCopilotThreadId } from './state'; -import { IWidgetConfig } from './types'; +import { DisplayMode, IWidgetConfig } from './types'; interface Props { config: IWidgetConfig; error?: string; } +const LS_DISPLAY_MODE_KEY = 'chainlit-copilot-displayMode'; + const Widget = ({ config, error }: Props) => { const [expanded, setExpanded] = useState(config?.expanded || false); const [isOpen, setIsOpen] = useState(config?.opened || false); + const [displayMode, setDisplayMode] = useState( + () => + (localStorage.getItem(LS_DISPLAY_MODE_KEY) as DisplayMode) || + config?.displayMode || + 'floating' + ); const projectConfig = useConfig(); + const { sidebarWidth, handleMouseDown } = useSidebarResize({ + displayMode, + isOpen + }); useEffect(() => { window.toggleChainlitCopilot = () => setIsOpen((prev) => !prev); @@ -44,8 +57,75 @@ const Widget = ({ config, error }: Props) => { }; }, []); + useEffect(() => { + localStorage.setItem(LS_DISPLAY_MODE_KEY, displayMode); + }, [displayMode]); + const customClassName = config?.button?.className || ''; + const chatContent = error ? ( + {error} + ) : ( + <> +
+
+ +
+ + ); + + if (displayMode === 'sidebar') { + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+
+ {chatContent} +
+
+ ); + } + return ( @@ -108,20 +188,7 @@ const Widget = ({ config, error }: Props) => { )} >
- {error ? ( - {error} - ) : ( - <> -
-
- -
- - )} + {chatContent}