Skip to content

Commit 68d0885

Browse files
tylerslatonclaude
andcommitted
feat(react-core): add position prop to CopilotSidebar (left/right)
Lets consumers anchor the v2 CopilotSidebar to either side of the viewport instead of the hardcoded right side. The prop flips the fixed anchor, the border side, the off-screen translate direction, and the body push margin (marginInlineStart vs marginInlineEnd) so the layout mirrors correctly. Defaults to "right" for backward compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b888045 commit 68d0885

4 files changed

Lines changed: 173 additions & 10 deletions

File tree

examples/v2/react/storybook/stories/CopilotSidebarView.stories.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ export const Default: Story = {
3131
},
3232
};
3333

34+
export const RightPosition: Story = {
35+
args: {
36+
autoScroll: true,
37+
position: "right",
38+
},
39+
};
40+
41+
export const LeftPosition: Story = {
42+
args: {
43+
autoScroll: true,
44+
position: "left",
45+
},
46+
};
47+
3448
export const CustomHeader: Story = {
3549
args: {
3650
header: {

packages/react-core/src/v2/components/chat/CopilotSidebar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ export type CopilotSidebarProps = Omit<CopilotChatProps, "chatView"> & {
1414
toggleButton?: CopilotSidebarViewProps["toggleButton"];
1515
defaultOpen?: boolean;
1616
width?: number | string;
17+
position?: CopilotSidebarViewProps["position"];
1718
};
1819

1920
export function CopilotSidebar({
2021
header,
2122
toggleButton,
2223
defaultOpen,
2324
width,
25+
position,
2426
...chatProps
2527
}: CopilotSidebarProps) {
2628
const { checkFeature } = useLicenseContext();
@@ -41,6 +43,7 @@ export function CopilotSidebar({
4143
toggleButton: viewToggleButton,
4244
width: viewWidth,
4345
defaultOpen: viewDefaultOpen,
46+
position: viewPosition,
4447
...restProps
4548
} = viewProps as CopilotSidebarViewProps;
4649

@@ -51,12 +54,13 @@ export function CopilotSidebar({
5154
toggleButton={toggleButton ?? viewToggleButton}
5255
width={width ?? viewWidth}
5356
defaultOpen={defaultOpen ?? viewDefaultOpen}
57+
position={position ?? viewPosition}
5458
/>
5559
);
5660
};
5761

5862
return Object.assign(Component, CopilotChatView);
59-
}, [header, toggleButton, width, defaultOpen]);
63+
}, [header, toggleButton, width, defaultOpen, position]);
6064

6165
return (
6266
<>

packages/react-core/src/v2/components/chat/CopilotSidebarView.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ export type CopilotSidebarViewProps = CopilotChatViewProps & {
2121
toggleButton?: SlotValue<typeof CopilotChatToggleButton>;
2222
width?: number | string;
2323
defaultOpen?: boolean;
24+
position?: "left" | "right";
2425
};
2526

2627
export function CopilotSidebarView({
2728
header,
2829
toggleButton,
2930
width,
3031
defaultOpen = true,
32+
position = "right",
3133
...props
3234
}: CopilotSidebarViewProps) {
3335
return (
@@ -36,6 +38,7 @@ export function CopilotSidebarView({
3638
header={header}
3739
toggleButton={toggleButton}
3840
width={width}
41+
position={position}
3942
{...props}
4043
/>
4144
</CopilotChatConfigurationProvider>
@@ -46,6 +49,7 @@ function CopilotSidebarViewInternal({
4649
header,
4750
toggleButton,
4851
width,
52+
position = "right",
4953
...props
5054
}: Omit<CopilotSidebarViewProps, "defaultOpen">) {
5155
const configuration = useCopilotChatConfiguration();
@@ -118,23 +122,28 @@ function CopilotSidebarViewInternal({
118122
return;
119123
if (!window.matchMedia("(min-width: 768px)").matches) return;
120124

125+
const marginStyleProp =
126+
position === "left" ? "marginInlineStart" : "marginInlineEnd";
127+
const transitionCssProp =
128+
position === "left" ? "margin-inline-start" : "margin-inline-end";
129+
121130
if (isSidebarOpen) {
122131
if (hasMounted.current) {
123-
document.body.style.transition = `margin-inline-end ${SIDEBAR_TRANSITION_MS}ms ease`;
132+
document.body.style.transition = `${transitionCssProp} ${SIDEBAR_TRANSITION_MS}ms ease`;
124133
}
125-
document.body.style.marginInlineEnd = widthToMargin(sidebarWidth);
134+
document.body.style[marginStyleProp] = widthToMargin(sidebarWidth);
126135
} else if (hasMounted.current) {
127-
document.body.style.transition = `margin-inline-end ${SIDEBAR_TRANSITION_MS}ms ease`;
128-
document.body.style.marginInlineEnd = "";
136+
document.body.style.transition = `${transitionCssProp} ${SIDEBAR_TRANSITION_MS}ms ease`;
137+
document.body.style[marginStyleProp] = "";
129138
}
130139

131140
hasMounted.current = true;
132141

133142
return () => {
134-
document.body.style.marginInlineEnd = "";
143+
document.body.style[marginStyleProp] = "";
135144
document.body.style.transition = "";
136145
};
137-
}, [isSidebarOpen, sidebarWidth]);
146+
}, [isSidebarOpen, sidebarWidth, position]);
138147

139148
const headerElement = renderSlot(header, CopilotModalHeader, {});
140149
const toggleButtonElement = renderSlot(
@@ -151,18 +160,23 @@ function CopilotSidebarViewInternal({
151160
data-copilotkit
152161
data-testid="copilot-sidebar"
153162
data-copilot-sidebar
163+
data-position={position}
154164
className={cn(
155165
"copilotKitSidebar copilotKitWindow",
156-
"cpk:fixed cpk:right-0 cpk:top-0 cpk:z-[1200] cpk:flex",
166+
"cpk:fixed cpk:top-0 cpk:z-[1200] cpk:flex",
167+
position === "left" ? "cpk:left-0" : "cpk:right-0",
157168
// Height with dvh fallback and safe area support
158169
"cpk:h-[100vh] cpk:h-[100dvh] cpk:max-h-screen",
159170
// Responsive width: full on mobile, custom on desktop
160171
"cpk:w-full",
161-
"cpk:border-l cpk:border-border cpk:bg-background cpk:text-foreground cpk:shadow-xl",
172+
position === "left" ? "cpk:border-r" : "cpk:border-l",
173+
"cpk:border-border cpk:bg-background cpk:text-foreground cpk:shadow-xl",
162174
"cpk:transition-transform cpk:duration-300 cpk:ease-out",
163175
isSidebarOpen
164176
? "cpk:translate-x-0"
165-
: "cpk:translate-x-full cpk:pointer-events-none",
177+
: position === "left"
178+
? "cpk:-translate-x-full cpk:pointer-events-none"
179+
: "cpk:translate-x-full cpk:pointer-events-none",
166180
)}
167181
style={
168182
{
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import React from "react";
2+
import { render } from "@testing-library/react";
3+
import { describe, it, expect } from "vitest";
4+
import { CopilotSidebarView } from "../CopilotSidebarView";
5+
import { CopilotSidebar } from "../CopilotSidebar";
6+
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
7+
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
8+
import {
9+
MockStepwiseAgent,
10+
renderWithCopilotKit,
11+
} from "../../../__tests__/utils/test-helpers";
12+
13+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
14+
<CopilotKitProvider>
15+
<CopilotChatConfigurationProvider threadId="test-thread">
16+
{children}
17+
</CopilotChatConfigurationProvider>
18+
</CopilotKitProvider>
19+
);
20+
21+
const sampleMessages = [
22+
{ id: "1", role: "user" as const, content: "Hello" },
23+
];
24+
25+
function getSidebarAside(container: HTMLElement) {
26+
const sidebar = container.querySelector("[data-copilot-sidebar]");
27+
if (!sidebar) throw new Error("sidebar aside not found");
28+
return sidebar;
29+
}
30+
31+
describe("CopilotSidebarView position prop", () => {
32+
describe("CopilotSidebarView", () => {
33+
it("defaults to right-anchored when position is omitted", () => {
34+
const { container } = render(
35+
<TestWrapper>
36+
<CopilotSidebarView messages={sampleMessages} />
37+
</TestWrapper>,
38+
);
39+
40+
const aside = getSidebarAside(container);
41+
expect(aside.classList.contains("cpk:right-0")).toBe(true);
42+
expect(aside.classList.contains("cpk:border-l")).toBe(true);
43+
expect(aside.classList.contains("cpk:left-0")).toBe(false);
44+
expect(aside.classList.contains("cpk:border-r")).toBe(false);
45+
});
46+
47+
it("renders right-anchored when position=\"right\" explicitly", () => {
48+
const { container } = render(
49+
<TestWrapper>
50+
<CopilotSidebarView messages={sampleMessages} position="right" />
51+
</TestWrapper>,
52+
);
53+
54+
const aside = getSidebarAside(container);
55+
expect(aside.classList.contains("cpk:right-0")).toBe(true);
56+
expect(aside.classList.contains("cpk:border-l")).toBe(true);
57+
expect(aside.classList.contains("cpk:left-0")).toBe(false);
58+
expect(aside.classList.contains("cpk:border-r")).toBe(false);
59+
});
60+
61+
it("renders left-anchored when position=\"left\"", () => {
62+
const { container } = render(
63+
<TestWrapper>
64+
<CopilotSidebarView messages={sampleMessages} position="left" />
65+
</TestWrapper>,
66+
);
67+
68+
const aside = getSidebarAside(container);
69+
expect(aside.classList.contains("cpk:left-0")).toBe(true);
70+
expect(aside.classList.contains("cpk:border-r")).toBe(true);
71+
expect(aside.classList.contains("cpk:right-0")).toBe(false);
72+
expect(aside.classList.contains("cpk:border-l")).toBe(false);
73+
});
74+
75+
it("translates off-screen to the right when closed and position=\"right\"", () => {
76+
const { container } = render(
77+
<TestWrapper>
78+
<CopilotSidebarView
79+
messages={sampleMessages}
80+
defaultOpen={false}
81+
position="right"
82+
/>
83+
</TestWrapper>,
84+
);
85+
86+
const aside = getSidebarAside(container);
87+
expect(aside.classList.contains("cpk:translate-x-full")).toBe(true);
88+
expect(aside.classList.contains("cpk:-translate-x-full")).toBe(false);
89+
});
90+
91+
it("translates off-screen to the left when closed and position=\"left\"", () => {
92+
const { container } = render(
93+
<TestWrapper>
94+
<CopilotSidebarView
95+
messages={sampleMessages}
96+
defaultOpen={false}
97+
position="left"
98+
/>
99+
</TestWrapper>,
100+
);
101+
102+
const aside = getSidebarAside(container);
103+
expect(aside.classList.contains("cpk:-translate-x-full")).toBe(true);
104+
expect(aside.classList.contains("cpk:translate-x-full")).toBe(false);
105+
});
106+
});
107+
108+
describe("CopilotSidebar wrapper", () => {
109+
it("forwards position=\"left\" through to CopilotSidebarView", () => {
110+
const { container } = renderWithCopilotKit({
111+
agent: new MockStepwiseAgent(),
112+
children: <CopilotSidebar position="left" />,
113+
});
114+
115+
const aside = getSidebarAside(container);
116+
expect(aside.classList.contains("cpk:left-0")).toBe(true);
117+
expect(aside.classList.contains("cpk:border-r")).toBe(true);
118+
});
119+
120+
it("defaults to right-anchored when position is omitted", () => {
121+
const { container } = renderWithCopilotKit({
122+
agent: new MockStepwiseAgent(),
123+
children: <CopilotSidebar />,
124+
});
125+
126+
const aside = getSidebarAside(container);
127+
expect(aside.classList.contains("cpk:right-0")).toBe(true);
128+
expect(aside.classList.contains("cpk:border-l")).toBe(true);
129+
});
130+
});
131+
});

0 commit comments

Comments
 (0)