Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions cypress/e2e/copilot/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>');
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 });

Expand Down
155 changes: 137 additions & 18 deletions libs/copilot/src/hooks/useSidebarResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const LS_WIDTH_KEY = 'chainlit-copilot-sidebarWidth';
interface UseSidebarResizeOptions {
displayMode: DisplayMode;
isOpen: boolean;
hostRoot?: string;
}

interface UseSidebarResizeReturn {
Expand All @@ -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<HTMLElement>(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') {
Expand All @@ -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;
Expand Down Expand Up @@ -74,22 +141,74 @@ export function useSidebarResize({
};
}, [stopDragging, displayMode, isOpen]);

// Suspend any containing block the host set on <body> (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 };
}
4 changes: 4 additions & 0 deletions libs/copilot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion libs/copilot/src/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ const Widget = ({ config, error }: Props) => {
const projectConfig = useConfig();
const { sidebarWidth, handleMouseDown } = useSidebarResize({
displayMode,
isOpen
isOpen,
hostRoot: config?.hostRoot
});

useEffect(() => {
Expand Down
Loading