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(() => {