Skip to content

Commit d09618a

Browse files
mxmzbjpr5
authored andcommitted
feat(react-native): add CopilotModal and CopilotSidebar components
1 parent 73431de commit d09618a

2 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { type ReactNode } from "react";
2+
import { CopilotChat, type CopilotChatProps } from "./CopilotChat";
3+
4+
export interface CopilotModalProps extends CopilotChatProps {
5+
/**
6+
* Optional children rendered inside the modal context.
7+
*/
8+
children?: ReactNode;
9+
}
10+
11+
/**
12+
* Headless CopilotModal component for React Native.
13+
*
14+
* A thin wrapper around CopilotChat that mirrors the web SDK's CopilotModal
15+
* API surface. On React Native, modal presentation is handled by the consumer
16+
* (e.g. React Native's `Modal` component) -- this component only provides
17+
* the agent wiring and prop resolution.
18+
*
19+
* ```tsx
20+
* import { CopilotModal } from "@copilotkit/react-native";
21+
* import { Modal } from "react-native";
22+
*
23+
* <Modal visible={isOpen}>
24+
* <CopilotModal agentId="my-agent">
25+
* <MyChatUI />
26+
* </CopilotModal>
27+
* </Modal>
28+
* ```
29+
*/
30+
export function CopilotModal({ children, ...props }: CopilotModalProps) {
31+
return <CopilotChat {...props}>{children}</CopilotChat>;
32+
}
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import React, {
2+
forwardRef,
3+
useCallback,
4+
useEffect,
5+
useImperativeHandle,
6+
useRef,
7+
useState,
8+
} from "react";
9+
import type { ReactNode } from "react";
10+
import {
11+
Animated,
12+
Dimensions,
13+
Pressable,
14+
StyleSheet,
15+
Text,
16+
useWindowDimensions,
17+
View,
18+
} from "react-native";
19+
import type { ViewStyle } from "react-native";
20+
import { CopilotChat } from "./CopilotChat";
21+
import type { CopilotChatProps } from "./CopilotChat";
22+
23+
// ---------------------------------------------------------------------------
24+
// Types
25+
// ---------------------------------------------------------------------------
26+
27+
export interface CopilotSidebarHandle {
28+
/** Slide the drawer open. */
29+
open(): void;
30+
/** Slide the drawer closed. */
31+
close(): void;
32+
/** Toggle the drawer open/closed. */
33+
toggle(): void;
34+
}
35+
36+
export interface CopilotSidebarProps extends Omit<
37+
CopilotChatProps,
38+
"children"
39+
> {
40+
/**
41+
* Start the drawer in the open position.
42+
* @default false
43+
*/
44+
defaultOpen?: boolean;
45+
46+
/**
47+
* Width of the drawer panel. Accepts a number (points) or a percentage
48+
* string (e.g. `"85%"`). Defaults to 85% of the screen width.
49+
*/
50+
width?: number | string;
51+
52+
/**
53+
* Title displayed in the drawer header bar.
54+
* @default "Copilot"
55+
*/
56+
headerTitle?: string;
57+
58+
/**
59+
* Show a floating action button to toggle the drawer.
60+
* @default true
61+
*/
62+
showToggleButton?: boolean;
63+
64+
/** Called after the drawer finishes opening. */
65+
onOpen?: () => void;
66+
67+
/** Called after the drawer finishes closing. */
68+
onClose?: () => void;
69+
70+
/** Custom style applied to the drawer container. */
71+
style?: ViewStyle;
72+
73+
/** Content rendered inside the drawer below the chat area. */
74+
children?: ReactNode;
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// Constants
79+
// ---------------------------------------------------------------------------
80+
81+
const ANIMATION_DURATION_MS = 300;
82+
const DEFAULT_HEADER_TITLE = "Copilot";
83+
const BACKDROP_OPACITY = 0.4;
84+
const FAB_SIZE = 56;
85+
86+
// ---------------------------------------------------------------------------
87+
// Component
88+
// ---------------------------------------------------------------------------
89+
90+
/**
91+
* CopilotSidebar -- a slide-in drawer from the right edge of the screen
92+
* that wraps CopilotChat for React Native.
93+
*
94+
* ```tsx
95+
* import { CopilotSidebar } from "@copilotkit/react-native";
96+
*
97+
* const ref = useRef<CopilotSidebarHandle>(null);
98+
*
99+
* <CopilotSidebar
100+
* ref={ref}
101+
* agentId="my-agent"
102+
* headerTitle="Assistant"
103+
* defaultOpen={false}
104+
* />
105+
* ```
106+
*/
107+
export const CopilotSidebar = forwardRef<
108+
CopilotSidebarHandle,
109+
CopilotSidebarProps
110+
>(function CopilotSidebar(
111+
{
112+
agentId,
113+
agentName,
114+
threadId,
115+
onError,
116+
throttleMs,
117+
defaultOpen = false,
118+
width: widthProp,
119+
headerTitle = DEFAULT_HEADER_TITLE,
120+
showToggleButton = true,
121+
onOpen,
122+
onClose,
123+
style,
124+
children,
125+
...rest
126+
},
127+
ref,
128+
) {
129+
const { width: screenWidth } = useWindowDimensions();
130+
131+
// Resolve drawer width ---------------------------------------------------
132+
const drawerWidth = resolveWidth(widthProp, screenWidth);
133+
134+
// Animation & open state -------------------------------------------------
135+
const [isOpen, setIsOpen] = useState(defaultOpen);
136+
const slideAnim = useRef(
137+
new Animated.Value(defaultOpen ? 0 : drawerWidth),
138+
).current;
139+
140+
// Keep the animated value in sync when drawerWidth changes while closed
141+
useEffect(() => {
142+
if (!isOpen) {
143+
slideAnim.setValue(drawerWidth);
144+
}
145+
}, [drawerWidth, isOpen, slideAnim]);
146+
147+
const animateTo = useCallback(
148+
(toValue: number, cb?: () => void) => {
149+
Animated.timing(slideAnim, {
150+
toValue,
151+
duration: ANIMATION_DURATION_MS,
152+
useNativeDriver: true,
153+
}).start(({ finished }) => {
154+
if (finished) cb?.();
155+
});
156+
},
157+
[slideAnim],
158+
);
159+
160+
const open = useCallback(() => {
161+
setIsOpen(true);
162+
animateTo(0, onOpen);
163+
}, [animateTo, onOpen]);
164+
165+
const close = useCallback(() => {
166+
animateTo(drawerWidth, () => {
167+
setIsOpen(false);
168+
onClose?.();
169+
});
170+
}, [animateTo, drawerWidth, onClose]);
171+
172+
const toggle = useCallback(() => {
173+
if (isOpen) {
174+
close();
175+
} else {
176+
open();
177+
}
178+
}, [isOpen, open, close]);
179+
180+
useImperativeHandle(ref, () => ({ open, close, toggle }), [
181+
open,
182+
close,
183+
toggle,
184+
]);
185+
186+
// Callbacks stored in refs for stable animation closures -----------------
187+
const onOpenRef = useRef(onOpen);
188+
const onCloseRef = useRef(onClose);
189+
useEffect(() => {
190+
onOpenRef.current = onOpen;
191+
}, [onOpen]);
192+
useEffect(() => {
193+
onCloseRef.current = onClose;
194+
}, [onClose]);
195+
196+
// -----------------------------------------------------------------------
197+
// Render
198+
// -----------------------------------------------------------------------
199+
return (
200+
<>
201+
{/* Backdrop */}
202+
{isOpen && (
203+
<Pressable
204+
style={styles.backdrop}
205+
onPress={close}
206+
accessibilityRole="button"
207+
accessibilityLabel="Close sidebar"
208+
testID="copilot-sidebar-backdrop"
209+
/>
210+
)}
211+
212+
{/* Drawer */}
213+
{isOpen && (
214+
<Animated.View
215+
style={[
216+
styles.drawer,
217+
{ width: drawerWidth, transform: [{ translateX: slideAnim }] },
218+
style,
219+
]}
220+
testID="copilot-sidebar-drawer"
221+
>
222+
{/* Header */}
223+
<View style={styles.header}>
224+
<Text style={styles.headerTitle}>{headerTitle}</Text>
225+
<Pressable
226+
onPress={close}
227+
accessibilityRole="button"
228+
accessibilityLabel="Close"
229+
hitSlop={8}
230+
testID="copilot-sidebar-close"
231+
>
232+
<Text style={styles.closeButton}>{"✕"}</Text>
233+
</Pressable>
234+
</View>
235+
236+
{/* Chat area */}
237+
<View style={styles.chatContainer}>
238+
<CopilotChat
239+
agentId={agentId}
240+
agentName={agentName}
241+
threadId={threadId}
242+
onError={onError}
243+
throttleMs={throttleMs}
244+
{...rest}
245+
>
246+
{children}
247+
</CopilotChat>
248+
</View>
249+
</Animated.View>
250+
)}
251+
252+
{/* Floating action button */}
253+
{showToggleButton && !isOpen && (
254+
<Pressable
255+
style={styles.fab}
256+
onPress={open}
257+
accessibilityRole="button"
258+
accessibilityLabel="Open sidebar"
259+
testID="copilot-sidebar-fab"
260+
>
261+
<Text style={styles.fabIcon}>{"💬"}</Text>
262+
</Pressable>
263+
)}
264+
</>
265+
);
266+
});
267+
268+
// ---------------------------------------------------------------------------
269+
// Helpers
270+
// ---------------------------------------------------------------------------
271+
272+
function resolveWidth(
273+
widthProp: number | string | undefined,
274+
screenWidth: number,
275+
): number {
276+
if (widthProp === undefined) {
277+
return Math.round(screenWidth * 0.85);
278+
}
279+
if (typeof widthProp === "number") {
280+
return widthProp;
281+
}
282+
// Percentage string, e.g. "85%"
283+
const pctMatch = String(widthProp).match(/^(\d+(?:\.\d+)?)%$/);
284+
if (pctMatch) {
285+
return Math.round(screenWidth * (parseFloat(pctMatch[1]) / 100));
286+
}
287+
// Fallback: try parsing as number
288+
const parsed = parseFloat(widthProp);
289+
return isNaN(parsed) ? Math.round(screenWidth * 0.85) : parsed;
290+
}
291+
292+
// ---------------------------------------------------------------------------
293+
// Styles
294+
// ---------------------------------------------------------------------------
295+
296+
const styles = StyleSheet.create({
297+
backdrop: {
298+
...StyleSheet.absoluteFillObject,
299+
backgroundColor: `rgba(0, 0, 0, ${BACKDROP_OPACITY})`,
300+
zIndex: 999,
301+
},
302+
drawer: {
303+
position: "absolute",
304+
top: 0,
305+
right: 0,
306+
bottom: 0,
307+
backgroundColor: "#ffffff",
308+
zIndex: 1000,
309+
shadowColor: "#000",
310+
shadowOffset: { width: -2, height: 0 },
311+
shadowOpacity: 0.25,
312+
shadowRadius: 8,
313+
elevation: 16,
314+
},
315+
header: {
316+
flexDirection: "row",
317+
alignItems: "center",
318+
justifyContent: "space-between",
319+
paddingHorizontal: 16,
320+
paddingVertical: 12,
321+
borderBottomWidth: StyleSheet.hairlineWidth,
322+
borderBottomColor: "#e0e0e0",
323+
},
324+
headerTitle: {
325+
fontSize: 18,
326+
fontWeight: "600",
327+
color: "#1a1a1a",
328+
},
329+
closeButton: {
330+
fontSize: 20,
331+
color: "#666",
332+
padding: 4,
333+
},
334+
chatContainer: {
335+
flex: 1,
336+
},
337+
fab: {
338+
position: "absolute",
339+
bottom: 24,
340+
right: 24,
341+
width: FAB_SIZE,
342+
height: FAB_SIZE,
343+
borderRadius: FAB_SIZE / 2,
344+
backgroundColor: "#007AFF",
345+
alignItems: "center",
346+
justifyContent: "center",
347+
shadowColor: "#000",
348+
shadowOffset: { width: 0, height: 2 },
349+
shadowOpacity: 0.3,
350+
shadowRadius: 4,
351+
elevation: 8,
352+
zIndex: 998,
353+
},
354+
fabIcon: {
355+
fontSize: 24,
356+
},
357+
});

0 commit comments

Comments
 (0)