From 2e1044529a094da937b8b0a8595d2dec3151c139 Mon Sep 17 00:00:00 2001 From: Eyal Amitay Date: Wed, 18 Feb 2026 13:37:10 +0200 Subject: [PATCH 1/8] feat: Add copilot sidebar mode with drag-to-resize Add sidebar display mode as an alternative to the floating popover. The sidebar opens as a fixed panel on the right edge with a drag handle on the left edge for Notion-style resize (300px min, 50% viewport max). Width persists to localStorage. Includes display mode toggle in header and E2E tests for sidebar open/close/resize/mode-switch. --- cypress/e2e/copilot/spec.cy.ts | 116 ++++++++++++++++++-- libs/copilot/index.tsx | 2 + libs/copilot/src/components/Header.tsx | 78 +++++++++++--- libs/copilot/src/types.ts | 1 + libs/copilot/src/widget.tsx | 144 ++++++++++++++++++++++++- 5 files changed, 320 insertions(+), 21 deletions(-) diff --git a/cypress/e2e/copilot/spec.cy.ts b/cypress/e2e/copilot/spec.cy.ts index 6852c125a4..4f69738874 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,110 @@ describe('Copilot', { includeShadowDom: true }, () => { copilotShouldBeOpen(); }); + + describe('Sidebar mode', () => { + beforeEach(() => { + // Clear localStorage to avoid state leaking between tests + 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; + // Drag 200px to the left to widen the sidebar + 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(); + + // Select "Floating" from dropdown + cy.contains('[role="menuitemradio"]', 'Floating').click(); + + // Body margin should be restored + cy.document().then((doc) => { + expect(doc.body.style.marginRight).to.not.equal('400px'); + }); + }); + }); }); diff --git a/libs/copilot/index.tsx b/libs/copilot/index.tsx index 9dc840612e..1ddc7df3d7 100644 --- a/libs/copilot/index.tsx +++ b/libs/copilot/index.tsx @@ -60,6 +60,8 @@ window.mountChainlitWidget = (config: IWidgetConfig) => { }; window.unmountChainlitWidget = () => { + document.body.style.marginRight = ''; + document.body.style.transition = ''; root?.unmount(); }; diff --git a/libs/copilot/src/components/Header.tsx b/libs/copilot/src/components/Header.tsx index 1981e227c6..635ab2c566 100644 --- a/libs/copilot/src/components/Header.tsx +++ b/libs/copilot/src/components/Header.tsx @@ -1,10 +1,17 @@ -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'; @@ -20,12 +27,18 @@ interface Props { expanded: boolean; setExpanded: (expanded: boolean) => void; projectConfig: IProjectConfig; + displayMode?: 'floating' | 'sidebar'; + setDisplayMode?: (mode: 'floating' | 'sidebar') => 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 +65,56 @@ const Header = ({ className="text-muted-foreground mt-[1.5px]" onConfirm={startNewChat} /> - + {setDisplayMode && ( + + + + + + + setDisplayMode(v as 'floating' | 'sidebar') + } + > + + Floating + + + Sidebar + + + + + )} + {displayMode !== 'sidebar' && ( + + )} + {displayMode === 'sidebar' && setIsOpen && ( + + )} ); diff --git a/libs/copilot/src/types.ts b/libs/copilot/src/types.ts index aa4834b845..fed8bbb7f8 100644 --- a/libs/copilot/src/types.ts +++ b/libs/copilot/src/types.ts @@ -13,4 +13,5 @@ export interface IWidgetConfig { expanded?: boolean; language?: string; opened?: boolean; + displayMode?: 'floating' | 'sidebar'; } diff --git a/libs/copilot/src/widget.tsx b/libs/copilot/src/widget.tsx index f41e72de3b..da47aa0ba4 100644 --- a/libs/copilot/src/widget.tsx +++ b/libs/copilot/src/widget.tsx @@ -1,6 +1,6 @@ import { cn } from '@/lib/utils'; import { MessageCircle, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Alert from '@chainlit/app/src/components/Alert'; import { Button } from '@chainlit/app/src/components/ui/button'; @@ -25,9 +25,26 @@ interface Props { error?: string; } +const SIDEBAR_MIN_WIDTH = 300; +const SIDEBAR_DEFAULT_WIDTH = 400; +const SIDEBAR_MAX_WIDTH_RATIO = 0.5; +const LS_KEY = 'chainlit-copilot-displayMode'; +const LS_WIDTH_KEY = 'chainlit-copilot-sidebarWidth'; + const Widget = ({ config, error }: Props) => { const [expanded, setExpanded] = useState(config?.expanded || false); const [isOpen, setIsOpen] = useState(config?.opened || false); + const [displayMode, setDisplayMode] = useState<'floating' | 'sidebar'>( + () => + (localStorage.getItem(LS_KEY) as 'floating' | 'sidebar') || + config?.displayMode || + 'floating' + ); + const [sidebarWidth, setSidebarWidth] = useState(() => { + const stored = localStorage.getItem(LS_WIDTH_KEY); + return stored ? Number(stored) : SIDEBAR_DEFAULT_WIDTH; + }); + const isDragging = useRef(false); const projectConfig = useConfig(); useEffect(() => { @@ -44,8 +61,130 @@ const Widget = ({ config, error }: Props) => { }; }, []); + // Persist displayMode to localStorage + useEffect(() => { + localStorage.setItem(LS_KEY, displayMode); + }, [displayMode]); + + // Persist sidebar width to localStorage + useEffect(() => { + localStorage.setItem(LS_WIDTH_KEY, String(sidebarWidth)); + }, [sidebarWidth]); + + // Drag-to-resize logic + const handleMouseDown = useCallback(() => { + isDragging.current = true; + document.body.style.userSelect = 'none'; + }, []); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + 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); + }; + + const onMouseUp = () => { + if (!isDragging.current) return; + isDragging.current = false; + document.body.style.userSelect = ''; + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + // Manage body margin for sidebar mode + useEffect(() => { + if (displayMode === 'sidebar' && isOpen) { + const originalMargin = document.body.style.marginRight; + document.body.style.transition = 'margin-right 0.3s ease-in-out'; + document.body.style.marginRight = `${sidebarWidth}px`; + return () => { + document.body.style.marginRight = originalMargin; + document.body.style.transition = ''; + }; + } + }, [displayMode, isOpen, sidebarWidth]); + const customClassName = config?.button?.className || ''; + // Sidebar mode: early return before the Popover + if (displayMode === 'sidebar') { + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+
+ {error ? ( + {error} + ) : ( + <> +
+
+ +
+ + )} +
+ {/* Hidden button for test compatibility */} +
+ ); + } + return ( @@ -116,6 +255,9 @@ const Widget = ({ config, error }: Props) => { expanded={expanded} setExpanded={setExpanded} projectConfig={projectConfig} + displayMode={displayMode} + setDisplayMode={setDisplayMode} + setIsOpen={setIsOpen} />
From a912efc077939bf8a438ba78f628efa0c4052dad Mon Sep 17 00:00:00 2001 From: Eyal Amitay Date: Wed, 18 Feb 2026 14:01:22 +0200 Subject: [PATCH 2/8] fix: resolve sidebar resize bugs and extract useSidebarResize hook - Fix stale originalMargin capture by splitting into lifecycle + sync effects so the true original margin is captured once on open via ref, not re-captured on every width change - Fix userSelect not restored on unmount when drag is in progress - Fix CSS transition lag during drag by disabling transition on mousedown and re-enabling on mouseup - Add window.blur handler to cancel stuck drags when mouse released outside browser - Extract resize logic into useSidebarResize hook, consolidate DisplayMode type - Deduplicate chatContent and renderButtonIcon in widget.tsx - Add E2E tests for unmount cleanup and localStorage persistence across remounts --- cypress/e2e/copilot/spec.cy.ts | 78 +++++++++++ libs/copilot/src/components/Header.tsx | 30 ++--- libs/copilot/src/hooks/index.ts | 1 + libs/copilot/src/hooks/useSidebarResize.ts | 94 ++++++++++++++ libs/copilot/src/types.ts | 4 +- libs/copilot/src/widget.tsx | 142 ++++++--------------- 6 files changed, 228 insertions(+), 121 deletions(-) create mode 100644 libs/copilot/src/hooks/useSidebarResize.ts diff --git a/cypress/e2e/copilot/spec.cy.ts b/cypress/e2e/copilot/spec.cy.ts index 4f69738874..a34b5e40e8 100644 --- a/cypress/e2e/copilot/spec.cy.ts +++ b/cypress/e2e/copilot/spec.cy.ts @@ -265,5 +265,83 @@ describe('Copilot', { includeShadowDom: true }, () => { 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', () => { + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.get('#chainlit-copilot-chat').should('exist'); + + cy.step('Resize sidebar via drag'); + 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 - 100; + + cy.wrap($handle) + .trigger('mousedown', { clientX: startX, clientY: startY }) + .then(() => { + cy.document().trigger('mousemove', { + clientX: targetX, + clientY: startY + }); + cy.document().trigger('mouseup'); + }); + }); + + cy.step('Capture resized width from localStorage'); + cy.window().then((win) => { + const storedWidth = win.localStorage.getItem( + 'chainlit-copilot-sidebarWidth' + ); + expect(storedWidth).to.not.equal(null); + const width = Number(storedWidth); + expect(width).to.be.greaterThan(400); + + cy.step('Unmount and remount widget'); + // @ts-expect-error is not a valid prop + win.unmountChainlitWidget(); + const el = win.document.getElementById('chainlit-copilot'); + if (el) el.remove(); + }); + + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.step('Verify restored width matches persisted value'); + cy.window().then((win) => { + const storedWidth = Number( + win.localStorage.getItem('chainlit-copilot-sidebarWidth') + ); + cy.get('#chainlit-copilot-chat') + .parents('div.fixed') + .first() + .invoke('width') + .should('be.closeTo', storedWidth, 5); + }); + }); }); }); diff --git a/libs/copilot/src/components/Header.tsx b/libs/copilot/src/components/Header.tsx index 635ab2c566..2e2f19ef0f 100644 --- a/libs/copilot/src/components/Header.tsx +++ b/libs/copilot/src/components/Header.tsx @@ -15,6 +15,7 @@ import { import { IChainlitConfig, useAudio } from '@chainlit/react-client'; import { useCopilotInteract } from '../hooks'; +import { DisplayMode } from '../types'; interface IProjectConfig { config?: IChainlitConfig; @@ -27,8 +28,8 @@ interface Props { expanded: boolean; setExpanded: (expanded: boolean) => void; projectConfig: IProjectConfig; - displayMode?: 'floating' | 'sidebar'; - setDisplayMode?: (mode: 'floating' | 'sidebar') => void; + displayMode?: DisplayMode; + setDisplayMode?: (mode: DisplayMode) => void; setIsOpen?: (open: boolean) => void; } @@ -78,9 +79,7 @@ const Header = ({ > - setDisplayMode(v as 'floating' | 'sidebar') - } + onValueChange={(v) => setDisplayMode(v as DisplayMode)} > Floating @@ -92,7 +91,16 @@ const Header = ({ )} - {displayMode !== '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..7aa9956df4 --- /dev/null +++ b/libs/copilot/src/hooks/useSidebarResize.ts @@ -0,0 +1,94 @@ +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(''); + + // Persist sidebar width to localStorage + useEffect(() => { + localStorage.setItem(LS_WIDTH_KEY, String(sidebarWidth)); + }, [sidebarWidth]); + + 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(() => { + 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]); + + // Capture original margin when sidebar opens, restore when it closes + 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]); + + // Sync body margin with sidebar width + 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 fed8bbb7f8..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,5 +15,5 @@ export interface IWidgetConfig { expanded?: boolean; language?: string; opened?: boolean; - displayMode?: 'floating' | 'sidebar'; + displayMode?: DisplayMode; } diff --git a/libs/copilot/src/widget.tsx b/libs/copilot/src/widget.tsx index da47aa0ba4..32c3ec1d75 100644 --- a/libs/copilot/src/widget.tsx +++ b/libs/copilot/src/widget.tsx @@ -1,6 +1,6 @@ import { cn } from '@/lib/utils'; import { MessageCircle, X } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import Alert from '@chainlit/app/src/components/Alert'; import { Button } from '@chainlit/app/src/components/ui/button'; @@ -14,38 +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 SIDEBAR_MIN_WIDTH = 300; -const SIDEBAR_DEFAULT_WIDTH = 400; -const SIDEBAR_MAX_WIDTH_RATIO = 0.5; -const LS_KEY = 'chainlit-copilot-displayMode'; -const LS_WIDTH_KEY = 'chainlit-copilot-sidebarWidth'; +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<'floating' | 'sidebar'>( + const [displayMode, setDisplayMode] = useState( () => - (localStorage.getItem(LS_KEY) as 'floating' | 'sidebar') || + (localStorage.getItem(LS_DISPLAY_MODE_KEY) as DisplayMode) || config?.displayMode || 'floating' ); - const [sidebarWidth, setSidebarWidth] = useState(() => { - const stored = localStorage.getItem(LS_WIDTH_KEY); - return stored ? Number(stored) : SIDEBAR_DEFAULT_WIDTH; - }); - const isDragging = useRef(false); const projectConfig = useConfig(); + const { sidebarWidth, handleMouseDown } = useSidebarResize({ + displayMode, + isOpen + }); useEffect(() => { window.toggleChainlitCopilot = () => setIsOpen((prev) => !prev); @@ -63,59 +59,37 @@ const Widget = ({ config, error }: Props) => { // Persist displayMode to localStorage useEffect(() => { - localStorage.setItem(LS_KEY, displayMode); + localStorage.setItem(LS_DISPLAY_MODE_KEY, displayMode); }, [displayMode]); - // Persist sidebar width to localStorage - useEffect(() => { - localStorage.setItem(LS_WIDTH_KEY, String(sidebarWidth)); - }, [sidebarWidth]); + const customClassName = config?.button?.className || ''; - // Drag-to-resize logic - const handleMouseDown = useCallback(() => { - isDragging.current = true; - document.body.style.userSelect = 'none'; - }, []); + const chatContent = error ? ( + {error} + ) : ( + <> +
+
+ +
+ + ); - useEffect(() => { - const onMouseMove = (e: MouseEvent) => { - 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) + function renderButtonIcon(): JSX.Element { + if (config?.button?.imageUrl) { + return ( + Chat bubble icon ); - setSidebarWidth(newWidth); - }; - - const onMouseUp = () => { - if (!isDragging.current) return; - isDragging.current = false; - document.body.style.userSelect = ''; - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - return () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - }, []); - - // Manage body margin for sidebar mode - useEffect(() => { - if (displayMode === 'sidebar' && isOpen) { - const originalMargin = document.body.style.marginRight; - document.body.style.transition = 'margin-right 0.3s ease-in-out'; - document.body.style.marginRight = `${sidebarWidth}px`; - return () => { - document.body.style.marginRight = originalMargin; - document.body.style.transition = ''; - }; } - }, [displayMode, isOpen, sidebarWidth]); - - const customClassName = config?.button?.className || ''; + return ; + } // Sidebar mode: early return before the Popover if (displayMode === 'sidebar') { @@ -132,15 +106,7 @@ const Widget = ({ config, error }: Props) => { onClick={() => setIsOpen(true)} >
- {config?.button?.imageUrl ? ( - Chat bubble icon - ) : ( - - )} + {renderButtonIcon()}
); @@ -157,23 +123,7 @@ const Widget = ({ config, error }: Props) => { className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/20 active:bg-primary/30 z-10" />
- {error ? ( - {error} - ) : ( - <> -
-
- -
- - )} + {chatContent}
{/* Hidden button for test compatibility */} ); @@ -123,11 +122,6 @@ const Widget = ({ config, error }: Props) => {
{chatContent}
-