Skip to content
Open
158 changes: 151 additions & 7 deletions cypress/e2e/copilot/spec.cy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {
copilotShouldBeOpen,
clearCopilotThreadId,
copilotShouldBeOpen,
getCopilotThreadId,
loadCopilotScript,
mountCopilotWidget,
openCopilot,
submitMessage,
submitMessage
} from '../../support/testUtils';

describe('Copilot', { includeShadowDom: true }, () => {
Expand Down Expand Up @@ -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);
});
});
});
Expand All @@ -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);
});
});
});
});
76 changes: 63 additions & 13 deletions libs/copilot/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand All @@ -52,17 +66,53 @@ const Header = ({
className="text-muted-foreground mt-[1.5px]"
onConfirm={startNewChat}
/>
<Button
size="icon"
variant="ghost"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<Minimize className="!size-5 text-muted-foreground" />
) : (
<Maximize className="!size-5 text-muted-foreground" />
)}
</Button>
{setDisplayMode && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button id="display-mode-button" size="icon" variant="ghost">
<PanelRight className="!size-5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
container={window.cl_shadowRootElement}
>
<DropdownMenuRadioGroup
value={displayMode}
onValueChange={(v) => setDisplayMode(v as DisplayMode)}
>
<DropdownMenuRadioItem value="floating">
Floating
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="sidebar">
Sidebar
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{displayMode === 'sidebar' && setIsOpen ? (
<Button
id="close-sidebar-button"
size="icon"
variant="ghost"
onClick={() => setIsOpen(false)}
>
<ChevronsRight className="!size-5 text-muted-foreground" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<Minimize className="!size-5 text-muted-foreground" />
) : (
<Maximize className="!size-5 text-muted-foreground" />
)}
</Button>
)}
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions libs/copilot/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useCopilotInteract } from './useCopilotInteract';
export { useSidebarResize } from './useSidebarResize';
95 changes: 95 additions & 0 deletions libs/copilot/src/hooks/useSidebarResize.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
3 changes: 3 additions & 0 deletions libs/copilot/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type DisplayMode = 'floating' | 'sidebar';

export interface IWidgetConfig {
chainlitServer: string;
showCot?: boolean;
Expand All @@ -13,4 +15,5 @@ export interface IWidgetConfig {
expanded?: boolean;
language?: string;
opened?: boolean;
displayMode?: DisplayMode;
}
Loading
Loading