diff --git a/cypress/e2e/copilot/spec.cy.ts b/cypress/e2e/copilot/spec.cy.ts index 2deb0a5214..60d099bd36 100644 --- a/cypress/e2e/copilot/spec.cy.ts +++ b/cypress/e2e/copilot/spec.cy.ts @@ -189,6 +189,62 @@ describe('Copilot', { includeShadowDom: true }, () => { }); }); + it('should neutralize a host body transform while open and restore it', () => { + cy.step('Give the host a transform on '); + cy.document().then((doc) => { + doc.body.style.transform = 'translateZ(0px)'; + }); + + mountCopilotWidget({ displayMode: 'sidebar', opened: true }); + + cy.get('#chainlit-copilot-chat').should('exist'); + cy.step( + 'Body transform is neutralized so the sidebar stays viewport-fixed' + ); + cy.document().should((doc) => { + expect(doc.body.style.transform).to.equal('none'); + }); + + cy.step('Close and verify the host transform is restored'); + cy.get('#close-sidebar-button').click(); + cy.get('#chainlit-copilot-chat').should('not.exist'); + cy.document().should((doc) => { + expect(doc.body.style.transform).to.equal('translateZ(0px)'); + }); + }); + + it('should constrain a configured hostRoot instead of the body margin', () => { + cy.step('Add a host root element the widget should constrain'); + cy.document().then((doc) => { + const el = doc.createElement('div'); + el.id = 'test-host-root'; + doc.body.appendChild(el); + }); + + mountCopilotWidget({ + displayMode: 'sidebar', + opened: true, + hostRoot: '#test-host-root' + }); + + cy.get('#chainlit-copilot-chat').should('exist'); + cy.step( + 'hostRoot width is constrained and the body margin is left alone' + ); + cy.get('#test-host-root').should(($el) => { + const el = $el[0]; + const win = el.ownerDocument.defaultView; + if (!win) throw new Error('missing host window'); + expect(el.getBoundingClientRect().width).to.be.closeTo( + win.innerWidth - 400, + 2 + ); + }); + cy.document().should((doc) => { + expect(doc.body.style.marginRight).to.not.equal('400px'); + }); + }); + it('should resize sidebar via drag handle', () => { mountCopilotWidget({ displayMode: 'sidebar', opened: true }); diff --git a/libs/copilot/src/hooks/useSidebarResize.ts b/libs/copilot/src/hooks/useSidebarResize.ts index c5e45c2fd0..39fa60976b 100644 --- a/libs/copilot/src/hooks/useSidebarResize.ts +++ b/libs/copilot/src/hooks/useSidebarResize.ts @@ -10,6 +10,7 @@ const LS_WIDTH_KEY = 'chainlit-copilot-sidebarWidth'; interface UseSidebarResizeOptions { displayMode: DisplayMode; isOpen: boolean; + hostRoot?: string; } interface UseSidebarResizeReturn { @@ -19,14 +20,80 @@ interface UseSidebarResizeReturn { export function useSidebarResize({ displayMode, - isOpen + isOpen, + hostRoot }: 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(''); + + // Resolve the host root fresh on each use so a host that swaps the node (SPA re-render) + // is always handled — never a cached, detached node. + const getHostRoot = useCallback( + () => (hostRoot ? document.querySelector(hostRoot) : null), + [hostRoot] + ); + + // Original inline styles of every host node we've constrained this session. The SPA can + // swap the host mid-session, so we key by node and restore them all on close. + const styledHosts = useRef( + new Map< + HTMLElement, + { width: string; overflowX: string; transition: string } + >() + ); + + // Whether we wrote the body-margin fallback this session (host absent). Tracked apart + // from styledHosts because a session can use both if a configured host disappears. + const usedBodyFallback = useRef(false); + + // Snapshot a host's original inline styles the first time we touch it, so close can + // restore it even after a swap. + const rememberHost = useCallback((host: HTMLElement) => { + if (!styledHosts.current.has(host)) { + styledHosts.current.set(host, { + width: host.style.width, + overflowX: host.style.overflowX, + transition: host.style.transition + }); + } + }, []); + + // Reserve space beside the sidebar: constrain the host root's width, or (default) push + // the body with a right margin. + const reserveSpace = useCallback( + (width: number) => { + const host = getHostRoot(); + if (host) { + rememberHost(host); + host.style.width = `calc(100vw - ${width}px)`; + } else { + usedBodyFallback.current = true; + document.body.style.marginRight = `${width}px`; + } + }, + [getHostRoot, rememberHost] + ); + + // Toggle the reservation transition on whichever element we actually resize, so drags + // follow the pointer instantly instead of animating each step. + const setReserveTransition = useCallback( + (enabled: boolean) => { + const host = getHostRoot(); + if (host) { + rememberHost(host); + host.style.transition = enabled ? 'width 0.3s ease-in-out' : ''; + } else { + usedBodyFallback.current = true; + document.body.style.transition = enabled + ? 'margin-right 0.3s ease-in-out' + : ''; + } + }, + [getHostRoot, rememberHost] + ); useEffect(() => { if (displayMode === 'sidebar') { @@ -38,14 +105,14 @@ export function useSidebarResize({ if (!isDragging.current) return; isDragging.current = false; document.body.style.userSelect = ''; - document.body.style.transition = 'margin-right 0.3s ease-in-out'; - }, []); + setReserveTransition(true); + }, [setReserveTransition]); const handleMouseDown = useCallback(() => { isDragging.current = true; document.body.style.userSelect = 'none'; - document.body.style.transition = ''; - }, []); + setReserveTransition(false); + }, [setReserveTransition]); useEffect(() => { if (displayMode !== 'sidebar' || !isOpen) return; @@ -74,22 +141,74 @@ export function useSidebarResize({ }; }, [stopDragging, displayMode, isOpen]); + // Suspend any containing block the host set on (transform / perspective / + // will-change) so the fixed sidebar stays anchored to the viewport, then reserve space + // for it. A viewport-filling host (100vw / absolute inset) can't be shrunk by a body + // margin, so when `hostRoot` is given we constrain that element's width instead. + // Everything is restored on close. 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 = ''; - }; + if (displayMode !== 'sidebar' || !isOpen) return; + + const body = document.body; + const host = getHostRoot(); + usedBodyFallback.current = false; + + const prevBody = { + transform: body.style.transform, + perspective: body.style.perspective, + willChange: body.style.willChange, + marginRight: body.style.marginRight, + transition: body.style.transition + }; + body.style.transform = 'none'; + body.style.perspective = 'none'; + body.style.willChange = 'auto'; + + if (host) { + rememberHost(host); + // `clip` shrinks non-reflowing content without turning the host into a scroll container. + host.style.overflowX = 'clip'; } - }, [displayMode, isOpen]); + // Reserve first (instant), then enable the transition so only later drags animate. + reserveSpace(sidebarWidth); + // Commit the host width before enabling its transition, else Chromium tries to animate + // width from `auto`, sticks at the pre-open value, and the host never shrinks. A body + // margin animates from 0 fine, so it needs no flush. + if (host) void host.offsetWidth; + setReserveTransition(true); + + const hosts = styledHosts.current; + return () => { + body.style.transform = prevBody.transform; + body.style.perspective = prevBody.perspective; + body.style.willChange = prevBody.willChange; + // Restore every host node we constrained — the SPA may have swapped it mid-session. + hosts.forEach((prev, node) => { + node.style.width = prev.width; + node.style.overflowX = prev.overflowX; + node.style.transition = prev.transition; + }); + hosts.clear(); + // Undo the body-margin fallback only if we actually used it (a host may have vanished + // mid-session), so we never clobber host-app updates in the pure-host path. + if (usedBodyFallback.current) { + body.style.marginRight = prevBody.marginRight; + body.style.transition = prevBody.transition; + } + }; + }, [ + displayMode, + isOpen, + getHostRoot, + reserveSpace, + setReserveTransition, + rememberHost + ]); useEffect(() => { - if (displayMode === 'sidebar' && isOpen) { - document.body.style.marginRight = `${sidebarWidth}px`; - } - }, [sidebarWidth, displayMode, isOpen]); + if (displayMode !== 'sidebar' || !isOpen) return; + reserveSpace(sidebarWidth); + }, [sidebarWidth, displayMode, isOpen, reserveSpace]); return { sidebarWidth, handleMouseDown }; } diff --git a/libs/copilot/src/types.ts b/libs/copilot/src/types.ts index b71f9a44a0..9b9214983e 100644 --- a/libs/copilot/src/types.ts +++ b/libs/copilot/src/types.ts @@ -16,4 +16,8 @@ export interface IWidgetConfig { language?: string; opened?: boolean; displayMode?: DisplayMode; + // CSS selector for a viewport-filling host root (e.g. a full-screen map/dashboard + // shell). In sidebar mode its width is constrained instead of nudging the body margin, + // which a `100vw` / `position: absolute inset` layout would ignore. + hostRoot?: string; } diff --git a/libs/copilot/src/widget.tsx b/libs/copilot/src/widget.tsx index e2485f316a..9a950648f3 100644 --- a/libs/copilot/src/widget.tsx +++ b/libs/copilot/src/widget.tsx @@ -36,7 +36,8 @@ const Widget = ({ config, error }: Props) => { const projectConfig = useConfig(); const { sidebarWidth, handleMouseDown } = useSidebarResize({ displayMode, - isOpen + isOpen, + hostRoot: config?.hostRoot }); useEffect(() => {