chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
} from "react";
import type { ReactNode } from "react";
import { useAgent } from "@copilotkit/react-core/v2/headless";
import { useCopilotKit } from "@copilotkit/react-core/v2/context";
import { DEFAULT_AGENT_ID, randomUUID } from "@copilotkit/shared";
import type { InputContent } from "@copilotkit/shared";
import type { CopilotKitCoreErrorCode } from "@copilotkit/core";
import { useAttachments } from "./hooks/use-attachments";
import type { NativeAttachmentsConfig } from "./hooks/use-attachments";
import type { Attachment } from "@copilotkit/shared";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
export interface CopilotChatContextValue {
/** The resolved agent instance. */
agent: any;
/** Whether the agent is currently running. */
isRunning: boolean;
/** Current messages in the conversation. */
messages: any[];
/** Currently selected attachments (uploading + ready). */
attachments: Attachment[];
/** Whether attachments are enabled. */
attachmentsEnabled: boolean;
/** Open the native document picker to add files. */
openPicker: () => Promise<void>;
/** Remove an attachment by ID. */
removeAttachment: (id: string) => void;
/**
* Submit a message with optional attachments.
* Handles consuming ready attachments, building InputContent[],
* calling agent.addMessage, and running the agent.
*/
submitMessage: (text: string) => Promise<void>;
}
const CopilotChatCtx = createContext<CopilotChatContextValue | null>(null);
/**
* Hook to access the CopilotChat context from child components.
* Must be called inside a `<CopilotChat>` component tree.
*/
export function useCopilotChatContext(): CopilotChatContextValue {
const ctx = useContext(CopilotChatCtx);
if (!ctx) {
throw new Error(
"useCopilotChatContext must be used within a <CopilotChat> component",
);
}
return ctx;
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface CopilotChatBaseProps {
/**
* The agent ID to use for this chat session.
* Matches the web SDK's CopilotChat `agentId` prop.
*
* Resolution order: `agentId` > `agentName` > `"default"`
*/
agentId?: string;
/**
* @deprecated Use `agentId` instead. `agentName` is kept for backwards
* compatibility and will be removed in a future release.
*/
agentName?: string;
/**
* Thread ID for this chat session. When provided, the chat will resume
* the specified thread. Matches the web SDK's CopilotChat `threadId` prop.
*/
threadId?: string;
/**
* Error handler scoped to this chat's agent. Fires in addition to the
* provider-level onError (does not suppress it). Receives only errors
* whose context.agentId matches this chat's agent.
*/
onError?: (event: {
error: Error;
code: CopilotKitCoreErrorCode;
context: Record<string, any>;
}) => void | Promise<void>;
/**
* Throttle interval (in milliseconds) for re-renders triggered by message
* change notifications. Overrides the provider-level `defaultThrottleMs`
* for this chat instance. Forwarded to the internal `useAgent()` hook.
*
* @default undefined -- inherits from provider `defaultThrottleMs`;
* if that is also unset, re-renders are unthrottled.
*/
throttleMs?: number;
/**
* Enable multimodal file attachments (images, audio, video, documents).
* Pass a NativeAttachmentsConfig object to configure file picking behavior.
*/
attachments?: NativeAttachmentsConfig;
/**
* Optional children rendered inside the chat context.
*/
children?: ReactNode;
}
export interface CopilotChatProps extends CopilotChatBaseProps {
/** Passthrough props are forwarded to consumers via the agent context. */
[key: string]: unknown;
}
/**
* Headless CopilotChat component for React Native.
*
* Wires up the `useAgent` hook with `agentId` resolution and renders children.
* Unlike the web SDK's CopilotChat, this component does not render any UI
* elements -- consumers provide their own React Native views.
*
* Children can access chat state via `useCopilotChatContext()`.
*
* ```tsx
* import { CopilotChat, useCopilotChatContext } from "@copilotkit/react-native";
*
* function MyChatUI() {
* const { messages, submitMessage, attachments, openPicker } = useCopilotChatContext();
* // ... render your UI
* }
*
* <CopilotChat agentId="my-agent" attachments={{ enabled: true }}>
* <MyChatUI />
* </CopilotChat>
* ```
*/
export function CopilotChat({
agentId,
agentName,
threadId,
onError,
throttleMs,
attachments: attachmentsConfig,
children,
..._rest
}: CopilotChatProps) {
const resolvedAgentId = agentId ?? agentName ?? DEFAULT_AGENT_ID;
// Deprecation warning (dev only, fires once per mount)
const warnedRef = useRef(false);
useEffect(() => {
if (
agentName !== undefined &&
agentId === undefined &&
!warnedRef.current
) {
warnedRef.current = true;
if (typeof __DEV__ === "undefined" || __DEV__) {
console.warn(
"[CopilotKit] agentName is deprecated, use agentId instead",
);
}
}
}, [agentName, agentId]);
const { agent } = useAgent({ agentId: resolvedAgentId, throttleMs });
// Set threadId on the agent when provided
useEffect(() => {
if (threadId) {
agent.threadId = threadId;
}
}, [agent, threadId]);
// onError subscription -- forward core errors scoped to this chat's agent
const { copilotkit } = useCopilotKit();
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
useEffect(() => {
if (!onErrorRef.current) return;
const subscription = copilotkit.subscribe({
onError: (event) => {
// Only forward errors that match this chat's agent
if (
event.context?.agentId === resolvedAgentId ||
!event.context?.agentId
) {
onErrorRef.current?.({
error: event.error,
code: event.code,
context: event.context,
});
}
},
});
return () => {
subscription.unsubscribe();
};
}, [copilotkit, resolvedAgentId]);
// Attachments
const {
attachments: selectedAttachments,
enabled: attachmentsEnabled,
openPicker,
removeAttachment,
consumeAttachments,
} = useAttachments({ config: attachmentsConfig });
// Submit handler -- mirrors web CopilotChat.tsx lines 234-288
const submitMessage = useCallback(
async (value: string) => {
// Block if uploads in progress
const hasUploading = selectedAttachments.some(
(a) => a.status === "uploading",
);
if (hasUploading) {
console.error(
"[CopilotKit] Cannot send while attachments are uploading",
);
return;
}
const readyAttachments = consumeAttachments();
if (readyAttachments.length > 0) {
const contentParts: InputContent[] = [];
if (value.trim()) {
contentParts.push({ type: "text", text: value });
}
for (const att of readyAttachments) {
contentParts.push({
type: att.type,
source: att.source,
metadata: {
...(att.filename ? { filename: att.filename } : {}),
...att.metadata,
},
} as InputContent);
}
agent.addMessage({
id: randomUUID(),
role: "user",
content: contentParts,
});
} else {
agent.addMessage({
id: randomUUID(),
role: "user",
content: value,
});
}
try {
await copilotkit.runAgent({ agent });
} catch (error) {
console.error("CopilotChat: runAgent failed", error);
}
},
// copilotkit is intentionally excluded -- it is a stable ref that never changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[agent, selectedAttachments, consumeAttachments],
);
const contextValue: CopilotChatContextValue = {
agent,
isRunning: agent.isRunning,
messages: agent.messages,
attachments: selectedAttachments,
attachmentsEnabled,
openPicker,
removeAttachment,
submitMessage,
};
return (
<CopilotChatCtx.Provider value={contextValue}>
{children}
</CopilotChatCtx.Provider>
);
}
@@ -0,0 +1,239 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
CopilotKitContext,
LicenseContext,
} from "@copilotkit/react-core/v2/context";
import type {
CopilotKitContextValue,
CopilotKitCoreReact as CopilotKitCoreReactInstance,
} from "@copilotkit/react-core/v2/context";
import { CopilotKitCoreReact } from "@copilotkit/react-core/v2/headless";
import type { CopilotKitCoreErrorCode } from "@copilotkit/core";
import type { DebugConfig, RuntimeLicenseStatus } from "@copilotkit/shared";
import { createLicenseContextValue } from "@copilotkit/shared";
import { RenderToolProvider } from "./hooks/RenderToolContext";
export interface CopilotKitNativeProviderProps {
children: ReactNode;
/** URL of the CopilotKit runtime endpoint */
runtimeUrl: string;
/** Custom headers sent with every request */
headers?: Record<string, string> | (() => Record<string, string>);
/**
* Credentials mode for fetch requests (e.g., "include" for HTTP-only cookies in cross-origin requests).
*/
credentials?: RequestCredentials;
/** Whether the runtime uses a single-route endpoint */
useSingleEndpoint?: boolean;
/** Custom properties forwarded to agents */
properties?: Record<string, unknown>;
/**
* Error handler called when CopilotKit encounters an error.
* Fires for all error types (runtime connection failures, agent errors, tool errors).
* If not provided, errors are logged to console.error.
*/
onError?: (event: {
error: Error;
code: CopilotKitCoreErrorCode;
context: Record<string, any>;
}) => void | Promise<void>;
/**
* Enable debug logging for the client-side event pipeline.
* When `true`, enables verbose logging from the core instance.
*/
debug?: DebugConfig;
/**
* Default throttle interval (ms) for `onMessagesChanged` / `onStateChanged`
* subscriptions. Individual subscriptions can override with their own `throttleMs`.
*/
defaultThrottleMs?: number;
// Cloud features (publicApiKey, licenseToken) — not yet supported on React Native
}
/**
* CopilotKit provider for React Native.
*
* A lightweight alternative to the web CopilotKitProvider that avoids
* web-only dependencies (DOM, CSS, Radix UI, Lit, etc).
*
* Polyfills are auto-imported when `@copilotkit/react-native` is loaded,
* so a separate `import "@copilotkit/react-native/polyfills"` is no longer
* required (though it remains available for advanced use).
*
* Usage:
* ```tsx
* import { CopilotKitProvider } from "@copilotkit/react-native";
*
* function App() {
* return (
* <CopilotKitProvider runtimeUrl="https://your-runtime/api/copilotkit">
* <ChatScreen />
* </CopilotKitProvider>
* );
* }
* ```
*/
export const CopilotKitProvider: React.FC<CopilotKitNativeProviderProps> = ({
children,
runtimeUrl,
headers: headersProp,
credentials,
useSingleEndpoint,
properties,
onError,
debug,
defaultThrottleMs,
}) => {
// Resolve headers from function or static object (matches web provider pattern)
const resolvedHeaders =
typeof headersProp === "function" ? headersProp() : headersProp;
// Stabilize headers/properties references to avoid effect churn when callers
// pass inline object literals (e.g. headers={{}} or the undefined default).
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableHeaders = useMemo(
() => resolvedHeaders ?? {},
[JSON.stringify(resolvedHeaders)],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const stableProperties = useMemo(
() => properties ?? {},
[JSON.stringify(properties)],
);
const copilotkitRef = useRef<CopilotKitCoreReactInstance | null>(null);
if (copilotkitRef.current === null) {
const instance: CopilotKitCoreReactInstance = new CopilotKitCoreReact({
runtimeUrl,
runtimeTransport:
useSingleEndpoint === true
? "single"
: useSingleEndpoint === false
? "rest"
: "auto",
headers: stableHeaders,
credentials,
properties: stableProperties,
debug,
});
// Set initial defaultThrottleMs synchronously so child hooks see the
// correct value on their first render (before useEffect fires).
if (defaultThrottleMs !== undefined) {
instance.setDefaultThrottleMs(defaultThrottleMs);
}
copilotkitRef.current = instance;
}
const copilotkit = copilotkitRef.current;
// Sync props to core instance
useEffect(() => {
copilotkit.setRuntimeUrl(runtimeUrl);
copilotkit.setRuntimeTransport(
useSingleEndpoint === true
? "single"
: useSingleEndpoint === false
? "rest"
: "auto",
);
copilotkit.setHeaders(stableHeaders);
copilotkit.setCredentials(credentials);
copilotkit.setProperties(stableProperties);
copilotkit.setDebug(debug);
}, [
runtimeUrl,
useSingleEndpoint,
stableHeaders,
credentials,
stableProperties,
debug,
copilotkit,
]);
// Sync defaultThrottleMs to the core instance on prop changes.
// Initial value is set synchronously during instance creation (inside the
// ref guard above), so this only handles subsequent updates.
useEffect(() => {
copilotkit.setDefaultThrottleMs(defaultThrottleMs);
}, [copilotkit, defaultThrottleMs]);
// Track executing tool call IDs at the provider level.
// Critical for HITL reconnection: onToolExecutionStart fires before child
// components mount, so we must capture the state here.
const [executingToolCallIds, setExecutingToolCallIds] = useState<
ReadonlySet<string>
>(() => new Set());
const [runtimeLicenseStatus, setRuntimeLicenseStatus] = useState<
RuntimeLicenseStatus | undefined
>(undefined);
// Use ref to avoid subscription churn when onError changes
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
// Single subscription for tool execution tracking and error handling.
// Tool call IDs are tracked at the provider level because onToolExecutionStart
// fires before child components mount — critical for HITL reconnection.
useEffect(() => {
const subscription = copilotkit.subscribe({
onToolExecutionStart: ({ toolCallId }) => {
setExecutingToolCallIds((prev) => {
if (prev.has(toolCallId)) return prev;
const next = new Set(prev);
next.add(toolCallId);
return next;
});
},
onToolExecutionEnd: ({ toolCallId }) => {
setExecutingToolCallIds((prev) => {
if (!prev.has(toolCallId)) return prev;
const next = new Set(prev);
next.delete(toolCallId);
return next;
});
},
onError: (event) => {
if (onErrorRef.current) {
onErrorRef.current(event);
} else {
console.error(
`[CopilotKit] Error (${event.code}):`,
event.error,
event.context ?? {},
);
}
},
onRuntimeConnectionStatusChanged: () => {
setRuntimeLicenseStatus(copilotkit.licenseStatus);
},
});
return () => subscription.unsubscribe();
}, [copilotkit]);
const contextValue: CopilotKitContextValue = useMemo(
() => ({
copilotkit,
executingToolCallIds,
}),
[copilotkit, executingToolCallIds],
);
// License context — driven by server-reported status via /info endpoint
const licenseContextValue = useMemo(
() => createLicenseContextValue(runtimeLicenseStatus),
[runtimeLicenseStatus],
);
return (
<CopilotKitContext.Provider value={contextValue}>
<LicenseContext.Provider value={licenseContextValue}>
<RenderToolProvider>{children}</RenderToolProvider>
</LicenseContext.Provider>
</CopilotKitContext.Provider>
);
};
@@ -0,0 +1,32 @@
import React, { type ReactNode } from "react";
import { CopilotChat, type CopilotChatProps } from "./CopilotChat";
export interface CopilotModalProps extends CopilotChatProps {
/**
* Optional children rendered inside the modal context.
*/
children?: ReactNode;
}
/**
* Headless CopilotModal component for React Native.
*
* A thin wrapper around CopilotChat that mirrors the web SDK's CopilotModal
* API surface. On React Native, modal presentation is handled by the consumer
* (e.g. React Native's `Modal` component) -- this component only provides
* the agent wiring and prop resolution.
*
* ```tsx
* import { CopilotModal } from "@copilotkit/react-native";
* import { Modal } from "react-native";
*
* <Modal visible={isOpen}>
* <CopilotModal agentId="my-agent">
* <MyChatUI />
* </CopilotModal>
* </Modal>
* ```
*/
export function CopilotModal({ children, ...props }: CopilotModalProps) {
return <CopilotChat {...props}>{children}</CopilotChat>;
}
+359
View File
@@ -0,0 +1,359 @@
// NOTE: This component needs to be exported from index.ts
// e.g. export { CopilotPopup } from "./CopilotPopup";
// export type { CopilotPopupProps, CopilotPopupHandle } from "./CopilotPopup";
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import type { ReactNode } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from "react-native";
import type { ViewStyle } from "react-native";
import { CopilotChat } from "./CopilotChat";
import type { NativeAttachmentsConfig } from "./hooks/use-attachments";
import type { CopilotKitCoreErrorCode } from "@copilotkit/core";
export interface CopilotPopupProps {
/**
* The agent ID to use for this chat session.
* Passed through to CopilotChat.
*/
agentId?: string;
/**
* @deprecated Use `agentId` instead.
*/
agentName?: string;
/**
* Thread ID for this chat session.
*/
threadId?: string;
/**
* Throttle interval (ms) for re-renders.
*/
throttleMs?: number;
/**
* Whether the popup starts in the open state.
* @default false
*/
defaultOpen?: boolean;
/**
* Height of the popup card. Accepts a number (points) or a percentage
* string (e.g. "60%") relative to the screen height.
* @default "60%"
*/
height?: number | string;
/**
* Error handler scoped to this popup's chat agent.
*/
onError?: (error: Error) => void;
/**
* Title displayed in the popup header bar.
* @default "CopilotKit"
*/
headerTitle?: string;
/**
* Enable multimodal file attachments. Forwarded to the internal CopilotChat.
* Children access attachment state via `useCopilotChatContext()`.
*/
attachments?: NativeAttachmentsConfig;
/**
* Optional children rendered below the CopilotChat content
* inside the popup card.
*/
children?: ReactNode;
/**
* Callback fired when the popup opens.
*/
onOpen?: () => void;
/**
* Callback fired when the popup closes.
*/
onClose?: () => void;
/**
* Whether tapping the semi-transparent backdrop dismisses the popup.
* Equivalent to web SDK's `clickOutsideToClose`.
* @default true
*/
dismissOnBackdropPress?: boolean;
/**
* Whether to show the floating action button (FAB) that toggles the popup.
* @default true
*/
showToggleButton?: boolean;
/**
* Custom styles applied to the popup card container.
*/
style?: ViewStyle;
}
/**
* Imperative handle exposed via ref for controlling the popup programmatically.
*/
export interface CopilotPopupHandle {
open: () => void;
close: () => void;
toggle: () => void;
}
/**
* CopilotPopup for React Native.
*
* A floating action button (FAB) that opens a modal chat overlay.
* The popup appears as a card floating above content with rounded corners,
* a shadow, and a semi-transparent backdrop.
*
* ```tsx
* import { CopilotPopup } from "@copilotkit/react-native";
*
* const popupRef = useRef<CopilotPopupHandle>(null);
*
* <CopilotPopup
* ref={popupRef}
* agentId="my-agent"
* headerTitle="Chat"
* defaultOpen={false}
* />
* ```
*/
export const CopilotPopup = forwardRef<CopilotPopupHandle, CopilotPopupProps>(
function CopilotPopup(
{
agentId,
agentName,
threadId,
throttleMs,
defaultOpen = false,
height = "60%",
onError,
headerTitle = "CopilotKit",
attachments: attachmentsConfig,
children,
onOpen,
onClose,
dismissOnBackdropPress = true,
showToggleButton = true,
style,
}: CopilotPopupProps,
ref: React.Ref<CopilotPopupHandle>,
) {
const [visible, setVisible] = useState(defaultOpen);
const { height: screenHeight } = useWindowDimensions();
// Stable refs for callbacks to avoid effect churn
const onOpenRef = useRef(onOpen);
const onCloseRef = useRef(onClose);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const handleOpen = useCallback(() => {
setVisible(true);
onOpenRef.current?.();
}, []);
const handleClose = useCallback(() => {
setVisible(false);
onCloseRef.current?.();
}, []);
const handleToggle = useCallback(() => {
setVisible((prev) => {
const next = !prev;
if (next) {
onOpenRef.current?.();
} else {
onCloseRef.current?.();
}
return next;
});
}, []);
// Expose imperative methods
useImperativeHandle(
ref,
() => ({
open: handleOpen,
close: handleClose,
toggle: handleToggle,
}),
[handleOpen, handleClose, handleToggle],
);
// Resolve popup height
const resolvedHeight =
typeof height === "string" && height.endsWith("%")
? (parseFloat(height) / 100) * screenHeight
: typeof height === "number"
? height
: 0.6 * screenHeight;
// Wrap onError to match CopilotChat's expected signature
const chatOnError = onError
? (event: {
error: Error;
code: CopilotKitCoreErrorCode;
context: Record<string, any>;
}) => onError(event.error)
: undefined;
return (
<>
{/* Floating Action Button */}
{showToggleButton && !visible && (
<TouchableOpacity
testID="copilot-popup-fab"
style={styles.fab}
onPress={handleToggle}
activeOpacity={0.8}
accessibilityLabel="Open chat"
accessibilityRole="button"
>
<Text style={styles.fabIcon}>💬</Text>
</TouchableOpacity>
)}
{/* Modal Overlay */}
<Modal
testID="copilot-popup-modal"
visible={visible}
transparent
animationType="slide"
onRequestClose={handleClose}
>
{/* Backdrop */}
<Pressable
testID="copilot-popup-backdrop"
style={styles.backdrop}
onPress={dismissOnBackdropPress ? handleClose : undefined}
>
{/* Card — stop propagation so tapping the card doesn't dismiss */}
<Pressable
testID="copilot-popup-card"
style={[styles.card, { height: resolvedHeight }, style]}
onPress={() => {
// Prevent backdrop press from firing
}}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>{headerTitle}</Text>
<TouchableOpacity
testID="copilot-popup-close"
onPress={handleClose}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityLabel="Close chat"
accessibilityRole="button"
>
<Text style={styles.closeButton}></Text>
</TouchableOpacity>
</View>
{/* Chat Content */}
<View style={styles.chatContainer}>
<CopilotChat
agentId={agentId}
agentName={agentName}
threadId={threadId}
throttleMs={throttleMs}
onError={chatOnError}
attachments={attachmentsConfig}
>
{children}
</CopilotChat>
</View>
</Pressable>
</Pressable>
</Modal>
</>
);
},
);
const styles = StyleSheet.create({
fab: {
position: "absolute",
bottom: 24,
right: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#6366f1",
alignItems: "center",
justifyContent: "center",
elevation: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.27,
shadowRadius: 4.65,
},
fabIcon: {
fontSize: 24,
},
backdrop: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "flex-end",
},
card: {
backgroundColor: "#ffffff",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
overflow: "hidden",
elevation: 10,
shadowColor: "#000",
shadowOffset: { width: 0, height: -3 },
shadowOpacity: 0.25,
shadowRadius: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e7eb",
},
headerTitle: {
fontSize: 17,
fontWeight: "600",
color: "#111827",
},
closeButton: {
fontSize: 18,
color: "#6b7280",
fontWeight: "500",
},
chatContainer: {
flex: 1,
},
});
@@ -0,0 +1,357 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import type { ReactNode } from "react";
import {
Animated,
Dimensions,
Pressable,
StyleSheet,
Text,
useWindowDimensions,
View,
} from "react-native";
import type { ViewStyle } from "react-native";
import { CopilotChat } from "./CopilotChat";
import type { CopilotChatBaseProps } from "./CopilotChat";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CopilotSidebarHandle {
/** Slide the drawer open. */
open(): void;
/** Slide the drawer closed. */
close(): void;
/** Toggle the drawer open/closed. */
toggle(): void;
}
export interface CopilotSidebarProps extends Omit<
CopilotChatBaseProps,
"children"
> {
/**
* Start the drawer in the open position.
* @default false
*/
defaultOpen?: boolean;
/**
* Width of the drawer panel. Accepts a number (points) or a percentage
* string (e.g. `"85%"`). Defaults to 85% of the screen width.
*/
width?: number | string;
/**
* Title displayed in the drawer header bar.
* @default "Copilot"
*/
headerTitle?: string;
/**
* Show a floating action button to toggle the drawer.
* @default true
*/
showToggleButton?: boolean;
/** Called after the drawer finishes opening. */
onOpen?: () => void;
/** Called after the drawer finishes closing. */
onClose?: () => void;
/** Custom style applied to the drawer container. */
style?: ViewStyle;
/** Content rendered inside the drawer below the chat area. */
children?: ReactNode;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ANIMATION_DURATION_MS = 300;
const DEFAULT_HEADER_TITLE = "Copilot";
const BACKDROP_OPACITY = 0.4;
const FAB_SIZE = 56;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* CopilotSidebar -- a slide-in drawer from the right edge of the screen
* that wraps CopilotChat for React Native.
*
* ```tsx
* import { CopilotSidebar } from "@copilotkit/react-native";
*
* const ref = useRef<CopilotSidebarHandle>(null);
*
* <CopilotSidebar
* ref={ref}
* agentId="my-agent"
* headerTitle="Assistant"
* defaultOpen={false}
* />
* ```
*/
export const CopilotSidebar = forwardRef<
CopilotSidebarHandle,
CopilotSidebarProps
>(function CopilotSidebar(
{
agentId,
agentName,
threadId,
onError,
throttleMs,
defaultOpen = false,
width: widthProp,
headerTitle = DEFAULT_HEADER_TITLE,
showToggleButton = true,
onOpen,
onClose,
style,
children,
...rest
},
ref,
) {
const { width: screenWidth } = useWindowDimensions();
// Resolve drawer width ---------------------------------------------------
const drawerWidth = resolveWidth(widthProp, screenWidth);
// Animation & open state -------------------------------------------------
const [isOpen, setIsOpen] = useState(defaultOpen);
const slideAnim = useRef(
new Animated.Value(defaultOpen ? 0 : drawerWidth),
).current;
// Keep the animated value in sync when drawerWidth changes while closed
useEffect(() => {
if (!isOpen) {
slideAnim.setValue(drawerWidth);
}
}, [drawerWidth, isOpen, slideAnim]);
const animateTo = useCallback(
(toValue: number, cb?: () => void) => {
Animated.timing(slideAnim, {
toValue,
duration: ANIMATION_DURATION_MS,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) cb?.();
});
},
[slideAnim],
);
const open = useCallback(() => {
setIsOpen(true);
animateTo(0, onOpen);
}, [animateTo, onOpen]);
const close = useCallback(() => {
animateTo(drawerWidth, () => {
setIsOpen(false);
onClose?.();
});
}, [animateTo, drawerWidth, onClose]);
const toggle = useCallback(() => {
if (isOpen) {
close();
} else {
open();
}
}, [isOpen, open, close]);
useImperativeHandle(ref, () => ({ open, close, toggle }), [
open,
close,
toggle,
]);
// Callbacks stored in refs for stable animation closures -----------------
const onOpenRef = useRef(onOpen);
const onCloseRef = useRef(onClose);
useEffect(() => {
onOpenRef.current = onOpen;
}, [onOpen]);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
// -----------------------------------------------------------------------
// Render
// -----------------------------------------------------------------------
return (
<>
{/* Backdrop */}
{isOpen && (
<Pressable
style={styles.backdrop}
onPress={close}
accessibilityRole="button"
accessibilityLabel="Close sidebar"
testID="copilot-sidebar-backdrop"
/>
)}
{/* Drawer */}
{isOpen && (
<Animated.View
style={[
styles.drawer,
{ width: drawerWidth, transform: [{ translateX: slideAnim }] },
style,
]}
testID="copilot-sidebar-drawer"
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>{headerTitle}</Text>
<Pressable
onPress={close}
accessibilityRole="button"
accessibilityLabel="Close"
hitSlop={8}
testID="copilot-sidebar-close"
>
<Text style={styles.closeButton}>{"✕"}</Text>
</Pressable>
</View>
{/* Chat area */}
<View style={styles.chatContainer}>
<CopilotChat
agentId={agentId}
agentName={agentName}
threadId={threadId}
onError={onError}
throttleMs={throttleMs}
{...rest}
>
{children}
</CopilotChat>
</View>
</Animated.View>
)}
{/* Floating action button */}
{showToggleButton && !isOpen && (
<Pressable
style={styles.fab}
onPress={open}
accessibilityRole="button"
accessibilityLabel="Open sidebar"
testID="copilot-sidebar-fab"
>
<Text style={styles.fabIcon}>{"💬"}</Text>
</Pressable>
)}
</>
);
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveWidth(
widthProp: number | string | undefined,
screenWidth: number,
): number {
if (widthProp === undefined) {
return Math.round(screenWidth * 0.85);
}
if (typeof widthProp === "number") {
return widthProp;
}
// Percentage string, e.g. "85%"
const pctMatch = String(widthProp).match(/^(\d+(?:\.\d+)?)%$/);
if (pctMatch) {
return Math.round(screenWidth * (parseFloat(pctMatch[1]) / 100));
}
// Fallback: try parsing as number
const parsed = parseFloat(widthProp);
return isNaN(parsed) ? Math.round(screenWidth * 0.85) : parsed;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFill,
backgroundColor: `rgba(0, 0, 0, ${BACKDROP_OPACITY})`,
zIndex: 999,
},
drawer: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
backgroundColor: "#ffffff",
zIndex: 1000,
shadowColor: "#000",
shadowOffset: { width: -2, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 16,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e0e0e0",
},
headerTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1a1a1a",
},
closeButton: {
fontSize: 20,
color: "#666",
padding: 4,
},
chatContainer: {
flex: 1,
},
fab: {
position: "absolute",
bottom: 24,
right: 24,
width: FAB_SIZE,
height: FAB_SIZE,
borderRadius: FAB_SIZE / 2,
backgroundColor: "#007AFF",
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 8,
zIndex: 998,
},
fabIcon: {
fontSize: 24,
},
});
@@ -0,0 +1,16 @@
/**
* Fallback module for react-native-streamdown.
*
* react-native-streamdown is a peer dependency that isn't installed during
* monorepo development. This stub satisfies vite's import resolution so
* that vi.mock() in test files can override it at runtime.
*/
import React from "react";
export function StreamdownText(props: {
markdown: string;
markdownStyle?: Record<string, unknown>;
streamingAnimation?: boolean;
}) {
return React.createElement("div", null, props.markdown);
}
@@ -0,0 +1,52 @@
/**
* Stub module for react-native in vitest.
*
* The real react-native package uses Flow syntax (`import typeof`) that
* vite/rollup cannot parse. This stub provides minimal mocks so that
* modules importing from "react-native" can be resolved during testing.
* Individual test files can still override with vi.mock("react-native", ...).
*/
import React from "react";
export const StyleSheet = {
create: <T extends Record<string, unknown>>(styles: T): T => styles,
hairlineWidth: 1,
};
function createMockComponent(name: string) {
return React.forwardRef(function MockComponent(props: any, ref: any) {
return React.createElement(name, { ...props, ref });
});
}
export const View = createMockComponent("View");
export const Text = createMockComponent("Text");
export const TextInput = createMockComponent("TextInput");
export const TouchableOpacity = createMockComponent("TouchableOpacity");
export const Pressable = createMockComponent("Pressable");
export const FlatList = createMockComponent("FlatList");
export const KeyboardAvoidingView = createMockComponent("KeyboardAvoidingView");
export const ScrollView = createMockComponent("ScrollView");
export const ActivityIndicator = createMockComponent("ActivityIndicator");
export const Image = createMockComponent("Image");
export const Platform = {
OS: "ios" as const,
select: <T>(obj: { ios?: T; android?: T; default?: T }): T | undefined =>
obj.ios ?? obj.default,
};
export default {
StyleSheet,
View,
Text,
TextInput,
TouchableOpacity,
Pressable,
FlatList,
KeyboardAvoidingView,
ScrollView,
ActivityIndicator,
Image,
Platform,
};
@@ -0,0 +1,13 @@
// Stub for expo-document-picker — replaced at runtime by vi.mock() in tests.
// This file exists so Vite's import analysis can resolve the module.
export async function getDocumentAsync(_options?: unknown): Promise<{
canceled: boolean;
assets: Array<{
uri: string;
name: string;
size: number;
mimeType: string;
}>;
}> {
return { canceled: true, assets: [] };
}
@@ -0,0 +1,12 @@
// Stub for expo-file-system — replaced at runtime by vi.mock() in tests.
// This file exists so Vite's import analysis can resolve the module.
export const EncodingType = {
Base64: "base64",
} as const;
export async function readAsStringAsync(
_uri: string,
_options?: unknown,
): Promise<string> {
return "";
}
@@ -0,0 +1,336 @@
// packages/react-native/src/__tests__/attachments-integration.test.tsx
import React from "react";
import { render, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
const mockGetDocumentAsync = vi.fn();
const mockReadAsStringAsync = vi.fn();
vi.mock("expo-document-picker", () => ({
getDocumentAsync: (...args: any[]) => mockGetDocumentAsync(...args),
}));
vi.mock("expo-file-system", () => ({
readAsStringAsync: (...args: any[]) => mockReadAsStringAsync(...args),
EncodingType: { Base64: "base64" },
}));
vi.mock("react-native", () => {
const _React = require("react");
const View = ({ children, style, testID }: any) =>
_React.createElement("div", { "data-testid": testID, style }, children);
const Text = ({ children, style, testID }: any) =>
_React.createElement("span", { "data-testid": testID, style }, children);
const Pressable = ({ children, onPress, testID, style }: any) =>
_React.createElement(
"div",
{ "data-testid": testID, onClick: onPress, style },
children,
);
return {
View,
Text,
Pressable,
StyleSheet: { create: (s: any) => s, hairlineWidth: 1 },
Platform: { OS: "ios" },
};
});
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
let mockAgent: any;
let unsubscribeMock: ReturnType<typeof vi.fn>;
let mockRunAgent: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((_sub: any) => ({ unsubscribe: unsubscribeMock })),
subscribeToAgentWithOptions: vi.fn(() => ({ unsubscribe: vi.fn() })),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
getAgent: vi.fn(() => undefined),
runAgent: (...args: any[]) => mockRunAgent(...args),
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
runtimeConnectionStatus: "Disconnected",
headers: {},
agents: {},
defaultThrottleMs: undefined,
addTool: vi.fn(),
removeTool: vi.fn(),
getTool: vi.fn(() => undefined),
addHookRenderToolCall: vi.fn(),
registerThreadStore: vi.fn(),
unregisterThreadStore: vi.fn(),
intelligence: undefined,
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return {
CopilotKitCoreReact,
useAgent: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return { agent: mockAgent };
},
useFrontendTool: () => {},
useComponent: () => {},
useHumanInTheLoop: () => {},
useInterrupt: () => {},
useSuggestions: () => {},
useConfigureSuggestions: () => {},
useAgentContext: () => {},
useThreads: () => ({
threads: [],
isLoading: false,
error: null,
hasMoreThreads: false,
isFetchingMoreThreads: false,
fetchMoreThreads: () => {},
renameThread: async () => {},
archiveThread: async () => {},
deleteThread: async () => {},
}),
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return { copilotkit: ctx };
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
DEFAULT_AGENT_ID: "default",
randomUUID: () => "test-uuid-" + Math.random().toString(36).slice(2, 8),
getModalityFromMimeType: (mimeType: string) => {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
return "document";
},
formatFileSize: (bytes: number) => `${bytes} B`,
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
import { CopilotKitProvider } from "../CopilotKitProvider";
import { CopilotChat, useCopilotChatContext } from "../CopilotChat";
import type { CopilotChatContextValue } from "../CopilotChat";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Attachments integration: full pick -> attach -> submit flow", () => {
beforeEach(() => {
unsubscribeMock = vi.fn();
mockRunAgent = vi.fn().mockResolvedValue(undefined);
mockAgent = {
addMessage: vi.fn(),
isRunning: false,
messages: [],
threadId: undefined,
};
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("pick a file, see it in attachments, submit message with attachment data", async () => {
// Setup: DocumentPicker returns a JPEG file
mockGetDocumentAsync.mockResolvedValue({
canceled: false,
assets: [
{
uri: "file:///cache/photo.jpg",
name: "photo.jpg",
size: 4096,
mimeType: "image/jpeg",
},
],
});
mockReadAsStringAsync.mockResolvedValue("aGVsbG8="); // base64 "hello"
let chatCtx: CopilotChatContextValue | null = null;
function Consumer() {
chatCtx = useCopilotChatContext();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="test-agent" attachments={{ enabled: true }}>
<Consumer />
</CopilotChat>
</CopilotKitProvider>,
);
// Verify initial state
expect(chatCtx!.attachments).toEqual([]);
expect(chatCtx!.attachmentsEnabled).toBe(true);
// Step 1: Open picker
await act(async () => {
await chatCtx!.openPicker();
});
// Step 2: Verify attachment appeared
expect(chatCtx!.attachments).toHaveLength(1);
expect(chatCtx!.attachments[0]).toMatchObject({
type: "image",
filename: "photo.jpg",
size: 4096,
status: "ready",
source: {
type: "data",
value: "aGVsbG8=",
mimeType: "image/jpeg",
},
});
// Step 3: Submit message with text + attachment
await act(async () => {
await chatCtx!.submitMessage("Check this photo");
});
// Step 4: Verify addMessage was called with InputContent array
expect(mockAgent.addMessage).toHaveBeenCalledTimes(1);
const call = mockAgent.addMessage.mock.calls[0][0];
expect(call.role).toBe("user");
expect(Array.isArray(call.content)).toBe(true);
expect(call.content).toHaveLength(2);
// Text part
expect(call.content[0]).toMatchObject({
type: "text",
text: "Check this photo",
});
// Image part
expect(call.content[1]).toMatchObject({
type: "image",
source: {
type: "data",
value: "aGVsbG8=",
mimeType: "image/jpeg",
},
metadata: {
filename: "photo.jpg",
},
});
// Step 5: Verify attachments queue is now empty (consumed)
expect(chatCtx!.attachments).toHaveLength(0);
});
it("submit without attachments sends plain text", async () => {
let chatCtx: CopilotChatContextValue | null = null;
function Consumer() {
chatCtx = useCopilotChatContext();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="test-agent" attachments={{ enabled: true }}>
<Consumer />
</CopilotChat>
</CopilotKitProvider>,
);
await act(async () => {
await chatCtx!.submitMessage("Just text");
});
expect(mockAgent.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: "user",
content: "Just text",
}),
);
});
it("canceled picker does not add attachments", async () => {
mockGetDocumentAsync.mockResolvedValue({
canceled: true,
assets: [],
});
let chatCtx: CopilotChatContextValue | null = null;
function Consumer() {
chatCtx = useCopilotChatContext();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="test-agent" attachments={{ enabled: true }}>
<Consumer />
</CopilotChat>
</CopilotKitProvider>,
);
await act(async () => {
await chatCtx!.openPicker();
});
expect(chatCtx!.attachments).toHaveLength(0);
});
});
@@ -0,0 +1,305 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// Track the agentId passed to useAgent across renders
let capturedAgentId: string | undefined;
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
let unsubscribeMock: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((_subscriber: any) => ({
unsubscribe: unsubscribeMock,
})),
subscribeToAgentWithOptions: vi.fn((_agent: any, _handlers: any) => ({
unsubscribe: vi.fn(),
})),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
getAgent: vi.fn(() => undefined),
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
runtimeConnectionStatus: "Disconnected",
headers: {},
agents: {},
defaultThrottleMs: undefined,
addTool: vi.fn(),
removeTool: vi.fn(),
getTool: vi.fn(() => undefined),
addHookRenderToolCall: vi.fn(),
registerThreadStore: vi.fn(),
unregisterThreadStore: vi.fn(),
intelligence: undefined,
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return {
CopilotKitCoreReact,
useAgent: (props: any) => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
capturedAgentId = props?.agentId;
return { agent: {} };
},
useFrontendTool: () => {},
useComponent: () => {},
useHumanInTheLoop: () => {},
useInterrupt: () => {},
useSuggestions: () => {},
useConfigureSuggestions: () => {},
useAgentContext: () => {},
useThreads: () => ({
threads: [],
isLoading: false,
error: null,
hasMoreThreads: false,
isFetchingMoreThreads: false,
fetchMoreThreads: () => {},
renameThread: async () => {},
archiveThread: async () => {},
deleteThread: async () => {},
}),
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return ctx;
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
DEFAULT_AGENT_ID: "default",
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
// Import after mocks
import { CopilotKitProvider } from "../CopilotKitProvider";
import { CopilotChat } from "../CopilotChat";
import { CopilotModal } from "../CopilotModal";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotChat agentId resolution", () => {
beforeEach(() => {
capturedAgentId = undefined;
unsubscribeMock = vi.fn();
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
it("uses agentId when provided", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="my-agent" />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("my-agent");
});
it("falls back to agentName when agentId is not provided", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentName="legacy-agent" />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("legacy-agent");
});
it("agentId takes priority over agentName when both provided", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="primary" agentName="legacy" />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("primary");
});
it("falls back to DEFAULT_AGENT_ID when neither provided", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("default");
});
it("logs deprecation warning when agentName is used without agentId", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentName="old-name" />
</CopilotKitProvider>,
);
expect(warnSpy).toHaveBeenCalledWith(
"[CopilotKit] agentName is deprecated, use agentId instead",
);
warnSpy.mockRestore();
});
it("does not log deprecation warning when agentId is used", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="my-agent" />
</CopilotKitProvider>,
);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it("does not log deprecation warning when both agentId and agentName are provided", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="primary" agentName="legacy" />
</CopilotKitProvider>,
);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it("renders children", () => {
const { getByText } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat agentId="my-agent">
<span>Hello Chat</span>
</CopilotChat>
</CopilotKitProvider>,
);
expect(getByText("Hello Chat")).toBeTruthy();
});
});
describe("CopilotModal agentId resolution", () => {
beforeEach(() => {
capturedAgentId = undefined;
unsubscribeMock = vi.fn();
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
it("uses agentId when provided", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotModal agentId="modal-agent" />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("modal-agent");
});
it("falls back to agentName for backwards compatibility", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotModal agentName="legacy-modal" />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("legacy-modal");
});
it("agentId takes priority over agentName", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotModal agentId="new" agentName="old" />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("new");
});
it("logs deprecation warning when agentName used without agentId", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotModal agentName="old-modal" />
</CopilotKitProvider>,
);
expect(warnSpy).toHaveBeenCalledWith(
"[CopilotKit] agentName is deprecated, use agentId instead",
);
warnSpy.mockRestore();
});
});
@@ -0,0 +1,259 @@
// packages/react-native/src/__tests__/copilot-chat-attachments.test.tsx
import React from "react";
import { render, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// Mock expo modules (useAttachments imports these)
vi.mock("expo-document-picker", () => ({
getDocumentAsync: vi.fn().mockResolvedValue({ canceled: true, assets: [] }),
}));
vi.mock("expo-file-system", () => ({
readAsStringAsync: vi.fn().mockResolvedValue("base64data"),
EncodingType: { Base64: "base64" },
}));
// Mock react-native
vi.mock("react-native", () => {
const _React = require("react");
const View = ({ children, style, testID }: any) =>
_React.createElement("div", { "data-testid": testID, style }, children);
const Text = ({ children, style, testID }: any) =>
_React.createElement("span", { "data-testid": testID, style }, children);
const Pressable = ({ children, onPress, testID, style }: any) =>
_React.createElement(
"div",
{ "data-testid": testID, onClick: onPress, style },
children,
);
return {
View,
Text,
Pressable,
StyleSheet: { create: (s: any) => s, hairlineWidth: 1 },
Platform: { OS: "ios" },
};
});
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
let mockAgent: any;
let unsubscribeMock: ReturnType<typeof vi.fn>;
let mockRunAgent: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((_sub: any) => ({ unsubscribe: unsubscribeMock })),
subscribeToAgentWithOptions: vi.fn(() => ({ unsubscribe: vi.fn() })),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
getAgent: vi.fn(() => undefined),
runAgent: mockRunAgent,
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
runtimeConnectionStatus: "Disconnected",
headers: {},
agents: {},
defaultThrottleMs: undefined,
addTool: vi.fn(),
removeTool: vi.fn(),
getTool: vi.fn(() => undefined),
addHookRenderToolCall: vi.fn(),
registerThreadStore: vi.fn(),
unregisterThreadStore: vi.fn(),
intelligence: undefined,
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return {
CopilotKitCoreReact,
useAgent: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return { agent: mockAgent };
},
useFrontendTool: () => {},
useComponent: () => {},
useHumanInTheLoop: () => {},
useInterrupt: () => {},
useSuggestions: () => {},
useConfigureSuggestions: () => {},
useAgentContext: () => {},
useThreads: () => ({
threads: [],
isLoading: false,
error: null,
hasMoreThreads: false,
isFetchingMoreThreads: false,
fetchMoreThreads: () => {},
renameThread: async () => {},
archiveThread: async () => {},
deleteThread: async () => {},
}),
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return { copilotkit: ctx };
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
DEFAULT_AGENT_ID: "default",
randomUUID: () => "test-uuid-" + Math.random().toString(36).slice(2, 8),
getModalityFromMimeType: (mimeType: string) => {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
return "document";
},
formatFileSize: (bytes: number) => `${bytes} B`,
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
import { CopilotKitProvider } from "../CopilotKitProvider";
import { CopilotChat, useCopilotChatContext } from "../CopilotChat";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotChat attachments integration", () => {
beforeEach(() => {
unsubscribeMock = vi.fn();
mockRunAgent = vi.fn().mockResolvedValue(undefined);
mockAgent = {
addMessage: vi.fn(),
isRunning: false,
messages: [],
threadId: undefined,
};
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
it("exposes attachment state via useCopilotChatContext", () => {
let ctx: any = null;
function Consumer() {
ctx = useCopilotChatContext();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat attachments={{ enabled: true }}>
<Consumer />
</CopilotChat>
</CopilotKitProvider>,
);
expect(ctx).not.toBeNull();
expect(ctx.attachments).toEqual([]);
expect(typeof ctx.openPicker).toBe("function");
expect(typeof ctx.removeAttachment).toBe("function");
expect(typeof ctx.submitMessage).toBe("function");
expect(ctx.attachmentsEnabled).toBe(true);
});
it("does not expose attachment functions when attachments not configured", () => {
let ctx: any = null;
function Consumer() {
ctx = useCopilotChatContext();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat>
<Consumer />
</CopilotChat>
</CopilotKitProvider>,
);
expect(ctx.attachmentsEnabled).toBe(false);
});
it("submitMessage sends plain text when no attachments", async () => {
let ctx: any = null;
function Consumer() {
ctx = useCopilotChatContext();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotChat attachments={{ enabled: true }}>
<Consumer />
</CopilotChat>
</CopilotKitProvider>,
);
await act(async () => {
await ctx.submitMessage("Hello world");
});
expect(mockAgent.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: "user",
content: "Hello world",
}),
);
});
});
@@ -0,0 +1,558 @@
import React, { createRef } from "react";
import { render, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// Track the agentId passed to useAgent across renders
let capturedAgentId: string | undefined;
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
let unsubscribeMock: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((_subscriber: any) => ({
unsubscribe: unsubscribeMock,
})),
subscribeToAgentWithOptions: vi.fn((_agent: any, _handlers: any) => ({
unsubscribe: vi.fn(),
})),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
getAgent: vi.fn(() => undefined),
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
runtimeConnectionStatus: "Disconnected",
headers: {},
agents: {},
defaultThrottleMs: undefined,
addTool: vi.fn(),
removeTool: vi.fn(),
getTool: vi.fn(() => undefined),
addHookRenderToolCall: vi.fn(),
registerThreadStore: vi.fn(),
unregisterThreadStore: vi.fn(),
intelligence: undefined,
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return {
CopilotKitCoreReact,
useAgent: (props: any) => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
capturedAgentId = props?.agentId;
return { agent: {} };
},
useFrontendTool: () => {},
useComponent: () => {},
useHumanInTheLoop: () => {},
useInterrupt: () => {},
useSuggestions: () => {},
useConfigureSuggestions: () => {},
useAgentContext: () => {},
useThreads: () => ({
threads: [],
isLoading: false,
error: null,
hasMoreThreads: false,
isFetchingMoreThreads: false,
fetchMoreThreads: () => {},
renameThread: async () => {},
archiveThread: async () => {},
deleteThread: async () => {},
}),
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return ctx;
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
DEFAULT_AGENT_ID: "default",
randomUUID: () => "test-uuid-" + Math.random().toString(36).slice(2, 8),
getModalityFromMimeType: (mimeType: string) => {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
return "document";
},
formatFileSize: (bytes: number) => `${bytes} B`,
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
vi.mock("expo-document-picker", () => ({
getDocumentAsync: vi.fn().mockResolvedValue({ canceled: true, assets: [] }),
}));
vi.mock("expo-file-system", () => ({
readAsStringAsync: vi.fn().mockResolvedValue("base64data"),
EncodingType: { Base64: "base64" },
}));
// Mock React Native components for jsdom environment
vi.mock("react-native", () => {
const _React = require("react");
// Simple mock components that render as divs with testIDs
const Modal = ({ children, visible, testID, onRequestClose }: any) => {
if (!visible) return null;
return _React.createElement(
"div",
{ "data-testid": testID, "data-visible": visible },
children,
);
};
const View = ({ children, style, testID }: any) =>
_React.createElement("div", { "data-testid": testID, style }, children);
const Text = ({ children, style, testID }: any) =>
_React.createElement("span", { "data-testid": testID, style }, children);
const TouchableOpacity = ({
children,
onPress,
testID,
accessibilityLabel,
style,
}: any) =>
_React.createElement(
"button",
{
"data-testid": testID,
onClick: onPress,
"aria-label": accessibilityLabel,
style,
},
children,
);
const Pressable = ({ children, onPress, testID, style }: any) =>
_React.createElement(
"div",
{ "data-testid": testID, onClick: onPress, style },
children,
);
const Animated = {
View,
Text,
createAnimatedComponent: (comp: any) => comp,
timing: () => ({ start: (cb?: any) => cb?.() }),
spring: () => ({ start: (cb?: any) => cb?.() }),
Value: class {
_value: number;
constructor(v: number) {
this._value = v;
}
setValue(v: number) {
this._value = v;
}
interpolate() {
return this;
}
},
};
const StyleSheet = {
create: (styles: any) => styles,
hairlineWidth: 1,
};
const useWindowDimensions = () => ({ width: 375, height: 812 });
return {
Modal,
View,
Text,
TouchableOpacity,
Pressable,
Animated,
StyleSheet,
useWindowDimensions,
Platform: { OS: "ios" },
};
});
// Import after mocks
import { CopilotKitProvider } from "../CopilotKitProvider";
import { CopilotPopup } from "../CopilotPopup";
import type { CopilotPopupHandle } from "../CopilotPopup";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotPopup", () => {
beforeEach(() => {
capturedAgentId = undefined;
unsubscribeMock = vi.fn();
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup />
</CopilotKitProvider>,
);
expect(container).toBeTruthy();
});
it("shows FAB when closed and hides FAB when open", () => {
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={false} />
</CopilotKitProvider>,
);
// FAB should be visible when popup is closed
expect(queryByTestId("copilot-popup-fab")).toBeTruthy();
// Modal should not be visible
expect(queryByTestId("copilot-popup-modal")).toBeNull();
});
describe("defaultOpen", () => {
it("starts closed by default", () => {
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup />
</CopilotKitProvider>,
);
// FAB visible = popup closed
expect(queryByTestId("copilot-popup-fab")).toBeTruthy();
expect(queryByTestId("copilot-popup-modal")).toBeNull();
});
it("starts open when defaultOpen=true", () => {
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={true} />
</CopilotKitProvider>,
);
// Modal should be visible when defaultOpen is true
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
// FAB should be hidden when popup is open
expect(queryByTestId("copilot-popup-fab")).toBeNull();
});
});
describe("imperative handle", () => {
it("open() makes the popup visible", () => {
const ref = createRef<CopilotPopupHandle>();
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup ref={ref} defaultOpen={false} />
</CopilotKitProvider>,
);
// Initially closed
expect(queryByTestId("copilot-popup-modal")).toBeNull();
// Call open()
act(() => {
ref.current!.open();
});
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
});
it("close() hides the popup", () => {
const ref = createRef<CopilotPopupHandle>();
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup ref={ref} defaultOpen={true} />
</CopilotKitProvider>,
);
// Initially open
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
// Call close()
act(() => {
ref.current!.close();
});
expect(queryByTestId("copilot-popup-modal")).toBeNull();
});
it("toggle() switches the popup state", () => {
const ref = createRef<CopilotPopupHandle>();
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup ref={ref} defaultOpen={false} />
</CopilotKitProvider>,
);
// Initially closed
expect(queryByTestId("copilot-popup-modal")).toBeNull();
// Toggle open
act(() => {
ref.current!.toggle();
});
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
// Toggle closed
act(() => {
ref.current!.toggle();
});
expect(queryByTestId("copilot-popup-modal")).toBeNull();
});
});
describe("dismissOnBackdropPress", () => {
it("closes when backdrop is pressed (default behavior)", () => {
const ref = createRef<CopilotPopupHandle>();
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup ref={ref} defaultOpen={true} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
// Click the backdrop
act(() => {
fireEvent.click(getByTestId("copilot-popup-backdrop"));
});
expect(queryByTestId("copilot-popup-modal")).toBeNull();
});
it("does NOT close when dismissOnBackdropPress=false", () => {
const ref = createRef<CopilotPopupHandle>();
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup
ref={ref}
defaultOpen={true}
dismissOnBackdropPress={false}
/>
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
// Click the backdrop — should NOT close
act(() => {
fireEvent.click(getByTestId("copilot-popup-backdrop"));
});
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
});
});
describe("agentId forwarding", () => {
it("forwards agentId to CopilotChat", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup agentId="popup-agent" defaultOpen={true} />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("popup-agent");
});
it("forwards agentName to CopilotChat when agentId not set", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup agentName="legacy-popup" defaultOpen={true} />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("legacy-popup");
});
it("agentId takes priority over agentName", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup
agentId="primary"
agentName="legacy"
defaultOpen={true}
/>
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("primary");
});
it("uses default agent ID when neither provided", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={true} />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("default");
});
});
it("renders children inside the popup", () => {
const { getByText } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={true}>
<span>Custom Content</span>
</CopilotPopup>
</CopilotKitProvider>,
);
expect(getByText("Custom Content")).toBeTruthy();
});
it("displays custom header title", () => {
const { getByText } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={true} headerTitle="My Chat" />
</CopilotKitProvider>,
);
expect(getByText("My Chat")).toBeTruthy();
});
it("calls onOpen and onClose callbacks", () => {
const onOpen = vi.fn();
const onClose = vi.fn();
const ref = createRef<CopilotPopupHandle>();
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup ref={ref} onOpen={onOpen} onClose={onClose} />
</CopilotKitProvider>,
);
act(() => {
ref.current!.open();
});
expect(onOpen).toHaveBeenCalledTimes(1);
act(() => {
ref.current!.close();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("hides FAB when showToggleButton=false", () => {
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup showToggleButton={false} defaultOpen={false} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-popup-fab")).toBeNull();
});
it("FAB toggles the popup open", () => {
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={false} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-popup-modal")).toBeNull();
act(() => {
fireEvent.click(getByTestId("copilot-popup-fab"));
});
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
});
it("close button in header closes the popup", () => {
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup defaultOpen={true} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-popup-modal")).toBeTruthy();
act(() => {
fireEvent.click(getByTestId("copilot-popup-close"));
});
expect(queryByTestId("copilot-popup-modal")).toBeNull();
});
describe("attachments prop forwarding", () => {
it("forwards attachments config to CopilotChat", () => {
expect(() => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotPopup
defaultOpen={true}
attachments={{ enabled: true, accept: "image/*" }}
/>
</CopilotKitProvider>,
);
}).not.toThrow();
});
});
});
@@ -0,0 +1,500 @@
import React, { createRef } from "react";
import { render, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// Track the agentId passed to useAgent across renders
let capturedAgentId: string | undefined;
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
let unsubscribeMock: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((_subscriber: any) => ({
unsubscribe: unsubscribeMock,
})),
subscribeToAgentWithOptions: vi.fn((_agent: any, _handlers: any) => ({
unsubscribe: vi.fn(),
})),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
getAgent: vi.fn(() => undefined),
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
runtimeConnectionStatus: "Disconnected",
headers: {},
agents: {},
defaultThrottleMs: undefined,
addTool: vi.fn(),
removeTool: vi.fn(),
getTool: vi.fn(() => undefined),
addHookRenderToolCall: vi.fn(),
registerThreadStore: vi.fn(),
unregisterThreadStore: vi.fn(),
intelligence: undefined,
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return {
CopilotKitCoreReact,
useAgent: (props: any) => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
capturedAgentId = props?.agentId;
return { agent: {} };
},
useFrontendTool: () => {},
useComponent: () => {},
useHumanInTheLoop: () => {},
useInterrupt: () => {},
useSuggestions: () => {},
useConfigureSuggestions: () => {},
useAgentContext: () => {},
useThreads: () => ({
threads: [],
isLoading: false,
error: null,
hasMoreThreads: false,
isFetchingMoreThreads: false,
fetchMoreThreads: () => {},
renameThread: async () => {},
archiveThread: async () => {},
deleteThread: async () => {},
}),
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return ctx;
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
DEFAULT_AGENT_ID: "default",
randomUUID: () => "test-uuid-" + Math.random().toString(36).slice(2, 8),
getModalityFromMimeType: (mimeType: string) => {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
return "document";
},
formatFileSize: (bytes: number) => `${bytes} B`,
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
vi.mock("expo-document-picker", () => ({
getDocumentAsync: vi.fn().mockResolvedValue({ canceled: true, assets: [] }),
}));
vi.mock("expo-file-system", () => ({
readAsStringAsync: vi.fn().mockResolvedValue("base64data"),
EncodingType: { Base64: "base64" },
}));
// Mock react-native since tests run in jsdom
vi.mock("react-native", () => {
const _React = require("react");
// Minimal Animated.Value mock
class MockAnimatedValue {
_value: number;
constructor(v: number) {
this._value = v;
}
setValue(v: number) {
this._value = v;
}
}
return {
Animated: {
View: _React.forwardRef((props: any, ref: any) => {
const { testID, ...rest } = props;
return _React.createElement("div", {
...rest,
ref,
"data-testid": testID,
});
}),
Value: MockAnimatedValue,
timing: (_value: any, _config: any) => ({
start: (cb?: (result: { finished: boolean }) => void) => {
cb?.({ finished: true });
},
}),
},
Pressable: _React.forwardRef((props: any, ref: any) => {
const { onPress, testID, children, ...rest } = props;
return _React.createElement(
"button",
{ ...rest, ref, onClick: onPress, "data-testid": testID },
children,
);
}),
View: _React.forwardRef((props: any, ref: any) => {
const { testID, ...rest } = props;
return _React.createElement("div", {
...rest,
ref,
"data-testid": testID,
});
}),
Text: _React.forwardRef((props: any, ref: any) =>
_React.createElement("span", { ...props, ref }),
),
Dimensions: {
get: () => ({ width: 400, height: 800 }),
},
StyleSheet: {
create: (styles: any) => styles,
absoluteFill: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
hairlineWidth: 1,
},
useWindowDimensions: () => ({ width: 400, height: 800 }),
};
});
// Import after mocks
import { CopilotKitProvider } from "../CopilotKitProvider";
import { CopilotSidebar } from "../CopilotSidebar";
import type { CopilotSidebarHandle } from "../CopilotSidebar";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotSidebar", () => {
beforeEach(() => {
capturedAgentId = undefined;
unsubscribeMock = vi.fn();
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar />
</CopilotKitProvider>,
);
expect(container).toBeTruthy();
});
it("renders the FAB toggle button by default when closed", () => {
const { getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar />
</CopilotKitProvider>,
);
expect(getByTestId("copilot-sidebar-fab")).toBeTruthy();
});
it("hides the FAB when showToggleButton is false", () => {
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar showToggleButton={false} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-sidebar-fab")).toBeNull();
});
it("defaultOpen=true renders the drawer immediately", () => {
const { getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar defaultOpen />
</CopilotKitProvider>,
);
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
expect(getByTestId("copilot-sidebar-backdrop")).toBeTruthy();
});
it("defaultOpen=false does not render the drawer", () => {
const { queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar defaultOpen={false} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
expect(queryByTestId("copilot-sidebar-backdrop")).toBeNull();
});
it("open() imperative method opens the drawer", () => {
const ref = createRef<CopilotSidebarHandle>();
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar ref={ref} />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
act(() => {
ref.current!.open();
});
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
});
it("close() imperative method closes the drawer", () => {
const ref = createRef<CopilotSidebarHandle>();
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar ref={ref} defaultOpen />
</CopilotKitProvider>,
);
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
act(() => {
ref.current!.close();
});
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
});
it("toggle() imperative method toggles the drawer", () => {
const ref = createRef<CopilotSidebarHandle>();
const { queryByTestId, getByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar ref={ref} />
</CopilotKitProvider>,
);
// Initially closed
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
// Toggle open
act(() => {
ref.current!.toggle();
});
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
// Toggle closed
act(() => {
ref.current!.toggle();
});
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
});
it("forwards agentId to CopilotChat", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar agentId="sidebar-agent" defaultOpen />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("sidebar-agent");
});
it("forwards agentName to CopilotChat as deprecated alias", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar agentName="legacy-sidebar" defaultOpen />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("legacy-sidebar");
});
it("agentId takes priority over agentName", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar agentId="primary" agentName="legacy" defaultOpen />
</CopilotKitProvider>,
);
expect(capturedAgentId).toBe("primary");
});
it("renders custom headerTitle", () => {
const { getByText } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar headerTitle="My Assistant" defaultOpen />
</CopilotKitProvider>,
);
expect(getByText("My Assistant")).toBeTruthy();
});
it("renders default header title when not specified", () => {
const { getByText } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar defaultOpen />
</CopilotKitProvider>,
);
expect(getByText("Copilot")).toBeTruthy();
});
it("FAB click opens the drawer", () => {
const { getByTestId, queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar />
</CopilotKitProvider>,
);
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
fireEvent.click(getByTestId("copilot-sidebar-fab"));
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
});
it("close button click closes the drawer", () => {
const { getByTestId, queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar defaultOpen />
</CopilotKitProvider>,
);
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
fireEvent.click(getByTestId("copilot-sidebar-close"));
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
});
it("backdrop click closes the drawer", () => {
const { getByTestId, queryByTestId } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar defaultOpen />
</CopilotKitProvider>,
);
expect(getByTestId("copilot-sidebar-drawer")).toBeTruthy();
fireEvent.click(getByTestId("copilot-sidebar-backdrop"));
expect(queryByTestId("copilot-sidebar-drawer")).toBeNull();
});
it("renders children inside the drawer", () => {
const { getByText } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar defaultOpen>
<span>Custom Content</span>
</CopilotSidebar>
</CopilotKitProvider>,
);
expect(getByText("Custom Content")).toBeTruthy();
});
it("calls onOpen callback when drawer opens", () => {
const onOpen = vi.fn();
const ref = createRef<CopilotSidebarHandle>();
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar ref={ref} onOpen={onOpen} />
</CopilotKitProvider>,
);
act(() => {
ref.current!.open();
});
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("calls onClose callback when drawer closes", () => {
const onClose = vi.fn();
const ref = createRef<CopilotSidebarHandle>();
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar ref={ref} defaultOpen onClose={onClose} />
</CopilotKitProvider>,
);
act(() => {
ref.current!.close();
});
expect(onClose).toHaveBeenCalledTimes(1);
});
describe("attachments prop forwarding", () => {
it("forwards attachments config to CopilotChat via rest props", () => {
expect(() => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<CopilotSidebar
defaultOpen={true}
attachments={{ enabled: true }}
/>
</CopilotKitProvider>,
);
}).not.toThrow();
});
});
});
@@ -0,0 +1,427 @@
import React, { useContext } from "react";
import { render, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// vi.hoisted runs before vi.mock factories, making these available to both
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
// Captured subscribers from copilotkit.subscribe()
let capturedSubscriber: Record<string, (...args: any[]) => void>;
let unsubscribeMock: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((subscriber: any) => {
capturedSubscriber = subscriber;
return { unsubscribe: unsubscribeMock };
}),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
// Regular function (not arrow) so it's new-able
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return { CopilotKitCoreReact };
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
// Import after mocks
import { CopilotKitProvider } from "../CopilotKitProvider";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function ContextReader({ onContext }: { onContext: (ctx: any) => void }) {
const ctx = useContext(hoisted.RealContext);
onContext(ctx);
return null;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotKitProvider (React Native)", () => {
beforeEach(() => {
unsubscribeMock = vi.fn();
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
// ── Initialization ──────────────────────────────────────────────────────
describe("initialization", () => {
it("creates CopilotKitCoreReact with correct config", () => {
render(
<CopilotKitProvider
runtimeUrl="https://api.test"
headers={{ auth: "token" }}
properties={{ key: "val" }}
>
<div />
</CopilotKitProvider>,
);
expect(hoisted.MockCoreConstructor).toHaveBeenCalledWith(
expect.objectContaining({
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
headers: { auth: "token" },
properties: { key: "val" },
}),
);
});
it("creates exactly one instance across re-renders", () => {
const { rerender } = render(
<CopilotKitProvider runtimeUrl="https://api.test/v1">
<div />
</CopilotKitProvider>,
);
rerender(
<CopilotKitProvider runtimeUrl="https://api.test/v2">
<div />
</CopilotKitProvider>,
);
expect(hoisted.MockCoreConstructor).toHaveBeenCalledTimes(1);
});
it("maps useSingleEndpoint=true to transport 'single'", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test" useSingleEndpoint>
<div />
</CopilotKitProvider>,
);
expect(hoisted.MockCoreConstructor).toHaveBeenCalledWith(
expect.objectContaining({ runtimeTransport: "single" }),
);
});
it("maps useSingleEndpoint=false to transport 'rest'", () => {
render(
<CopilotKitProvider
runtimeUrl="https://api.test"
useSingleEndpoint={false}
>
<div />
</CopilotKitProvider>,
);
expect(hoisted.MockCoreConstructor).toHaveBeenCalledWith(
expect.objectContaining({ runtimeTransport: "rest" }),
);
});
it("defaults useSingleEndpoint to auto-detect", () => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<div />
</CopilotKitProvider>,
);
expect(hoisted.MockCoreConstructor).toHaveBeenCalledWith(
expect.objectContaining({ runtimeTransport: "auto" }),
);
});
});
// ── Prop synchronization ────────────────────────────────────────────────
describe("prop synchronization", () => {
it("calls setRuntimeUrl when runtimeUrl changes", () => {
const { rerender } = render(
<CopilotKitProvider runtimeUrl="https://api.test/v1">
<div />
</CopilotKitProvider>,
);
rerender(
<CopilotKitProvider runtimeUrl="https://api.test/v2">
<div />
</CopilotKitProvider>,
);
expect(mockCoreInstance.setRuntimeUrl).toHaveBeenCalledWith(
"https://api.test/v2",
);
});
it("calls setHeaders when headers content changes", () => {
const { rerender } = render(
<CopilotKitProvider runtimeUrl="https://api.test" headers={{ a: "1" }}>
<div />
</CopilotKitProvider>,
);
mockCoreInstance.setHeaders.mockClear();
rerender(
<CopilotKitProvider runtimeUrl="https://api.test" headers={{ b: "2" }}>
<div />
</CopilotKitProvider>,
);
expect(mockCoreInstance.setHeaders).toHaveBeenCalledWith({ b: "2" });
});
it("does not re-fire setHeaders when content is identical (JSON-stabilized)", () => {
const { rerender } = render(
<CopilotKitProvider runtimeUrl="https://api.test" headers={{ a: "1" }}>
<div />
</CopilotKitProvider>,
);
mockCoreInstance.setHeaders.mockClear();
// New object reference but same content
rerender(
<CopilotKitProvider runtimeUrl="https://api.test" headers={{ a: "1" }}>
<div />
</CopilotKitProvider>,
);
expect(mockCoreInstance.setHeaders).not.toHaveBeenCalled();
});
});
// ── Context provision ─────────────────────────────────────────────────
describe("context provision", () => {
it("provides copilotkit and executingToolCallIds via context", () => {
let ctx: any = null;
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<ContextReader onContext={(c) => (ctx = c)} />
</CopilotKitProvider>,
);
expect(ctx).not.toBeNull();
// Structural equality — the `new` constructor copies props via Object.assign
expect(ctx.copilotkit).toEqual(mockCoreInstance);
expect(ctx.executingToolCallIds).toBeInstanceOf(Set);
expect(ctx.executingToolCallIds.size).toBe(0);
});
});
// ── Tool execution tracking ─────────────────────────────────────────────
describe("tool execution tracking", () => {
it("adds toolCallId on onToolExecutionStart", () => {
let ctx: any = null;
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<ContextReader onContext={(c) => (ctx = c)} />
</CopilotKitProvider>,
);
act(() => {
capturedSubscriber.onToolExecutionStart({ toolCallId: "tc-1" });
});
expect(ctx.executingToolCallIds.has("tc-1")).toBe(true);
});
it("removes toolCallId on onToolExecutionEnd", () => {
let ctx: any = null;
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<ContextReader onContext={(c) => (ctx = c)} />
</CopilotKitProvider>,
);
act(() => {
capturedSubscriber.onToolExecutionStart({ toolCallId: "tc-1" });
});
act(() => {
capturedSubscriber.onToolExecutionEnd({ toolCallId: "tc-1" });
});
expect(ctx.executingToolCallIds.has("tc-1")).toBe(false);
});
it("handles multiple concurrent tool executions", () => {
let ctx: any = null;
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<ContextReader onContext={(c) => (ctx = c)} />
</CopilotKitProvider>,
);
act(() => {
capturedSubscriber.onToolExecutionStart({ toolCallId: "tc-1" });
capturedSubscriber.onToolExecutionStart({ toolCallId: "tc-2" });
});
expect(ctx.executingToolCallIds.size).toBe(2);
act(() => {
capturedSubscriber.onToolExecutionEnd({ toolCallId: "tc-1" });
});
expect(ctx.executingToolCallIds.has("tc-1")).toBe(false);
expect(ctx.executingToolCallIds.has("tc-2")).toBe(true);
});
it("is idempotent for duplicate start events", () => {
let ctx: any = null;
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<ContextReader onContext={(c) => (ctx = c)} />
</CopilotKitProvider>,
);
act(() => {
capturedSubscriber.onToolExecutionStart({ toolCallId: "tc-1" });
capturedSubscriber.onToolExecutionStart({ toolCallId: "tc-1" });
});
expect(ctx.executingToolCallIds.size).toBe(1);
});
});
// ── Error handling ────────────────────────────────────────────────────
describe("error handling", () => {
it("forwards errors to onError prop when provided", () => {
const onError = vi.fn();
render(
<CopilotKitProvider runtimeUrl="https://api.test" onError={onError}>
<div />
</CopilotKitProvider>,
);
const testError = new Error("test error");
act(() => {
capturedSubscriber.onError({
error: testError,
code: "RUNTIME_ERROR",
context: { detail: "info" },
});
});
expect(onError).toHaveBeenCalledWith({
error: testError,
code: "RUNTIME_ERROR",
context: { detail: "info" },
});
});
it("logs to console.error when onError is not provided", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<div />
</CopilotKitProvider>,
);
act(() => {
capturedSubscriber.onError({
error: new Error("fail"),
code: "AGENT_ERROR",
context: {},
});
});
expect(spy).toHaveBeenCalledWith(
expect.stringContaining("AGENT_ERROR"),
expect.any(Error),
expect.any(Object),
);
spy.mockRestore();
});
it("picks up new onError callback without resubscribing (ref pattern)", () => {
const onError1 = vi.fn();
const onError2 = vi.fn();
const { rerender } = render(
<CopilotKitProvider runtimeUrl="https://api.test" onError={onError1}>
<div />
</CopilotKitProvider>,
);
rerender(
<CopilotKitProvider runtimeUrl="https://api.test" onError={onError2}>
<div />
</CopilotKitProvider>,
);
act(() => {
capturedSubscriber.onError({
error: new Error("test"),
code: "RUNTIME_ERROR",
context: {},
});
});
expect(onError1).not.toHaveBeenCalled();
expect(onError2).toHaveBeenCalled();
});
});
// ── Cleanup ───────────────────────────────────────────────────────────
describe("cleanup", () => {
it("unsubscribes when component unmounts", () => {
const { unmount } = render(
<CopilotKitProvider runtimeUrl="https://api.test">
<div />
</CopilotKitProvider>,
);
unmount();
expect(unsubscribeMock).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,445 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// react-native uses Flow syntax that Vitest/Rollup can't parse outside of
// Metro. Mock it so barrel imports from ../index don't trigger a parse error.
vi.mock("react-native", () => {
const _React = require("react");
const View = ({ children, style, testID }: any) =>
_React.createElement("div", { "data-testid": testID, style }, children);
const Text = ({ children, style, testID }: any) =>
_React.createElement("span", { "data-testid": testID, style }, children);
const Pressable = ({ children, onPress, testID, style }: any) =>
_React.createElement(
"div",
{ "data-testid": testID, onClick: onPress, style },
children,
);
const TouchableOpacity = ({ children, onPress, testID, style }: any) =>
_React.createElement(
"button",
{ "data-testid": testID, onClick: onPress, style },
children,
);
const Modal = ({ children, visible, testID }: any) => {
if (!visible) return null;
return _React.createElement("div", { "data-testid": testID }, children);
};
return {
View,
Text,
Pressable,
TouchableOpacity,
Modal,
Animated: {
View,
Text,
createAnimatedComponent: (comp: any) => comp,
timing: () => ({ start: (cb?: any) => cb?.() }),
Value: class {
_value: number;
constructor(v: number) {
this._value = v;
}
setValue(v: number) {
this._value = v;
}
},
},
Dimensions: { get: () => ({ width: 375, height: 812 }) },
StyleSheet: { create: (styles: any) => styles, hairlineWidth: 1 },
useWindowDimensions: () => ({ width: 375, height: 812 }),
Platform: { OS: "ios" },
};
});
// vi.hoisted runs before vi.mock factories, making these available to both
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
MockCoreConstructor: vi.fn(),
};
});
let _capturedSubscriber: Record<string, (...args: any[]) => void>;
let unsubscribeMock: ReturnType<typeof vi.fn>;
function createMockCore() {
return {
subscribe: vi.fn((subscriber: any) => {
_capturedSubscriber = subscriber;
return { unsubscribe: unsubscribeMock };
}),
subscribeToAgentWithOptions: vi.fn((_agent: any, _handlers: any) => ({
unsubscribe: vi.fn(),
})),
setRuntimeUrl: vi.fn(),
setRuntimeTransport: vi.fn(),
setHeaders: vi.fn(),
setCredentials: vi.fn(),
setProperties: vi.fn(),
setDebug: vi.fn(),
setDefaultThrottleMs: vi.fn(),
getAgent: vi.fn(() => undefined),
runtimeUrl: "https://api.test",
runtimeTransport: "auto",
runtimeConnectionStatus: "Disconnected",
headers: {},
agents: {},
defaultThrottleMs: undefined,
addTool: vi.fn(),
removeTool: vi.fn(),
getTool: vi.fn(() => undefined),
addHookRenderToolCall: vi.fn(),
registerThreadStore: vi.fn(),
unregisterThreadStore: vi.fn(),
intelligence: undefined,
};
}
let mockCoreInstance: ReturnType<typeof createMockCore>;
vi.mock("@copilotkit/react-core/v2/headless", () => {
// Regular function (not arrow) so it's new-able
function CopilotKitCoreReact(this: any, ...args: any[]) {
hoisted.MockCoreConstructor(...args);
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
if (instance) Object.assign(this, instance);
}
return {
CopilotKitCoreReact,
useAgent: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return { agent: {} };
},
useFrontendTool: (_tool: any) => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useComponent: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useHumanInTheLoop: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useInterrupt: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useSuggestions: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useConfigureSuggestions: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useAgentContext: () => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
},
useThreads: (_input: any) => {
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return {
threads: [],
isLoading: false,
error: null,
hasMoreThreads: false,
isFetchingMoreThreads: false,
fetchMoreThreads: () => {},
renameThread: async () => {},
archiveThread: async () => {},
deleteThread: async () => {},
};
},
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return ctx;
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
// Mock @gorhom/bottom-sheet to prevent its CommonJS require("react-native")
// from bypassing the vite alias and loading the Flow-syntax react-native.
vi.mock("@gorhom/bottom-sheet", () => {
const React = require("react");
return {
__esModule: true,
default: React.forwardRef((props: any, ref: any) =>
React.createElement("mock-bottom-sheet", { ref }, props.children),
),
BottomSheetBackdrop: () => null,
BottomSheetFlatList: "FlatList",
BottomSheetView: (props: any) =>
React.createElement("div", null, props.children),
};
});
vi.mock("@copilotkit/shared", () => ({
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
// Import after mocks
import { CopilotKitProvider } from "../CopilotKitProvider";
import {
useAgent,
useFrontendTool,
useHumanInTheLoop,
useInterrupt,
useThreads,
} from "../index";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Headless integration", () => {
beforeEach(() => {
unsubscribeMock = vi.fn();
mockCoreInstance = createMockCore();
hoisted.MockCoreConstructor.mockClear();
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
});
afterEach(() => {
vi.clearAllMocks();
});
// ── Provider + hooks integration ──────────────────────────────────────
describe("provider + hooks integration", () => {
it("useAgent returns expected shape when called inside provider", () => {
let result: any = null;
function TestComponent() {
result = useAgent();
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<TestComponent />
</CopilotKitProvider>,
);
expect(result).not.toBeNull();
expect(result).toHaveProperty("agent");
expect(typeof result.agent).toBe("object");
});
it("useFrontendTool can register a tool without error", () => {
function TestComponent() {
useFrontendTool({
name: "test-tool",
description: "A test tool",
handler: async () => "done",
});
return null;
}
// Should not throw
expect(() => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<TestComponent />
</CopilotKitProvider>,
);
}).not.toThrow();
});
it("useThreads returns thread state when called inside provider", () => {
let result: any = null;
function TestComponent() {
result = useThreads({ agentId: "test-agent" });
return null;
}
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<TestComponent />
</CopilotKitProvider>,
);
expect(result).not.toBeNull();
expect(result).toHaveProperty("threads");
expect(result).toHaveProperty("isLoading");
expect(result).toHaveProperty("error");
expect(result).toHaveProperty("renameThread");
expect(result).toHaveProperty("archiveThread");
expect(result).toHaveProperty("deleteThread");
expect(result).toHaveProperty("hasMoreThreads");
expect(result).toHaveProperty("isFetchingMoreThreads");
expect(result).toHaveProperty("fetchMoreThreads");
expect(Array.isArray(result.threads)).toBe(true);
expect(typeof result.renameThread).toBe("function");
expect(typeof result.archiveThread).toBe("function");
expect(typeof result.deleteThread).toBe("function");
});
it("multiple hooks can coexist in the same provider tree", () => {
let agentResult: any = null;
let threadsResult: any = null;
function TestComponent() {
agentResult = useAgent();
threadsResult = useThreads({ agentId: "test-agent" });
useFrontendTool({
name: "multi-tool",
description: "Another tool",
handler: async () => "ok",
});
return null;
}
expect(() => {
render(
<CopilotKitProvider runtimeUrl="https://api.test">
<TestComponent />
</CopilotKitProvider>,
);
}).not.toThrow();
expect(agentResult).toHaveProperty("agent");
expect(threadsResult).toHaveProperty("threads");
});
});
// ── Provider error boundary ───────────────────────────────────────────
describe("provider error boundary", () => {
it("useAgent throws when called outside CopilotKitProvider", () => {
function TestComponent() {
useAgent();
return null;
}
// Suppress React error boundary console output
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useCopilotKit must be used within CopilotKitProvider");
spy.mockRestore();
});
it("useFrontendTool throws when called outside CopilotKitProvider", () => {
function TestComponent() {
useFrontendTool({
name: "orphan-tool",
description: "No provider",
handler: async () => "fail",
});
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useCopilotKit must be used within CopilotKitProvider");
spy.mockRestore();
});
it("useThreads throws when called outside CopilotKitProvider", () => {
function TestComponent() {
useThreads({ agentId: "test-agent" });
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useCopilotKit must be used within CopilotKitProvider");
spy.mockRestore();
});
it("useHumanInTheLoop throws when called outside CopilotKitProvider", () => {
function TestComponent() {
useHumanInTheLoop({ name: "orphan-hitl", render: () => null });
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useCopilotKit must be used within CopilotKitProvider");
spy.mockRestore();
});
it("useInterrupt throws when called outside CopilotKitProvider", () => {
function TestComponent() {
useInterrupt({ render: () => <></> });
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useCopilotKit must be used within CopilotKitProvider");
spy.mockRestore();
});
});
});
@@ -0,0 +1,242 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Mock streaming-fetch to isolate polyfill tests
vi.mock("../streaming-fetch", () => ({
installStreamingFetch: vi.fn(),
}));
// ─── Global save/restore ──────────────────────────────────────────────────────
const savedGlobals: Record<string, any> = {};
function saveGlobal(name: string) {
savedGlobals[name] = (globalThis as any)[name];
}
function deleteGlobal(name: string) {
saveGlobal(name);
delete (globalThis as any)[name];
}
function restoreGlobals() {
for (const [name, value] of Object.entries(savedGlobals)) {
if (value === undefined) {
delete (globalThis as any)[name];
} else {
(globalThis as any)[name] = value;
}
}
// Clear the saved state
for (const key of Object.keys(savedGlobals)) {
delete savedGlobals[key];
}
}
async function importPolyfills() {
vi.resetModules();
await import("../polyfills");
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("polyfills", () => {
afterEach(() => {
restoreGlobals();
});
// ── ReadableStream / WritableStream / TransformStream ───────────────────
describe("ReadableStream / WritableStream / TransformStream", () => {
it("installs when missing", async () => {
deleteGlobal("ReadableStream");
deleteGlobal("WritableStream");
deleteGlobal("TransformStream");
await importPolyfills();
expect(typeof globalThis.ReadableStream).toBe("function");
expect(typeof (globalThis as any).WritableStream).toBe("function");
expect(typeof (globalThis as any).TransformStream).toBe("function");
});
it("preserves existing ReadableStream", async () => {
const sentinel = function Sentinel() {};
saveGlobal("ReadableStream");
(globalThis as any).ReadableStream = sentinel;
await importPolyfills();
expect((globalThis as any).ReadableStream).toBe(sentinel);
});
});
// ── TextEncoder / TextDecoder ───────────────────────────────────────────
describe("TextEncoder / TextDecoder", () => {
it("installs TextEncoder when missing", async () => {
deleteGlobal("TextEncoder");
await importPolyfills();
expect(typeof globalThis.TextEncoder).toBe("function");
});
it("installs TextDecoder when missing", async () => {
deleteGlobal("TextDecoder");
await importPolyfills();
expect(typeof globalThis.TextDecoder).toBe("function");
});
it("preserves existing TextEncoder", async () => {
const sentinel = function Sentinel() {};
saveGlobal("TextEncoder");
(globalThis as any).TextEncoder = sentinel;
await importPolyfills();
expect((globalThis as any).TextEncoder).toBe(sentinel);
});
});
// ── crypto.getRandomValues ──────────────────────────────────────────────
describe("crypto.getRandomValues", () => {
it("installs when crypto is undefined", async () => {
deleteGlobal("crypto");
vi.spyOn(console, "warn").mockImplementation(() => {});
await importPolyfills();
expect(typeof globalThis.crypto.getRandomValues).toBe("function");
});
it("installs when crypto exists but getRandomValues is missing", async () => {
saveGlobal("crypto");
(globalThis as any).crypto = {};
vi.spyOn(console, "warn").mockImplementation(() => {});
await importPolyfills();
expect(typeof globalThis.crypto.getRandomValues).toBe("function");
});
it("preserves existing getRandomValues", async () => {
const sentinel = vi.fn();
saveGlobal("crypto");
(globalThis as any).crypto = { getRandomValues: sentinel };
await importPolyfills();
expect(globalThis.crypto.getRandomValues).toBe(sentinel);
});
it("produces a filled Uint8Array with values in [0, 255]", async () => {
deleteGlobal("crypto");
vi.spyOn(console, "warn").mockImplementation(() => {});
await importPolyfills();
const arr = new Uint8Array(32);
const result = globalThis.crypto.getRandomValues(arr);
expect(result).toBe(arr);
// With 32 random bytes, at least some should be non-zero
expect(arr.some((v) => v > 0)).toBe(true);
expect(arr.every((v) => v >= 0 && v <= 255)).toBe(true);
});
it("logs a security warning", async () => {
deleteGlobal("crypto");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await importPolyfills();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("non-cryptographic"),
);
});
});
// ── DOMException ────────────────────────────────────────────────────────
describe("DOMException", () => {
it("installs when missing", async () => {
deleteGlobal("DOMException");
await importPolyfills();
expect(typeof (globalThis as any).DOMException).toBe("function");
});
it("polyfill supports name parameter (e.g. AbortError)", async () => {
deleteGlobal("DOMException");
await importPolyfills();
const err = new (globalThis as any).DOMException("msg", "AbortError");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("AbortError");
expect(err.message).toBe("msg");
});
it("preserves existing DOMException", async () => {
const sentinel = function Sentinel() {};
saveGlobal("DOMException");
(globalThis as any).DOMException = sentinel;
await importPolyfills();
expect((globalThis as any).DOMException).toBe(sentinel);
});
});
// ── Headers ─────────────────────────────────────────────────────────────
describe("Headers polyfill", () => {
it("installs when missing", async () => {
deleteGlobal("Headers");
await importPolyfills();
expect(typeof (globalThis as any).Headers).toBe("function");
});
it("supports get/set/has/delete/append", async () => {
deleteGlobal("Headers");
await importPolyfills();
const h = new Headers();
h.set("X-Key", "val1");
expect(h.get("x-key")).toBe("val1");
expect(h.has("x-key")).toBe(true);
h.append("x-key", "val2");
expect(h.get("x-key")).toBe("val1, val2");
h.delete("x-key");
expect(h.has("x-key")).toBe(false);
});
it("normalizes header names to lowercase", async () => {
deleteGlobal("Headers");
await importPolyfills();
const h = new Headers({ "Content-Type": "text/html" });
expect(h.get("content-type")).toBe("text/html");
});
it("is iterable", async () => {
deleteGlobal("Headers");
await importPolyfills();
const h = new Headers({ a: "1", b: "2" });
const entries = [...h];
expect(entries).toEqual(
expect.arrayContaining([
["a", "1"],
["b", "2"],
]),
);
});
it("preserves existing Headers", async () => {
const sentinel = function Sentinel() {};
saveGlobal("Headers");
(globalThis as any).Headers = sentinel;
await importPolyfills();
expect((globalThis as any).Headers).toBe(sentinel);
});
});
// ── window.location ─────────────────────────────────────────────────────
describe("window.location", () => {
it("installs with react-native.invalid hostname when missing", async () => {
if (typeof window !== "undefined") {
saveGlobal("location");
delete (window as any).location;
await importPolyfills();
expect((window as any).location.hostname).toBe("react-native.invalid");
}
});
});
// ── streaming fetch integration ─────────────────────────────────────────
describe("streaming fetch integration", () => {
it("calls installStreamingFetch()", async () => {
await importPolyfills();
const { installStreamingFetch } = await import("../streaming-fetch");
expect(installStreamingFetch).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,9 @@
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// React Native global — always true in test environment
(globalThis as any).__DEV__ = true;
afterEach(() => {
cleanup();
});
@@ -0,0 +1,526 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ─── MockXHR ──────────────────────────────────────────────────────────────────
class MockXHR {
open = vi.fn();
send = vi.fn();
abort = vi.fn();
setRequestHeader = vi.fn();
getAllResponseHeaders = vi.fn(() => "");
readyState = 0;
status = 0;
statusText = "";
responseText = "";
responseType = "";
timeout = 0;
onreadystatechange: (() => void) | null = null;
onprogress: (() => void) | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
onabort: (() => void) | null = null;
}
let mockXhr: MockXHR;
/**
* Flush pending setTimeout(fn, 0) callbacks and microtasks.
* The streaming-fetch polyfill defers all XHR callbacks via setTimeout(fn, 0)
* to ensure they run on the JS thread in React Native. In tests we need to
* flush these timers after each simulated XHR event.
*
* We flush multiple ticks to ensure all chained timers and microtasks settle.
*/
async function flushTimers() {
for (let i = 0; i < 3; i++) {
await new Promise((r) => setTimeout(r, 0));
}
}
async function simulateHeaders(
xhr: MockXHR,
status: number,
headers = "content-type: text/plain\r\n",
statusText = "OK",
) {
xhr.readyState = 2;
xhr.status = status;
xhr.statusText = statusText;
xhr.getAllResponseHeaders.mockReturnValue(headers);
xhr.onreadystatechange?.();
await flushTimers();
}
async function simulateProgress(xhr: MockXHR, text: string) {
xhr.responseText = text;
xhr.onprogress?.();
await flushTimers();
}
async function simulateLoad(xhr: MockXHR) {
xhr.readyState = 4;
xhr.onload?.();
await flushTimers();
}
async function simulateError(xhr: MockXHR) {
xhr.onerror?.();
await flushTimers();
}
async function simulateTimeout(xhr: MockXHR) {
xhr.ontimeout?.();
await flushTimers();
}
// ─── Globals save/restore ─────────────────────────────────────────────────────
let savedFetch: typeof globalThis.fetch;
let savedXHR: typeof globalThis.XMLHttpRequest;
let savedResponse: typeof globalThis.Response;
beforeEach(() => {
savedFetch = globalThis.fetch;
savedXHR = globalThis.XMLHttpRequest;
savedResponse = globalThis.Response;
mockXhr = new MockXHR();
// Must use a regular function (not arrow) so it can be called with `new`
(globalThis as any).XMLHttpRequest = vi.fn(function () {
return mockXhr;
});
// Make feature detection fail so the polyfill installs
(globalThis as any).Response = class {
body = null;
};
});
afterEach(() => {
globalThis.fetch = savedFetch;
(globalThis as any).XMLHttpRequest = savedXHR;
(globalThis as any).Response = savedResponse;
});
// ─── Helper: import fresh module ──────────────────────────────────────────────
async function install() {
// Reset module cache so installStreamingFetch runs fresh
vi.resetModules();
// Ensure __DEV__ survives module reset (React Native global)
(globalThis as any).__DEV__ = true;
const mod = await import("../streaming-fetch");
mod.installStreamingFetch();
}
// Helper: make a fetch call and capture the mockXhr for lifecycle simulation
async function fetchAndCapture(
input: string | URL = "https://api.test/stream",
init?: RequestInit,
) {
await install();
const fetchPromise = globalThis.fetch(input, init);
return { fetchPromise, xhr: mockXhr };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("installStreamingFetch", () => {
// ── Feature detection ─────────────────────────────────────────────────────
describe("feature detection", () => {
it("skips installation when native fetch already supports ReadableStream body", async () => {
const originalFetch = globalThis.fetch;
(globalThis as any).Response = class {
body = {
getReader: () => ({}),
};
};
await install();
expect(globalThis.fetch).toBe(originalFetch);
});
it("installs replacement when Response constructor is unavailable", async () => {
const originalFetch = globalThis.fetch;
delete (globalThis as any).Response;
await install();
expect(globalThis.fetch).not.toBe(originalFetch);
});
it("installs replacement when Response.body is null", async () => {
const originalFetch = globalThis.fetch;
await install();
expect(globalThis.fetch).not.toBe(originalFetch);
});
it("installs replacement when Response.body.getReader is not a function", async () => {
const originalFetch = globalThis.fetch;
(globalThis as any).Response = class {
body = {};
};
await install();
expect(globalThis.fetch).not.toBe(originalFetch);
});
});
// ── Basic request lifecycle ───────────────────────────────────────────────
describe("basic request lifecycle", () => {
it("opens XHR with correct method and URL for string input", async () => {
const { xhr } = await fetchAndCapture("https://api.test/data", {
method: "POST",
});
expect(xhr.open).toHaveBeenCalledWith("POST", "https://api.test/data");
});
it("opens XHR with correct URL for URL input", async () => {
const { xhr } = await fetchAndCapture(new URL("https://api.test/path"));
expect(xhr.open).toHaveBeenCalledWith("GET", "https://api.test/path");
});
it("defaults to GET when no method specified", async () => {
const { xhr } = await fetchAndCapture("https://api.test");
expect(xhr.open).toHaveBeenCalledWith("GET", "https://api.test");
});
it("sets request headers from plain object", async () => {
const { xhr } = await fetchAndCapture("https://api.test", {
headers: { "Content-Type": "application/json", "X-Custom": "val" },
});
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
"Content-Type",
"application/json",
);
expect(xhr.setRequestHeader).toHaveBeenCalledWith("X-Custom", "val");
});
it("sets request headers from Headers instance", async () => {
const headers = new Headers({ Authorization: "Bearer tok" });
const { xhr } = await fetchAndCapture("https://api.test", { headers });
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
"authorization",
"Bearer tok",
);
});
it("sets request headers from array of tuples", async () => {
const { xhr } = await fetchAndCapture("https://api.test", {
headers: [["X-Key", "val"]],
});
expect(xhr.setRequestHeader).toHaveBeenCalledWith("X-Key", "val");
});
it("sends the request body", async () => {
const { xhr } = await fetchAndCapture("https://api.test", {
method: "POST",
body: '{"key":"value"}',
});
expect(xhr.send).toHaveBeenCalledWith('{"key":"value"}');
});
it("sets XHR timeout to 60 seconds", async () => {
const { xhr } = await fetchAndCapture();
expect(xhr.timeout).toBe(60_000);
});
});
// ── Response resolution ───────────────────────────────────────────────────
describe("response resolution", () => {
it("resolves when headers arrive with non-zero status", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
expect(resp.status).toBe(200);
});
it("exposes correct status, statusText, url, and ok", async () => {
const { fetchPromise, xhr } = await fetchAndCapture(
"https://api.test/not-found",
);
await simulateHeaders(xhr, 404, "", "Not Found");
const resp = await fetchPromise;
expect(resp.ok).toBe(false);
expect(resp.status).toBe(404);
expect(resp.statusText).toBe("Not Found");
expect(resp.url).toBe("https://api.test/not-found");
});
it("parses response headers into a Headers object", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(
xhr,
200,
"content-type: application/json\r\nx-request-id: abc123\r\n",
);
const resp = await fetchPromise;
expect(resp.headers.get("content-type")).toBe("application/json");
expect(resp.headers.get("x-request-id")).toBe("abc123");
});
it("provides a ReadableStream body on the response", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
expect(resp.body).toBeInstanceOf(ReadableStream);
});
});
// ── Streaming chunks ──────────────────────────────────────────────────────
describe("streaming chunks", () => {
it("delivers chunks incrementally as XHR fires onprogress", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
const reader = resp.body!.getReader();
await simulateProgress(xhr, "chunk1");
const { value: c1 } = await reader.read();
expect(new TextDecoder().decode(c1)).toBe("chunk1");
await simulateProgress(xhr, "chunk1chunk2");
const { value: c2 } = await reader.read();
expect(new TextDecoder().decode(c2)).toBe("chunk2");
});
it("closes stream on onload after delivering final chunks", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
const reader = resp.body!.getReader();
xhr.responseText = "all data";
await simulateLoad(xhr);
const { value, done } = await reader.read();
expect(new TextDecoder().decode(value)).toBe("all data");
const final = await reader.read();
expect(final.done).toBe(true);
});
it("encodes chunks as Uint8Array", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
const reader = resp.body!.getReader();
await simulateProgress(xhr, "hello");
const { value } = await reader.read();
// Cross-realm safe check (jsdom TextEncoder may produce a different Uint8Array)
expect(ArrayBuffer.isView(value)).toBe(true);
expect(value!.constructor.name).toBe("Uint8Array");
});
});
// ── Convenience methods ───────────────────────────────────────────────────
describe("convenience methods", () => {
it("text() returns full response text after XHR completes", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
xhr.responseText = "hello world";
await simulateLoad(xhr);
expect(await resp.text()).toBe("hello world");
});
it("json() parses full response text as JSON", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
xhr.responseText = '{"key":"value"}';
await simulateLoad(xhr);
expect(await resp.json()).toEqual({ key: "value" });
});
it("json() throws TypeError with descriptive message on invalid JSON", async () => {
const { fetchPromise, xhr } = await fetchAndCapture(
"https://api.test/bad",
{ method: "POST" },
);
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
xhr.responseText = "not json";
await simulateLoad(xhr);
await expect(resp.json()).rejects.toThrow(TypeError);
await expect(resp.json()).rejects.toThrow(/api\.test\/bad/);
});
it("clone() always throws", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
expect(() => resp.clone()).toThrow(/not supported/);
});
it("formData() always throws", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
await expect(resp.formData()).rejects.toThrow(/not supported/);
});
it("marks bodyUsed after calling text()", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
expect(resp.bodyUsed).toBe(false);
xhr.responseText = "data";
await simulateLoad(xhr);
await resp.text();
expect(resp.bodyUsed).toBe(true);
});
});
// ── Abort handling ────────────────────────────────────────────────────────
describe("abort handling", () => {
it("rejects immediately when signal is already aborted", async () => {
await install();
const controller = new AbortController();
controller.abort();
await expect(
globalThis.fetch("https://api.test", { signal: controller.signal }),
).rejects.toThrow(/aborted/i);
});
it("aborts XHR and rejects when signal fires before headers", async () => {
const controller = new AbortController();
const { fetchPromise, xhr } = await fetchAndCapture("https://api.test", {
signal: controller.signal,
});
controller.abort();
await expect(fetchPromise).rejects.toThrow(/aborted/i);
expect(xhr.abort).toHaveBeenCalled();
});
it("aborts XHR mid-stream when signal fires after headers arrive", async () => {
const controller = new AbortController();
const { fetchPromise, xhr } = await fetchAndCapture("https://api.test", {
signal: controller.signal,
});
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
const reader = resp.body!.getReader();
controller.abort();
await expect(reader.read()).rejects.toThrow(/aborted/i);
expect(xhr.abort).toHaveBeenCalled();
});
it("cancelling the ReadableStream aborts the XHR", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
const reader = resp.body!.getReader();
await reader.cancel();
expect(xhr.abort).toHaveBeenCalled();
});
it("removes abort listener after terminal XHR event (onload)", async () => {
const controller = new AbortController();
const removeSpy = vi.spyOn(controller.signal, "removeEventListener");
const { fetchPromise, xhr } = await fetchAndCapture("https://api.test", {
signal: controller.signal,
});
await simulateHeaders(xhr, 200);
await fetchPromise;
xhr.responseText = "done";
await simulateLoad(xhr);
expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
});
});
// ── Error handling ────────────────────────────────────────────────────────
describe("error handling", () => {
it("rejects with TypeError on XHR onerror", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
// Attach rejection handler BEFORE triggering error to avoid
// Node's unhandled rejection warning (setTimeout defers the rejection)
const rejection = expect(fetchPromise).rejects.toThrow(
"Network request failed",
);
await simulateError(xhr);
await rejection;
});
it("rejects with TypeError on XHR ontimeout", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
const rejection = expect(fetchPromise).rejects.toThrow(
"Network request timed out",
);
await simulateTimeout(xhr);
await rejection;
});
it("rejects with descriptive error on readyState=4 with status=0 (CORS/DNS)", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
const rejection = expect(fetchPromise).rejects.toThrow(/CORS failure/);
xhr.readyState = 4;
xhr.status = 0;
xhr.onreadystatechange?.();
await flushTimers();
await rejection;
});
it("errors stream and rejects text() when onerror fires after headers", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
await simulateHeaders(xhr, 200);
const resp = await fetchPromise;
// text() rejection handler must be attached before triggering error
const textRejection = expect(resp.text()).rejects.toThrow(
"Network request failed",
);
await simulateError(xhr);
await textRejection;
});
it("does not double-reject (settled guard)", async () => {
const { fetchPromise, xhr } = await fetchAndCapture();
// Attach rejection handler before triggering error
const rejection = expect(fetchPromise).rejects.toThrow(
"Network request failed",
);
await simulateError(xhr);
await rejection;
// Second error should not throw unhandled rejection
await simulateTimeout(xhr);
});
});
// ── __originalFetch ───────────────────────────────────────────────────────
describe("__originalFetch", () => {
it("exposes original fetch on the replacement", async () => {
const originalFetch = globalThis.fetch;
await install();
expect((globalThis.fetch as any).__originalFetch).toBe(originalFetch);
});
});
});
@@ -0,0 +1,410 @@
// packages/react-native/src/__tests__/use-attachments.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// Mock expo-document-picker
const mockGetDocumentAsync = vi.fn();
vi.mock("expo-document-picker", () => ({
getDocumentAsync: (...args: any[]) => mockGetDocumentAsync(...args),
}));
// Mock expo-file-system
const mockReadAsStringAsync = vi.fn();
vi.mock("expo-file-system", () => ({
readAsStringAsync: (...args: any[]) => mockReadAsStringAsync(...args),
EncodingType: { Base64: "base64" },
}));
// Mock @copilotkit/shared -- only the utils we use
vi.mock("@copilotkit/shared", () => ({
getModalityFromMimeType: (mimeType: string) => {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
return "document";
},
formatFileSize: (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
},
randomUUID: () => "test-uuid-" + Math.random().toString(36).slice(2, 8),
}));
// Import AFTER mocks
import { useAttachments } from "../hooks/use-attachments";
import type { NativeFileInput } from "../hooks/use-attachments";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("useAttachments (React Native)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── Initial state ──────────────────────────────────────────────────────
describe("initial state", () => {
it("returns empty attachments when no config provided", () => {
const { result } = renderHook(() => useAttachments({}));
expect(result.current.attachments).toEqual([]);
expect(result.current.enabled).toBe(false);
});
it("returns enabled=true when config.enabled is true", () => {
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
expect(result.current.enabled).toBe(true);
});
it("returns enabled=false when config.enabled is false", () => {
const { result } = renderHook(() =>
useAttachments({ config: { enabled: false } }),
);
expect(result.current.enabled).toBe(false);
});
});
// ── processFiles ──────────────────────────────────────────────────────
describe("processFiles", () => {
it("adds attachment with correct modality for an image", async () => {
mockReadAsStringAsync.mockResolvedValue("base64ImageData");
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
const file: NativeFileInput = {
uri: "file:///tmp/photo.jpg",
name: "photo.jpg",
size: 1024,
mimeType: "image/jpeg",
};
await act(async () => {
await result.current.processFiles([file]);
});
expect(result.current.attachments).toHaveLength(1);
expect(result.current.attachments[0]).toMatchObject({
type: "image",
filename: "photo.jpg",
size: 1024,
status: "ready",
source: {
type: "data",
value: "base64ImageData",
mimeType: "image/jpeg",
},
});
expect(result.current.attachments[0].id).toBeDefined();
});
it("adds attachment with 'document' modality for a PDF", async () => {
mockReadAsStringAsync.mockResolvedValue("base64PdfData");
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
const file: NativeFileInput = {
uri: "file:///tmp/doc.pdf",
name: "doc.pdf",
size: 2048,
mimeType: "application/pdf",
};
await act(async () => {
await result.current.processFiles([file]);
});
expect(result.current.attachments).toHaveLength(1);
expect(result.current.attachments[0].type).toBe("document");
});
it("rejects files that exceed maxSize", async () => {
const onUploadFailed = vi.fn();
const { result } = renderHook(() =>
useAttachments({
config: {
enabled: true,
maxSize: 500,
onUploadFailed,
},
}),
);
const file: NativeFileInput = {
uri: "file:///tmp/big.jpg",
name: "big.jpg",
size: 1000,
mimeType: "image/jpeg",
};
await act(async () => {
await result.current.processFiles([file]);
});
expect(result.current.attachments).toHaveLength(0);
expect(onUploadFailed).toHaveBeenCalledWith(
expect.objectContaining({
reason: "file-too-large",
}),
);
});
it("rejects files that do not match accept filter", async () => {
const onUploadFailed = vi.fn();
const { result } = renderHook(() =>
useAttachments({
config: {
enabled: true,
accept: "image/*",
onUploadFailed,
},
}),
);
const file: NativeFileInput = {
uri: "file:///tmp/doc.pdf",
name: "doc.pdf",
size: 1024,
mimeType: "application/pdf",
};
await act(async () => {
await result.current.processFiles([file]);
});
expect(result.current.attachments).toHaveLength(0);
expect(onUploadFailed).toHaveBeenCalledWith(
expect.objectContaining({
reason: "invalid-type",
}),
);
});
it("handles file read errors gracefully", async () => {
mockReadAsStringAsync.mockRejectedValue(new Error("Read failed"));
const onUploadFailed = vi.fn();
const { result } = renderHook(() =>
useAttachments({
config: { enabled: true, onUploadFailed },
}),
);
const file: NativeFileInput = {
uri: "file:///tmp/bad.jpg",
name: "bad.jpg",
size: 100,
mimeType: "image/jpeg",
};
await act(async () => {
await result.current.processFiles([file]);
});
// Placeholder should have been removed after error
expect(result.current.attachments).toHaveLength(0);
expect(onUploadFailed).toHaveBeenCalledWith(
expect.objectContaining({
reason: "upload-failed",
}),
);
});
it("calls custom onUpload instead of reading file directly", async () => {
const customUpload = vi.fn().mockResolvedValue({
type: "url",
value: "https://cdn.example.com/photo.jpg",
mimeType: "image/jpeg",
});
const { result } = renderHook(() =>
useAttachments({
config: { enabled: true, onUpload: customUpload },
}),
);
const file: NativeFileInput = {
uri: "file:///tmp/photo.jpg",
name: "photo.jpg",
size: 1024,
mimeType: "image/jpeg",
};
await act(async () => {
await result.current.processFiles([file]);
});
expect(customUpload).toHaveBeenCalledWith(file);
expect(mockReadAsStringAsync).not.toHaveBeenCalled();
expect(result.current.attachments[0].source).toEqual({
type: "url",
value: "https://cdn.example.com/photo.jpg",
mimeType: "image/jpeg",
});
});
});
// ── removeAttachment ──────────────────────────────────────────────────
describe("removeAttachment", () => {
it("removes an attachment by id", async () => {
mockReadAsStringAsync.mockResolvedValue("base64Data");
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
const file: NativeFileInput = {
uri: "file:///tmp/a.jpg",
name: "a.jpg",
size: 100,
mimeType: "image/jpeg",
};
await act(async () => {
await result.current.processFiles([file]);
});
const id = result.current.attachments[0].id;
act(() => {
result.current.removeAttachment(id);
});
expect(result.current.attachments).toHaveLength(0);
});
});
// ── consumeAttachments ────────────────────────────────────────────────
describe("consumeAttachments", () => {
it("returns ready attachments and clears the queue", async () => {
mockReadAsStringAsync.mockResolvedValue("base64Data");
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
const file: NativeFileInput = {
uri: "file:///tmp/a.jpg",
name: "a.jpg",
size: 100,
mimeType: "image/jpeg",
};
await act(async () => {
await result.current.processFiles([file]);
});
expect(result.current.attachments).toHaveLength(1);
let consumed: any[];
act(() => {
consumed = result.current.consumeAttachments();
});
expect(consumed!).toHaveLength(1);
expect(consumed![0].status).toBe("ready");
expect(result.current.attachments).toHaveLength(0);
});
it("returns empty array when no attachments", () => {
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
let consumed: any[];
act(() => {
consumed = result.current.consumeAttachments();
});
expect(consumed!).toHaveLength(0);
});
});
// ── openPicker ────────────────────────────────────────────────────────
describe("openPicker", () => {
it("calls DocumentPicker.getDocumentAsync and processes the result", async () => {
mockGetDocumentAsync.mockResolvedValue({
canceled: false,
assets: [
{
uri: "file:///cache/picked.jpg",
name: "picked.jpg",
size: 2048,
mimeType: "image/jpeg",
},
],
});
mockReadAsStringAsync.mockResolvedValue("pickedBase64");
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
await act(async () => {
await result.current.openPicker();
});
expect(mockGetDocumentAsync).toHaveBeenCalledWith({
type: ["*/*"],
copyToCacheDirectory: true,
multiple: true,
});
expect(result.current.attachments).toHaveLength(1);
expect(result.current.attachments[0].filename).toBe("picked.jpg");
});
it("does nothing when picker is canceled", async () => {
mockGetDocumentAsync.mockResolvedValue({
canceled: true,
assets: [],
});
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true } }),
);
await act(async () => {
await result.current.openPicker();
});
expect(result.current.attachments).toHaveLength(0);
});
it("passes accept filter to DocumentPicker", async () => {
mockGetDocumentAsync.mockResolvedValue({
canceled: true,
assets: [],
});
const { result } = renderHook(() =>
useAttachments({ config: { enabled: true, accept: "image/*" } }),
);
await act(async () => {
await result.current.openPicker();
});
expect(mockGetDocumentAsync).toHaveBeenCalledWith(
expect.objectContaining({
type: ["image/*"],
}),
);
});
});
});
@@ -0,0 +1,465 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
type ListRenderItemInfo,
type ViewStyle,
} from "react-native";
import { useAgent } from "@copilotkit/react-core/v2/headless";
import { useCopilotKit } from "@copilotkit/react-core/v2/context";
import { AssistantMessage } from "./messages/AssistantMessage";
import { UserMessage } from "./messages/UserMessage";
import { useRenderToolRegistry } from "../hooks/RenderToolContext";
import type { Message } from "@copilotkit/shared";
/** Shape of an assistant message with optional tool calls. */
interface AssistantMessageShape {
id: string;
role: "assistant";
content?: string;
toolCalls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
}
export interface CopilotChatProps {
/** Agent ID to connect to. Defaults to 'default'. */
agentName?: string;
/** Placeholder text for the input field. */
placeholder?: string;
/** Suggestion pills shown in the empty state. */
initialMessages?: string[];
/** Title shown when there are no messages. */
emptyStateTitle?: string;
/** Subtitle shown when there are no messages. */
emptyStateSubtitle?: string;
/** Title for the optional header bar. */
headerTitle?: string;
/** Whether to show the header bar. Defaults to true. */
showHeader?: boolean;
/** Style override for the outermost container. */
style?: ViewStyle;
/** Style override for the message list container. */
messageContainerStyle?: ViewStyle;
/** Style override for the input bar container. */
inputContainerStyle?: ViewStyle;
/** Callback fired when the user sends a message. */
onSendMessage?: (text: string) => void;
/** Custom FlatList component (e.g. BottomSheetFlatList for use inside a bottom sheet). */
FlatListComponent?: React.ComponentType<any>;
/** When true, skip the KeyboardAvoidingView wrapper (useful when a parent already handles keyboard). */
disableKeyboardAvoiding?: boolean;
}
interface ChatListItem {
id: string;
type: "user" | "assistant" | "tool-call" | "loading";
content?: string;
toolCalls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
}
/**
* Full-screen chat UI component for React Native.
*
* Connects to a CopilotKit agent via `useAgent` and renders messages
* using platform-appropriate AssistantMessage / UserMessage components.
*
* Usage:
* ```tsx
* <CopilotChat agentName="my-agent" headerTitle="Assistant" />
* ```
*/
export function CopilotChat({
agentName = "default",
placeholder = "Type a message...",
initialMessages = [],
emptyStateTitle = "How can I help?",
emptyStateSubtitle = "Ask me anything or try a suggestion below.",
headerTitle = "Chat",
showHeader = true,
style,
messageContainerStyle,
inputContainerStyle,
onSendMessage,
FlatListComponent = FlatList,
disableKeyboardAvoiding = false,
}: CopilotChatProps) {
const [inputText, setInputText] = useState("");
const [error, setError] = useState<string | null>(null);
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
const { copilotkit, executingToolCallIds } = useCopilotKit();
const { agent } = useAgent({ agentId: agentName });
const messages = agent.messages ?? [];
const isRunning = agent.isRunning;
const toolRenderers = useRenderToolRegistry();
// Stable extraData for FlatList to avoid re-creating the object every render
const extraData = useMemo(
() => ({ isRunning, executingToolCallIds, toolRenderers }),
[isRunning, executingToolCallIds, toolRenderers],
);
// Build flat list items from messages
const listItems: ChatListItem[] = useMemo(() => {
const items: ChatListItem[] = [];
for (const msg of messages) {
if (msg.role === "user") {
items.push({
id: msg.id,
type: "user",
content: typeof msg.content === "string" ? msg.content : "",
});
} else if (msg.role === "assistant") {
const assistantMsg = msg as AssistantMessageShape;
// Add text content if present
if (assistantMsg.content) {
items.push({
id: msg.id,
type: "assistant",
content: assistantMsg.content,
});
}
// Add tool calls if present
if (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0) {
for (const tc of assistantMsg.toolCalls) {
items.push({
id: `${msg.id}-tc-${tc.id}`,
type: "tool-call",
toolCalls: [tc],
});
}
}
}
}
// Show loading indicator when agent is running and the last message
// is not already the assistant streaming
if (isRunning) {
const lastItem = items[items.length - 1];
if (!lastItem || lastItem.type !== "assistant") {
items.push({ id: "__loading__", type: "loading" });
}
}
return items;
}, [messages, isRunning]);
// Shared logic for sending a message to the agent
const sendMessage = useCallback(
async (text: string) => {
if (!text || isRunning || !agent) return;
setError(null);
onSendMessage?.(text);
const id = `user-${++messageIdCounter.current}`;
agent.addMessage({
id,
role: "user",
content: text,
} as Message);
try {
await copilotkit.runAgent({ agent });
} catch (err) {
const message =
err instanceof Error ? err.message : "An unexpected error occurred";
console.error("[CopilotChat] runAgent failed:", err);
setError(message);
}
},
[isRunning, agent, copilotkit, onSendMessage],
);
// Send from the input field
const handleSend = useCallback(async () => {
const text = inputText.trim();
if (!text) return;
setInputText("");
await sendMessage(text);
}, [inputText, sendMessage]);
// Handle suggestion pill press
const handleSuggestion = useCallback(
(text: string) => {
void sendMessage(text);
},
[sendMessage],
);
// Auto-scroll when content changes
const handleContentSizeChange = useCallback(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, []);
// Render a single list item
const renderItem = useCallback(
({ item }: ListRenderItemInfo<ChatListItem>) => {
if (item.type === "user") {
return <UserMessage content={item.content ?? ""} />;
}
if (item.type === "assistant") {
return (
<AssistantMessage
content={item.content ?? ""}
isLoading={
isRunning && item.id === listItems[listItems.length - 1]?.id
}
/>
);
}
if (item.type === "tool-call" && item.toolCalls) {
const tc = item.toolCalls[0];
const renderer = toolRenderers.get(tc.function.name);
if (renderer) {
let args: Record<string, unknown> = {};
try {
args = JSON.parse(tc.function.arguments || "{}");
} catch (e) {
console.warn(
`[CopilotChat] Failed to parse tool call arguments for ${tc.function.name}:`,
e,
);
}
const status = executingToolCallIds.has(tc.id)
? "executing"
: "complete";
return renderer({ args, status });
}
// Subtle indicator for unregistered tool calls
return (
<View style={styles.toolCallIndicator}>
<Text style={styles.toolCallText}>Called: {tc.function.name}</Text>
</View>
);
}
if (item.type === "loading") {
return <AssistantMessage content="" isLoading />;
}
return null;
},
[isRunning, listItems, toolRenderers, executingToolCallIds],
);
const keyExtractor = useCallback((item: ChatListItem) => item.id, []);
// Empty state component
const emptyComponent = useMemo(
() => (
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>{emptyStateTitle}</Text>
<Text style={styles.emptySubtitle}>{emptyStateSubtitle}</Text>
{initialMessages.map((suggestion, i) => (
<Pressable
key={`suggestion-${i}`}
style={styles.suggestionPill}
onPress={() => handleSuggestion(suggestion)}
>
<Text style={styles.suggestionText}>{suggestion}</Text>
</Pressable>
))}
</View>
),
[emptyStateTitle, emptyStateSubtitle, initialMessages, handleSuggestion],
);
const sendDisabled = !inputText.trim() || isRunning;
const content = (
<>
{showHeader && (
<View style={styles.header}>
<Text style={styles.headerTitle}>{headerTitle}</Text>
</View>
)}
<FlatListComponent
ref={flatListRef}
data={listItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
extraData={extraData}
contentContainerStyle={[styles.messageList, messageContainerStyle]}
onContentSizeChange={handleContentSizeChange}
ListEmptyComponent={emptyComponent}
/>
{error && (
<View style={styles.errorContainer} testID="error-message">
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.inputContainer, inputContainerStyle]}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder={placeholder}
placeholderTextColor="#999"
multiline
numberOfLines={4}
returnKeyType="send"
onSubmitEditing={handleSend}
/>
<TouchableOpacity
style={[styles.sendButton, sendDisabled && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={sendDisabled}
testID="send-button"
>
<Text style={styles.sendButtonIcon}>{"↑"}</Text>
</TouchableOpacity>
</View>
</>
);
if (disableKeyboardAvoiding) {
return <View style={[styles.container, style]}>{content}</View>;
}
return (
<KeyboardAvoidingView
style={[styles.container, style]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={0}
>
{content}
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#FFFFFF",
},
header: {
height: 56,
justifyContent: "center",
alignItems: "center",
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#E0E0E0",
backgroundColor: "#FFFFFF",
},
headerTitle: {
fontSize: 17,
fontWeight: "600",
color: "#1A1A1A",
},
messageList: {
paddingHorizontal: 16,
flexGrow: 1,
},
inputContainer: {
flexDirection: "row",
alignItems: "flex-end",
paddingHorizontal: 8,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#E0E0E0",
backgroundColor: "#FFFFFF",
},
input: {
flex: 1,
backgroundColor: "#F5F5F5",
borderRadius: 20,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 15,
maxHeight: 100,
color: "#1A1A1A",
},
sendButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#0066CC",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
sendButtonDisabled: {
opacity: 0.4,
},
sendButtonIcon: {
color: "#FFFFFF",
fontSize: 18,
fontWeight: "700",
},
emptyState: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
gap: 12,
},
emptyTitle: {
fontSize: 22,
fontWeight: "700",
color: "#1A1A1A",
marginBottom: 4,
},
emptySubtitle: {
fontSize: 15,
color: "#666666",
marginBottom: 16,
},
suggestionPill: {
backgroundColor: "#E8F0FE",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
},
suggestionText: {
color: "#0066CC",
fontWeight: "600",
fontSize: 14,
},
toolCallIndicator: {
alignSelf: "flex-start",
backgroundColor: "#F0F0F0",
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 6,
marginBottom: 8,
},
toolCallText: {
fontSize: 12,
color: "#999999",
fontStyle: "italic",
},
errorContainer: {
backgroundColor: "#FEE2E2",
paddingHorizontal: 12,
paddingVertical: 8,
marginHorizontal: 8,
borderRadius: 8,
},
errorText: {
color: "#DC2626",
fontSize: 13,
},
});
@@ -0,0 +1,210 @@
/**
* CopilotModal — a bottom-sheet chat overlay for React Native.
*
* Mobile equivalent of CopilotPopup on web. Wraps CopilotChat inside
* @gorhom/bottom-sheet so the chat can slide up over any screen.
*/
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { StyleSheet, View } from "react-native";
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetFlatList,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
import { CopilotChat } from "./CopilotChat";
import type { CopilotChatProps } from "./CopilotChat";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CopilotModalProps {
/** Controlled visibility — when true the sheet opens, when false it closes. */
visible?: boolean;
/** Called when the sheet is dismissed (via backdrop tap, swipe-down, or close()). */
onDismiss?: () => void;
/**
* Bottom-sheet snap points.
* @default ['50%', '90%']
*/
snapPoints?: (string | number)[];
/**
* Which snap-point index to open at.
* @default 0
*/
initialSnapIndex?: number;
/**
* Whether closing the sheet fires onDismiss.
* @default true
*/
enableDismissOnClose?: boolean;
/**
* Backdrop opacity when the sheet is open.
* @default 0.5
*/
backdropOpacity?: number;
// -- Pass-through to CopilotChat ------------------------------------------
/** Which agent to connect to. */
agentName?: string;
/** Input placeholder text. */
placeholder?: string;
/** Seed messages shown on first render. */
initialMessages?: string[];
/** Title shown in the CopilotChat header area. */
headerTitle?: string;
}
/** Imperative handle exposed via ref. */
export interface CopilotModalRef {
/** Programmatically open the bottom sheet. */
open: () => void;
/** Programmatically close the bottom sheet. */
close: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const CopilotModal = forwardRef<CopilotModalRef, CopilotModalProps>(
function CopilotModal(
{
visible,
onDismiss,
snapPoints: snapPointsProp,
initialSnapIndex = 0,
enableDismissOnClose = true,
backdropOpacity = 0.5,
agentName,
placeholder,
initialMessages,
headerTitle,
},
ref,
) {
const bottomSheetRef = useRef<BottomSheet>(null);
// Stable snap-points array
const snapPoints = useMemo(
() => snapPointsProp ?? ["50%", "90%"],
[snapPointsProp],
);
// ── Imperative API ────────────────────────────────────────────────────
useImperativeHandle(
ref,
() => ({
open() {
bottomSheetRef.current?.snapToIndex(initialSnapIndex);
},
close() {
bottomSheetRef.current?.close();
},
}),
[initialSnapIndex],
);
// ── Controlled visibility ─────────────────────────────────────────────
useEffect(() => {
if (visible === undefined) return;
if (visible) {
bottomSheetRef.current?.snapToIndex(initialSnapIndex);
} else {
bottomSheetRef.current?.close();
}
}, [visible, initialSnapIndex]);
// ── Backdrop renderer ─────────────────────────────────────────────────
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={backdropOpacity}
pressBehavior="close"
/>
),
[backdropOpacity],
);
// ── Sheet close handler ───────────────────────────────────────────────
const handleClose = useCallback(() => {
if (enableDismissOnClose) {
onDismiss?.();
}
}, [enableDismissOnClose, onDismiss]);
// ── Build CopilotChat props ───────────────────────────────────────────
const chatProps = useMemo(() => {
const props: Partial<CopilotChatProps> = {};
if (agentName !== undefined) props.agentName = agentName;
if (placeholder !== undefined) props.placeholder = placeholder;
if (initialMessages !== undefined)
props.initialMessages = initialMessages;
if (headerTitle !== undefined) props.headerTitle = headerTitle;
return props;
}, [agentName, placeholder, initialMessages, headerTitle]);
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={renderBackdrop}
onClose={handleClose}
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
>
<BottomSheetView style={styles.contentContainer}>
<CopilotChat
{...chatProps}
FlatListComponent={BottomSheetFlatList}
disableKeyboardAvoiding
/>
</BottomSheetView>
</BottomSheet>
);
},
);
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
sheetBackground: {
backgroundColor: "#FFFFFF",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
handleIndicator: {
width: 40,
height: 4,
backgroundColor: "#DDDDDD",
},
contentContainer: {
flex: 1,
},
});
@@ -0,0 +1,124 @@
import React, { useMemo } from "react";
import { StreamdownText } from "react-native-streamdown";
/**
* Style object accepted by `react-native-enriched-markdown` (and therefore
* `react-native-streamdown`). Each key targets a markdown element; the
* available properties vary per element — see the enriched-markdown style
* reference for the full list.
*/
export type MarkdownStyle = Record<string, Record<string, unknown>>;
/**
* Props for the CopilotMarkdown component.
*/
export interface CopilotMarkdownProps {
/** Markdown string to render. */
content: string;
/** Optional style overrides merged on top of the defaults. */
style?: MarkdownStyle;
/** Whether to enable the streaming fade-in animation (default: true). */
streamingAnimation?: boolean;
}
/**
* Default markdown styles tuned for chat bubble display.
*
* Exported so consumers can spread and extend:
* ```ts
* import { defaultMarkdownStyles } from "@copilotkit/react-native";
* const custom = { ...defaultMarkdownStyles, h1: { fontSize: 28 } };
* ```
*/
export const defaultMarkdownStyles: MarkdownStyle = {
paragraph: {
fontSize: 16,
lineHeight: 24,
color: "#1a1a1a",
marginTop: 4,
marginBottom: 4,
},
h1: {
fontSize: 24,
fontWeight: "bold",
marginTop: 12,
marginBottom: 8,
color: "#111111",
},
h2: {
fontSize: 20,
fontWeight: "bold",
marginTop: 10,
marginBottom: 6,
color: "#111111",
},
h3: {
fontSize: 18,
fontWeight: "600",
marginTop: 8,
marginBottom: 4,
color: "#222222",
},
strong: {
fontWeight: "bold",
},
em: {
fontStyle: "italic",
},
link: {
color: "#0066cc",
underline: true,
},
blockquote: {
backgroundColor: "#f5f5f5",
borderWidth: 4,
borderColor: "#cccccc",
gapWidth: 12,
},
code: {
backgroundColor: "#f0f0f0",
fontFamily: "monospace",
fontSize: 14,
},
codeBlock: {
backgroundColor: "#f0f0f0",
borderRadius: 8,
padding: 12,
fontFamily: "monospace",
fontSize: 14,
},
list: {
marginTop: 4,
marginBottom: 4,
},
};
/**
* Renders markdown content using `react-native-streamdown` with
* pre-configured styles suited for CopilotKit chat bubbles.
*
* `react-native-streamdown` processes incomplete streaming markdown in the
* background, rendering incrementally without visual glitches — ideal for
* displaying LLM output as it arrives.
*
* Custom styles are merged on top of the defaults so callers only need
* to override what they want to change.
*/
export function CopilotMarkdown({
content,
style,
streamingAnimation = true,
}: CopilotMarkdownProps) {
const mergedStyles = useMemo(() => {
if (!style) return defaultMarkdownStyles;
return { ...defaultMarkdownStyles, ...style };
}, [style]);
return (
<StreamdownText
markdown={content}
markdownStyle={mergedStyles}
streamingAnimation={streamingAnimation}
/>
);
}
@@ -0,0 +1,418 @@
import React from "react";
import { render, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
// ─── Hoisted state ─────────────────────────────────────────────────────────────
const hoisted = vi.hoisted(() => {
return {
mockAgent: {
messages: [] as any[],
isRunning: false,
addMessage: vi.fn(),
},
mockRunAgent: vi.fn().mockResolvedValue(undefined),
mockToolRegistry: vi.fn(() => new Map()),
mockExecutingToolCallIds: new Set<string>(),
};
});
// ─── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@copilotkit/react-core/v2/headless", () => ({
useAgent: vi.fn(() => ({ agent: hoisted.mockAgent })),
}));
vi.mock("@copilotkit/react-core/v2/context", () => ({
useCopilotKit: vi.fn(() => ({
copilotkit: { runAgent: hoisted.mockRunAgent },
executingToolCallIds: hoisted.mockExecutingToolCallIds,
})),
}));
vi.mock("../messages/AssistantMessage", () => ({
AssistantMessage: ({ content, isLoading }: any) => {
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "assistant-message" },
isLoading ? "Loading..." : content,
);
},
}));
vi.mock("../messages/UserMessage", () => ({
UserMessage: ({ content }: any) => {
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "user-message" },
content,
);
},
}));
vi.mock("../../hooks/RenderToolContext", () => ({
useRenderToolRegistry: () => hoisted.mockToolRegistry(),
}));
// Mock react-native components with testable DOM elements
vi.mock("react-native", () => {
const React = require("react");
return {
FlatList: ({ data, renderItem, ListEmptyComponent, keyExtractor }: any) => {
if (!data || data.length === 0) {
return React.createElement(
"div",
{ "data-testid": "flatlist" },
ListEmptyComponent,
);
}
return React.createElement(
"div",
{ "data-testid": "flatlist" },
data.map((item: any, index: number) =>
React.createElement(
"div",
{ key: keyExtractor?.(item, index) ?? index },
renderItem({ item, index }),
),
),
);
},
KeyboardAvoidingView: ({ children }: any) =>
React.createElement("div", { "data-testid": "keyboard-view" }, children),
Platform: { OS: "ios" },
Pressable: ({ children, onPress, ...props }: any) =>
React.createElement(
"button",
{ onClick: onPress, "data-testid": "pressable", ...props },
children,
),
StyleSheet: {
create: (styles: any) => styles,
hairlineWidth: 1,
},
Text: ({ children, ...props }: any) =>
React.createElement("span", props, children),
TextInput: ({ value, onChangeText, onSubmitEditing, ...props }: any) =>
React.createElement("input", {
value,
onChange: (e: any) => onChangeText?.(e.target.value),
onKeyDown: (e: any) => {
if (e.key === "Enter") onSubmitEditing?.();
},
"data-testid": "text-input",
...props,
}),
TouchableOpacity: ({
children,
onPress,
disabled,
testID,
...props
}: any) =>
React.createElement(
"button",
{
onClick: onPress,
disabled,
...(testID ? { "data-testid": testID } : {}),
...props,
},
children,
),
View: ({ children, ...props }: any) =>
React.createElement("div", props, children),
};
});
import { CopilotChat } from "../CopilotChat";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotChat edge cases", () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.mockAgent.messages = [];
hoisted.mockAgent.isRunning = false;
hoisted.mockAgent.addMessage = vi.fn();
hoisted.mockRunAgent.mockResolvedValue(undefined);
hoisted.mockToolRegistry.mockReturnValue(new Map());
hoisted.mockExecutingToolCallIds.clear();
});
describe("disableKeyboardAvoiding", () => {
it("wraps content in KeyboardAvoidingView by default", () => {
const { getByTestId } = render(<CopilotChat />);
expect(getByTestId("keyboard-view")).toBeTruthy();
});
it("skips KeyboardAvoidingView when disableKeyboardAvoiding is true", () => {
const { queryByTestId } = render(<CopilotChat disableKeyboardAvoiding />);
expect(queryByTestId("keyboard-view")).toBeNull();
});
});
describe("FlatListComponent", () => {
it("uses custom FlatListComponent when provided", () => {
const CustomFlatList = ({
data,
renderItem,
ListEmptyComponent,
keyExtractor,
}: any) => {
return React.createElement(
"div",
{ "data-testid": "custom-flatlist" },
ListEmptyComponent,
);
};
const { getByTestId, queryByTestId } = render(
<CopilotChat FlatListComponent={CustomFlatList} />,
);
expect(getByTestId("custom-flatlist")).toBeTruthy();
// The default FlatList should not be rendered
expect(queryByTestId("flatlist")).toBeNull();
});
});
describe("malformed tool call arguments", () => {
it("handles invalid JSON in tool arguments gracefully", () => {
const mockRenderer = (props: any) => {
return React.createElement(
"div",
{ "data-testid": "tool-render" },
`args: ${JSON.stringify(props.args)}`,
);
};
const toolMap = new Map([["brokenTool", mockRenderer]]);
hoisted.mockToolRegistry.mockReturnValue(toolMap);
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: {
name: "brokenTool",
arguments: "this is not valid JSON{{{",
},
},
],
},
];
// Suppress the expected console.warn
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { getByTestId } = render(<CopilotChat />);
// Should render with empty args instead of crashing
expect(getByTestId("tool-render")).toBeTruthy();
expect(getByTestId("tool-render").textContent).toBe("args: {}");
spy.mockRestore();
});
it("handles empty string arguments in tool calls", () => {
const mockRenderer = (props: any) => {
return React.createElement(
"div",
{ "data-testid": "tool-render" },
`args: ${JSON.stringify(props.args)}`,
);
};
const toolMap = new Map([["emptyArgsTool", mockRenderer]]);
hoisted.mockToolRegistry.mockReturnValue(toolMap);
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: {
name: "emptyArgsTool",
arguments: "",
},
},
],
},
];
const { getByTestId } = render(<CopilotChat />);
expect(getByTestId("tool-render")).toBeTruthy();
expect(getByTestId("tool-render").textContent).toBe("args: {}");
});
});
describe("tool call status", () => {
it("passes 'executing' status when tool call is in executingToolCallIds", () => {
const receivedProps: any[] = [];
const mockRenderer = (props: any) => {
receivedProps.push(props);
return React.createElement(
"div",
{ "data-testid": "tool-render" },
`status: ${props.status}`,
);
};
const toolMap = new Map([["myTool", mockRenderer]]);
hoisted.mockToolRegistry.mockReturnValue(toolMap);
// Add executing tool call ID to the shared set
hoisted.mockExecutingToolCallIds.add("tc-1");
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: { name: "myTool", arguments: '{"key":"val"}' },
},
],
},
];
const { getByTestId } = render(<CopilotChat />);
expect(getByTestId("tool-render").textContent).toBe("status: executing");
expect(receivedProps[0].status).toBe("executing");
});
});
describe("message list building", () => {
it("handles assistant messages with empty content and tool calls", () => {
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: { name: "unknownTool", arguments: "{}" },
},
],
},
];
const { getByText, queryAllByTestId } = render(<CopilotChat />);
// Should show tool call indicator, not an empty assistant message
expect(getByText("Called: unknownTool")).toBeTruthy();
expect(queryAllByTestId("assistant-message")).toHaveLength(0);
});
it("handles assistant messages with both content and tool calls", () => {
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "Let me check that for you",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: { name: "searchTool", arguments: "{}" },
},
],
},
];
const { getByText, getByTestId } = render(<CopilotChat />);
// Should show both content and tool call indicator
expect(getByTestId("assistant-message").textContent).toBe(
"Let me check that for you",
);
expect(getByText("Called: searchTool")).toBeTruthy();
});
it("shows loading indicator when agent is running and last message is from user", () => {
hoisted.mockAgent.messages = [
{ id: "1", role: "user", content: "Hello" },
];
hoisted.mockAgent.isRunning = true;
const { getAllByTestId } = render(<CopilotChat />);
// Should have user message + loading assistant message
const userMessages = getAllByTestId("user-message");
const assistantMessages = getAllByTestId("assistant-message");
expect(userMessages).toHaveLength(1);
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].textContent).toBe("Loading...");
});
it("does not add extra loading indicator when last item is already an assistant message", () => {
hoisted.mockAgent.messages = [
{ id: "1", role: "user", content: "Hello" },
{ id: "2", role: "assistant", content: "I'm thinking..." },
];
hoisted.mockAgent.isRunning = true;
const { getAllByTestId } = render(<CopilotChat />);
// Should NOT add an extra loading indicator since last message is assistant
const assistantMessages = getAllByTestId("assistant-message");
expect(assistantMessages).toHaveLength(1);
});
});
describe("empty message handling", () => {
it("does not send a whitespace-only message", async () => {
const { getByTestId } = render(<CopilotChat />);
const input = getByTestId("text-input");
const sendBtn = getByTestId("send-button");
await act(async () => {
fireEvent.change(input, { target: { value: " " } });
});
// Button should still be disabled for whitespace-only input
expect(sendBtn).toHaveProperty("disabled", true);
});
});
describe("suggestion pill interaction", () => {
it("sends a message when a suggestion pill is pressed", async () => {
const suggestions = ["Tell me a joke"];
const { getByText } = render(
<CopilotChat initialMessages={suggestions} />,
);
await act(async () => {
fireEvent.click(getByText("Tell me a joke"));
});
expect(hoisted.mockAgent.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: "user",
content: "Tell me a joke",
}),
);
expect(hoisted.mockRunAgent).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,390 @@
import React from "react";
import { render, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
// ─── Hoisted state ─────────────────────────────────────────────────────────────
const hoisted = vi.hoisted(() => {
return {
mockAgent: {
messages: [] as any[],
isRunning: false,
addMessage: vi.fn(),
},
mockRunAgent: vi.fn().mockResolvedValue(undefined),
mockToolRegistry: vi.fn(() => new Map()),
};
});
// ─── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@copilotkit/react-core/v2/headless", () => ({
useAgent: vi.fn(() => ({ agent: hoisted.mockAgent })),
}));
vi.mock("@copilotkit/react-core/v2/context", () => ({
useCopilotKit: vi.fn(() => ({
copilotkit: { runAgent: hoisted.mockRunAgent },
executingToolCallIds: new Set<string>(),
})),
}));
// Mock sub-components that B2 builds
vi.mock("../messages/AssistantMessage", () => ({
AssistantMessage: ({ content, isLoading }: any) => {
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "assistant-message" },
isLoading ? "Loading..." : content,
);
},
}));
vi.mock("../messages/UserMessage", () => ({
UserMessage: ({ content }: any) => {
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "user-message" },
content,
);
},
}));
// Mock RenderToolContext (B3)
vi.mock("../../hooks/RenderToolContext", () => ({
useRenderToolRegistry: () => hoisted.mockToolRegistry(),
}));
// Mock react-native components with testable DOM elements
vi.mock("react-native", () => {
const React = require("react");
return {
FlatList: ({ data, renderItem, ListEmptyComponent, keyExtractor }: any) => {
if (!data || data.length === 0) {
return React.createElement(
"div",
{ "data-testid": "flatlist" },
ListEmptyComponent,
);
}
return React.createElement(
"div",
{ "data-testid": "flatlist" },
data.map((item: any, index: number) =>
React.createElement(
"div",
{ key: keyExtractor?.(item, index) ?? index },
renderItem({ item, index }),
),
),
);
},
KeyboardAvoidingView: ({ children }: any) =>
React.createElement("div", { "data-testid": "keyboard-view" }, children),
Platform: { OS: "ios" },
Pressable: ({ children, onPress, ...props }: any) =>
React.createElement(
"button",
{ onClick: onPress, "data-testid": "pressable", ...props },
children,
),
StyleSheet: {
create: (styles: any) => styles,
hairlineWidth: 1,
},
Text: ({ children, ...props }: any) =>
React.createElement("span", props, children),
TextInput: ({ value, onChangeText, onSubmitEditing, ...props }: any) =>
React.createElement("input", {
value,
onChange: (e: any) => onChangeText?.(e.target.value),
onKeyDown: (e: any) => {
if (e.key === "Enter") onSubmitEditing?.();
},
"data-testid": "text-input",
...props,
}),
TouchableOpacity: ({
children,
onPress,
disabled,
testID,
...props
}: any) =>
React.createElement(
"button",
{
onClick: onPress,
disabled,
...(testID ? { "data-testid": testID } : {}),
...props,
},
children,
),
View: ({ children, ...props }: any) =>
React.createElement("div", props, children),
};
});
// Import component under test AFTER mocks
import { CopilotChat } from "../CopilotChat";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotChat", () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.mockAgent.messages = [];
hoisted.mockAgent.isRunning = false;
hoisted.mockAgent.addMessage = vi.fn();
hoisted.mockRunAgent.mockResolvedValue(undefined);
hoisted.mockToolRegistry.mockReturnValue(new Map());
});
it("renders empty state when there are no messages", () => {
const { getByText } = render(<CopilotChat />);
expect(getByText("How can I help?")).toBeTruthy();
expect(
getByText("Ask me anything or try a suggestion below."),
).toBeTruthy();
});
it("renders custom empty state title and subtitle", () => {
const { getByText } = render(
<CopilotChat
emptyStateTitle="Welcome!"
emptyStateSubtitle="Start chatting"
/>,
);
expect(getByText("Welcome!")).toBeTruthy();
expect(getByText("Start chatting")).toBeTruthy();
});
it("renders suggestion pills when initialMessages provided", () => {
const suggestions = ["Hello", "Help me"];
const { getByText } = render(<CopilotChat initialMessages={suggestions} />);
expect(getByText("Hello")).toBeTruthy();
expect(getByText("Help me")).toBeTruthy();
});
it("renders user and assistant messages", () => {
hoisted.mockAgent.messages = [
{ id: "1", role: "user", content: "Hi there" },
{ id: "2", role: "assistant", content: "Hello! How can I help?" },
];
const { getAllByTestId } = render(<CopilotChat />);
const userMessages = getAllByTestId("user-message");
const assistantMessages = getAllByTestId("assistant-message");
expect(userMessages).toHaveLength(1);
expect(assistantMessages).toHaveLength(1);
expect(userMessages[0].textContent).toBe("Hi there");
expect(assistantMessages[0].textContent).toBe("Hello! How can I help?");
});
it("shows loading indicator when agent is running", () => {
hoisted.mockAgent.messages = [{ id: "1", role: "user", content: "Hi" }];
hoisted.mockAgent.isRunning = true;
const { getAllByTestId } = render(<CopilotChat />);
const assistantMessages = getAllByTestId("assistant-message");
expect(assistantMessages.length).toBeGreaterThanOrEqual(1);
const loadingMsg = assistantMessages[assistantMessages.length - 1];
expect(loadingMsg.textContent).toBe("Loading...");
});
it("calls agent.addMessage and copilotkit.runAgent on send", async () => {
const { getByTestId } = render(<CopilotChat />);
const input = getByTestId("text-input");
const sendBtn = getByTestId("send-button");
await act(async () => {
fireEvent.change(input, { target: { value: "Test message" } });
});
await act(async () => {
fireEvent.click(sendBtn);
});
expect(hoisted.mockAgent.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: "user",
content: "Test message",
}),
);
expect(hoisted.mockRunAgent).toHaveBeenCalledWith({
agent: hoisted.mockAgent,
});
});
it("disables send button when input is empty", () => {
const { getByTestId } = render(<CopilotChat />);
const sendBtn = getByTestId("send-button");
expect(sendBtn).toHaveProperty("disabled", true);
});
it("disables send button when agent is running", async () => {
hoisted.mockAgent.isRunning = true;
const { getByTestId } = render(<CopilotChat />);
const input = getByTestId("text-input");
await act(async () => {
fireEvent.change(input, { target: { value: "Test" } });
});
const sendBtn = getByTestId("send-button");
expect(sendBtn).toHaveProperty("disabled", true);
});
it("shows header when showHeader is true", () => {
const { getByText } = render(
<CopilotChat showHeader headerTitle="My Chat" />,
);
expect(getByText("My Chat")).toBeTruthy();
});
it("hides header when showHeader is false", () => {
const { queryByText } = render(
<CopilotChat showHeader={false} headerTitle="Hidden" />,
);
expect(queryByText("Hidden")).toBeNull();
});
it("calls onSendMessage callback when sending", async () => {
const onSend = vi.fn();
const { getByTestId } = render(<CopilotChat onSendMessage={onSend} />);
const input = getByTestId("text-input");
const sendBtn = getByTestId("send-button");
await act(async () => {
fireEvent.change(input, { target: { value: "Callback test" } });
});
await act(async () => {
fireEvent.click(sendBtn);
});
expect(onSend).toHaveBeenCalledWith("Callback test");
});
it("renders tool call indicator for unregistered tools", () => {
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: { name: "myTool", arguments: "{}" },
},
],
},
];
const { getByText } = render(<CopilotChat />);
expect(getByText("Called: myTool")).toBeTruthy();
});
it("renders registered tool renderer with correct props", () => {
const receivedProps: any[] = [];
const mockRenderer = (props: any) => {
receivedProps.push(props);
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "tool-render" },
`rendered: ${JSON.stringify(props.args)}`,
);
};
const toolMap = new Map([["myTool", mockRenderer]]);
hoisted.mockToolRegistry.mockReturnValue(toolMap);
hoisted.mockAgent.messages = [
{
id: "1",
role: "assistant",
content: "",
toolCalls: [
{
id: "tc-1",
type: "function" as const,
function: {
name: "myTool",
arguments: '{"city":"Seattle"}',
},
},
],
},
];
const { getByTestId } = render(<CopilotChat />);
expect(getByTestId("tool-render")).toBeTruthy();
expect(receivedProps[0]).toEqual({
args: { city: "Seattle" },
status: "complete",
});
});
it("shows error message when runAgent fails", async () => {
hoisted.mockRunAgent.mockRejectedValueOnce(new Error("Network timeout"));
const { getByTestId, getByText } = render(<CopilotChat />);
const input = getByTestId("text-input");
const sendBtn = getByTestId("send-button");
await act(async () => {
fireEvent.change(input, { target: { value: "fail message" } });
});
await act(async () => {
fireEvent.click(sendBtn);
});
expect(getByText("Network timeout")).toBeTruthy();
});
it("uses incrementing message IDs instead of Date.now()", async () => {
const { getByTestId } = render(<CopilotChat />);
const input = getByTestId("text-input");
const sendBtn = getByTestId("send-button");
await act(async () => {
fireEvent.change(input, { target: { value: "first" } });
});
await act(async () => {
fireEvent.click(sendBtn);
});
await act(async () => {
fireEvent.change(input, { target: { value: "second" } });
});
await act(async () => {
fireEvent.click(sendBtn);
});
const calls = hoisted.mockAgent.addMessage.mock.calls;
expect(calls[0][0].id).toBe("user-1");
expect(calls[1][0].id).toBe("user-2");
});
});
@@ -0,0 +1,257 @@
import React, { createRef } from "react";
import { render, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const hoisted = vi.hoisted(() => {
const snapToIndex = vi.fn();
const close = vi.fn();
return {
snapToIndex,
close,
lastOnClose: null as (() => void) | null,
lastSnapPoints: null as (string | number)[] | null,
lastIndex: null as number | null,
};
});
// Mock react-native primitives used in CopilotModal
vi.mock("react-native", () => ({
StyleSheet: {
create: (s: Record<string, unknown>) => s,
},
View: "View",
}));
// Mock @gorhom/bottom-sheet
vi.mock("@gorhom/bottom-sheet", () => {
const React = require("react");
// BottomSheet stores its ref and captures props for assertions
const BottomSheet = React.forwardRef(function MockBottomSheet(
props: any,
ref: any,
) {
React.useImperativeHandle(ref, () => ({
snapToIndex: hoisted.snapToIndex,
close: hoisted.close,
}));
hoisted.lastOnClose = props.onClose ?? null;
hoisted.lastSnapPoints = props.snapPoints ?? null;
hoisted.lastIndex = props.index ?? null;
return React.createElement(
"mock-bottom-sheet",
{ "data-testid": "bottom-sheet" },
props.children,
);
});
const BottomSheetView = (props: any) =>
React.createElement(
"mock-bottom-sheet-view",
{ "data-testid": "bottom-sheet-view" },
props.children,
);
const BottomSheetBackdrop = (props: any) =>
React.createElement("mock-backdrop", props);
const BottomSheetFlatList = (props: any) =>
React.createElement(
"mock-bottom-sheet-flatlist",
{ "data-testid": "bottom-sheet-flatlist" },
props.children,
);
return {
__esModule: true,
default: BottomSheet,
BottomSheetView,
BottomSheetBackdrop,
BottomSheetFlatList,
};
});
// Mock CopilotChat (B4's component — may not exist yet in this worktree)
vi.mock("../CopilotChat", () => {
const React = require("react");
const CopilotChat = (props: any) =>
React.createElement("mock-copilot-chat", {
"data-testid": "copilot-chat",
...props,
});
return { CopilotChat };
});
// Import after mocks
import { CopilotModal } from "../CopilotModal";
import type { CopilotModalRef } from "../CopilotModal";
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("CopilotModal", () => {
beforeEach(() => {
hoisted.snapToIndex.mockClear();
hoisted.close.mockClear();
hoisted.lastOnClose = null;
hoisted.lastSnapPoints = null;
hoisted.lastIndex = null;
});
// ── Rendering ───────────────────────────────────────────────────────────
describe("rendering", () => {
it("renders the bottom sheet with default snap points", () => {
render(<CopilotModal />);
expect(hoisted.lastSnapPoints).toEqual(["50%", "90%"]);
});
it("starts closed (index -1)", () => {
render(<CopilotModal />);
expect(hoisted.lastIndex).toBe(-1);
});
it("embeds CopilotChat inside the sheet", () => {
const { getByTestId } = render(<CopilotModal />);
expect(getByTestId("copilot-chat")).toBeTruthy();
});
});
// ── Controlled visibility ─────────────────────────────────────────────
describe("controlled visibility", () => {
it("opens the sheet when visible becomes true", () => {
const { rerender } = render(<CopilotModal visible={false} />);
rerender(<CopilotModal visible={true} />);
expect(hoisted.snapToIndex).toHaveBeenCalledWith(0);
});
it("closes the sheet when visible becomes false", () => {
const { rerender } = render(<CopilotModal visible={true} />);
hoisted.snapToIndex.mockClear();
rerender(<CopilotModal visible={false} />);
expect(hoisted.close).toHaveBeenCalled();
});
});
// ── onDismiss ─────────────────────────────────────────────────────────
describe("onDismiss", () => {
it("calls onDismiss when the sheet closes", () => {
const onDismiss = vi.fn();
render(<CopilotModal onDismiss={onDismiss} />);
// Simulate bottom-sheet calling onClose
act(() => {
hoisted.lastOnClose?.();
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("does not call onDismiss when enableDismissOnClose is false", () => {
const onDismiss = vi.fn();
render(
<CopilotModal onDismiss={onDismiss} enableDismissOnClose={false} />,
);
act(() => {
hoisted.lastOnClose?.();
});
expect(onDismiss).not.toHaveBeenCalled();
});
});
// ── Custom snap points ────────────────────────────────────────────────
describe("custom snap points", () => {
it("uses provided snap points", () => {
render(<CopilotModal snapPoints={["25%", "75%", "100%"]} />);
expect(hoisted.lastSnapPoints).toEqual(["25%", "75%", "100%"]);
});
it("opens at the specified initialSnapIndex", () => {
const ref = createRef<CopilotModalRef>();
render(<CopilotModal ref={ref} initialSnapIndex={1} />);
act(() => {
ref.current?.open();
});
expect(hoisted.snapToIndex).toHaveBeenCalledWith(1);
});
});
// ── Imperative API ────────────────────────────────────────────────────
describe("imperative API", () => {
it("exposes open() via ref", () => {
const ref = createRef<CopilotModalRef>();
render(<CopilotModal ref={ref} />);
act(() => {
ref.current?.open();
});
expect(hoisted.snapToIndex).toHaveBeenCalledWith(0);
});
it("exposes close() via ref", () => {
const ref = createRef<CopilotModalRef>();
render(<CopilotModal ref={ref} />);
act(() => {
ref.current?.close();
});
expect(hoisted.close).toHaveBeenCalled();
});
});
// ── CopilotChat pass-through ──────────────────────────────────────────
describe("CopilotChat integration", () => {
it("passes agentName to CopilotChat", () => {
const { getByTestId } = render(<CopilotModal agentName="test-agent" />);
const chat = getByTestId("copilot-chat");
expect(chat.getAttribute("agentName")).toBe("test-agent");
});
it("passes placeholder to CopilotChat", () => {
const { getByTestId } = render(
<CopilotModal placeholder="Ask me anything..." />,
);
const chat = getByTestId("copilot-chat");
expect(chat.getAttribute("placeholder")).toBe("Ask me anything...");
});
it("passes headerTitle to CopilotChat", () => {
const { getByTestId } = render(
<CopilotModal headerTitle="AI Assistant" />,
);
const chat = getByTestId("copilot-chat");
expect(chat.getAttribute("headerTitle")).toBe("AI Assistant");
});
});
});
@@ -0,0 +1,106 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
// Mock react-native since we're in jsdom
vi.mock("react-native", () => ({
StyleSheet: {
create: <T extends Record<string, any>>(styles: T): T => styles,
flatten: (style: any) => style,
},
View: "View",
Text: "Text",
}));
// Capture the props passed to StreamdownText
let lastStreamdownProps: any = null;
vi.mock("react-native-streamdown", () => ({
StreamdownText: function MockStreamdownText(props: any) {
lastStreamdownProps = props;
return React.createElement(
"div",
{ "data-testid": "markdown" },
props.markdown,
);
},
}));
// Import after mocks
import { CopilotMarkdown, defaultMarkdownStyles } from "../Markdown";
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("CopilotMarkdown", () => {
beforeEach(() => {
lastStreamdownProps = null;
});
it("renders without crashing", () => {
const { container } = render(<CopilotMarkdown content="Hello world" />);
expect(container).toBeTruthy();
});
it("passes content as markdown prop to StreamdownText", () => {
render(<CopilotMarkdown content="# Title" />);
expect(lastStreamdownProps).not.toBeNull();
expect(lastStreamdownProps.markdown).toBe("# Title");
});
it("uses default styles when no custom style is provided", () => {
render(<CopilotMarkdown content="test" />);
expect(lastStreamdownProps.markdownStyle).toBe(defaultMarkdownStyles);
});
it("merges custom styles with defaults", () => {
const customStyle = { paragraph: { fontSize: 20, color: "#000" } };
render(<CopilotMarkdown content="test" style={customStyle} />);
// Custom should override the paragraph style
expect(lastStreamdownProps.markdownStyle.paragraph).toEqual({
fontSize: 20,
color: "#000",
});
// Other defaults should still be present
expect(lastStreamdownProps.markdownStyle.h1).toEqual(
defaultMarkdownStyles.h1,
);
expect(lastStreamdownProps.markdownStyle.codeBlock).toEqual(
defaultMarkdownStyles.codeBlock,
);
});
it("renders safely with empty content", () => {
const { container } = render(<CopilotMarkdown content="" />);
expect(container).toBeTruthy();
expect(lastStreamdownProps.markdown).toBe("");
});
it("enables streamingAnimation by default", () => {
render(<CopilotMarkdown content="test" />);
expect(lastStreamdownProps.streamingAnimation).toBe(true);
});
it("allows disabling streamingAnimation", () => {
render(<CopilotMarkdown content="test" streamingAnimation={false} />);
expect(lastStreamdownProps.streamingAnimation).toBe(false);
});
});
describe("defaultMarkdownStyles", () => {
it("exports a style object with expected keys", () => {
expect(defaultMarkdownStyles.paragraph).toBeDefined();
expect(defaultMarkdownStyles.h1).toBeDefined();
expect(defaultMarkdownStyles.h2).toBeDefined();
expect(defaultMarkdownStyles.h3).toBeDefined();
expect(defaultMarkdownStyles.strong).toBeDefined();
expect(defaultMarkdownStyles.em).toBeDefined();
expect(defaultMarkdownStyles.link).toBeDefined();
expect(defaultMarkdownStyles.blockquote).toBeDefined();
expect(defaultMarkdownStyles.code).toBeDefined();
expect(defaultMarkdownStyles.codeBlock).toBeDefined();
expect(defaultMarkdownStyles.list).toBeDefined();
});
});
@@ -0,0 +1,18 @@
/**
* Component barrel — importable via `@copilotkit/react-native/components`.
*/
export { CopilotChat } from "./CopilotChat";
export type { CopilotChatProps } from "./CopilotChat";
export { CopilotModal } from "./CopilotModal";
export type { CopilotModalProps, CopilotModalRef } from "./CopilotModal";
export { CopilotMarkdown, defaultMarkdownStyles } from "./Markdown";
export type { CopilotMarkdownProps } from "./Markdown";
export { AssistantMessage } from "./messages/AssistantMessage";
export type { AssistantMessageProps } from "./messages/AssistantMessage";
export { UserMessage } from "./messages/UserMessage";
export type { UserMessageProps } from "./messages/UserMessage";
@@ -0,0 +1,73 @@
import React from "react";
import { View, Text, StyleSheet, type ViewStyle } from "react-native";
import { CopilotMarkdown } from "../Markdown";
import { TypingIndicator } from "./TypingIndicator";
import { formatTimestamp } from "./utils";
// ─── Colors ──────────────────────────────────────────────────────────────────
const ASSISTANT_BUBBLE_BG = "#F0F0F0";
const ASSISTANT_TEXT_COLOR = "#1A1A1A";
const TIMESTAMP_COLOR = "#999999";
/**
* Props for the AssistantMessage component.
*/
export interface AssistantMessageProps {
/** Markdown content to render inside the bubble */
content: string;
/** When true, shows a typing indicator instead of content */
isLoading?: boolean;
/** Optional timestamp displayed below the bubble */
timestamp?: Date;
/** Optional style override for the outer container */
style?: ViewStyle;
}
/**
* Left-aligned chat bubble for AI assistant responses.
*
* Renders markdown content via `CopilotMarkdown` and shows an animated
* typing indicator when `isLoading` is true.
*/
export function AssistantMessage({
content,
isLoading = false,
timestamp,
style,
}: AssistantMessageProps) {
return (
<View style={[styles.container, style]}>
<View style={styles.bubble}>
{content ? <CopilotMarkdown content={content} /> : null}
{isLoading ? <TypingIndicator /> : null}
</View>
{timestamp && (
<Text style={styles.timestamp}>{formatTimestamp(timestamp)}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "flex-start",
marginVertical: 4,
paddingHorizontal: 12,
},
bubble: {
backgroundColor: ASSISTANT_BUBBLE_BG,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomRightRadius: 16,
borderBottomLeftRadius: 4,
paddingHorizontal: 12,
paddingVertical: 8,
maxWidth: "80%",
},
timestamp: {
color: TIMESTAMP_COLOR,
fontSize: 11,
marginTop: 2,
marginLeft: 4,
},
});
@@ -0,0 +1,105 @@
import React, { useEffect, useRef } from "react";
import { View, Animated, StyleSheet } from "react-native";
import type { ViewStyle } from "react-native";
/**
* Props for the TypingIndicator component.
*/
export interface TypingIndicatorProps {
/** Optional style override for the container */
style?: ViewStyle;
}
const DOT_SIZE = 6;
const DOT_SPACING = 4;
const ANIMATION_DURATION = 400;
/**
* Three animated dots that pulse in sequence, suitable for embedding
* inside an AssistantMessage to indicate the AI is still generating.
*
* Uses React Native's built-in `Animated` API (no Reanimated dependency).
*/
export function TypingIndicator({ style }: TypingIndicatorProps) {
const dot1 = useRef(new Animated.Value(0)).current;
const dot2 = useRef(new Animated.Value(0)).current;
const dot3 = useRef(new Animated.Value(0)).current;
useEffect(() => {
const createPulse = (dot: Animated.Value, delay: number) =>
Animated.sequence([
Animated.delay(delay),
Animated.loop(
Animated.sequence([
Animated.timing(dot, {
toValue: 1,
duration: ANIMATION_DURATION,
useNativeDriver: true,
}),
Animated.timing(dot, {
toValue: 0,
duration: ANIMATION_DURATION,
useNativeDriver: true,
}),
]),
),
]);
const animation = Animated.parallel([
createPulse(dot1, 0),
createPulse(dot2, ANIMATION_DURATION * 0.33),
createPulse(dot3, ANIMATION_DURATION * 0.66),
]);
animation.start();
return () => {
animation.stop();
};
}, [dot1, dot2, dot3]);
const dotStyle = (animatedValue: Animated.Value) => ({
...styles.dot,
opacity: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
transform: [
{
scale: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1.2],
}),
},
],
});
return (
<View
testID="copilot-loading-cursor"
style={[styles.container, style]}
accessibilityLabel="Typing indicator"
accessibilityRole="text"
>
<Animated.View style={dotStyle(dot1)} />
<Animated.View style={dotStyle(dot2)} />
<Animated.View style={dotStyle(dot3)} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 4,
paddingHorizontal: 2,
},
dot: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: DOT_SIZE / 2,
backgroundColor: "#999999",
marginHorizontal: DOT_SPACING / 2,
},
});
@@ -0,0 +1,68 @@
import React from "react";
import { View, Text, StyleSheet, type ViewStyle } from "react-native";
import { formatTimestamp } from "./utils";
// ─── Colors ──────────────────────────────────────────────────────────────────
const USER_BUBBLE_BG = "#0066CC";
const USER_TEXT_COLOR = "#FFFFFF";
const TIMESTAMP_COLOR = "#999999";
/**
* Props for the UserMessage component.
*/
export interface UserMessageProps {
/** Plain text content to display */
content: string;
/** Optional timestamp displayed below the bubble */
timestamp?: Date;
/** Optional style override for the outer container */
style?: ViewStyle;
}
/**
* Right-aligned chat bubble for user messages.
*
* Renders plain text (no markdown) with a primary-color background
* and white text. Optionally displays a subtle timestamp below.
*/
export function UserMessage({ content, timestamp, style }: UserMessageProps) {
return (
<View style={[styles.container, style]}>
<View style={styles.bubble}>
<Text style={styles.text}>{content}</Text>
</View>
{timestamp && (
<Text style={styles.timestamp}>{formatTimestamp(timestamp)}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "flex-end",
marginVertical: 4,
paddingHorizontal: 12,
},
bubble: {
backgroundColor: USER_BUBBLE_BG,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 16,
borderBottomRightRadius: 4,
paddingHorizontal: 12,
paddingVertical: 8,
maxWidth: "80%",
},
text: {
color: USER_TEXT_COLOR,
fontSize: 16,
lineHeight: 22,
},
timestamp: {
color: TIMESTAMP_COLOR,
fontSize: 11,
marginTop: 2,
marginRight: 4,
},
});
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
/**
* Verifies the react-native TypingIndicator renders the stable
* `copilot-loading-cursor` testID so e2e tests can deterministically detect
* the "still loading" state. Uses RN's `testID` (capital ID) convention.
*/
const tiPath = resolve(__dirname, "../TypingIndicator.tsx");
const tiSrc = readFileSync(tiPath, "utf-8");
describe("react-native stable testids", () => {
it("TypingIndicator renders the copilot-loading-cursor testID", () => {
expect(tiSrc).toMatch(/testID="copilot-loading-cursor"/);
});
});
@@ -0,0 +1,179 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
// ─── Mock react-native ───────────────────────────────────────────────────────
vi.mock("react-native", () => {
const React = require("react");
const View = React.forwardRef(
(
{
children,
style,
testID,
accessibilityLabel,
accessibilityRole,
...rest
}: any,
ref: any,
) =>
React.createElement(
"div",
{
ref,
style,
"data-testid": testID,
"aria-label": accessibilityLabel,
role: accessibilityRole,
...rest,
},
children,
),
);
View.displayName = "View";
const Text = React.forwardRef(({ children, style, ...rest }: any, ref: any) =>
React.createElement("span", { ref, style, ...rest }, children),
);
Text.displayName = "Text";
const AnimatedValue = class {
_value: number;
constructor(value: number) {
this._value = value;
}
interpolate({ outputRange }: any) {
return outputRange[0];
}
};
const AnimatedView = React.forwardRef(
({ children, style, ...rest }: any, ref: any) =>
React.createElement("div", { ref, style, ...rest }, children),
);
AnimatedView.displayName = "Animated.View";
const timing = () => ({ start: vi.fn(), stop: vi.fn() });
const sequence = () => ({ start: vi.fn(), stop: vi.fn() });
const loop = () => ({ start: vi.fn(), stop: vi.fn() });
const parallel = () => ({ start: vi.fn(), stop: vi.fn() });
const delay = () => ({ start: vi.fn(), stop: vi.fn() });
return {
View,
Text,
Animated: {
Value: AnimatedValue,
View: AnimatedView,
timing,
sequence,
loop,
parallel,
delay,
},
StyleSheet: {
create: (styles: any) => styles,
flatten: (style: any) =>
Array.isArray(style) ? Object.assign({}, ...style) : style || {},
},
};
});
// ─── Mock Markdown ────────────────────────────────────────────────────────────
vi.mock("../../Markdown", () => ({
CopilotMarkdown: ({ content }: { content: string }) => {
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "copilot-markdown" },
content,
);
},
}));
import { AssistantMessage } from "../AssistantMessage";
import { UserMessage } from "../UserMessage";
import { TypingIndicator } from "../TypingIndicator";
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("AssistantMessage edge cases", () => {
it("renders empty content without crashing", () => {
const { container } = render(<AssistantMessage content="" />);
expect(container).toBeTruthy();
});
it("shows both markdown content AND typing indicator when both content and isLoading are provided", () => {
// When content is truthy AND isLoading is true, BOTH should render
// Looking at the source: content is rendered when truthy, isLoading adds typing indicator
const { queryByTestId, queryByLabelText } = render(
<AssistantMessage content="Thinking..." isLoading />,
);
expect(queryByTestId("copilot-markdown")).toBeTruthy();
expect(queryByLabelText("Typing indicator")).toBeTruthy();
});
it("does not render markdown when content is empty", () => {
const { queryByTestId } = render(<AssistantMessage content="" />);
// Empty content = falsy = CopilotMarkdown should not render
expect(queryByTestId("copilot-markdown")).toBeNull();
});
it("does not render typing indicator when isLoading is false", () => {
const { queryByLabelText } = render(
<AssistantMessage content="Hello" isLoading={false} />,
);
expect(queryByLabelText("Typing indicator")).toBeNull();
});
it("accepts style override", () => {
const { container } = render(
<AssistantMessage content="styled" style={{ marginTop: 20 }} />,
);
expect(container).toBeTruthy();
});
});
describe("UserMessage edge cases", () => {
it("renders empty content without crashing", () => {
const { container } = render(<UserMessage content="" />);
expect(container).toBeTruthy();
});
it("renders long content without crashing", () => {
const longText = "A".repeat(5000);
const { container } = render(<UserMessage content={longText} />);
expect(container.textContent).toContain("A".repeat(100));
});
it("accepts style override", () => {
const { container } = render(
<UserMessage content="styled" style={{ marginBottom: 10 }} />,
);
expect(container).toBeTruthy();
});
});
describe("TypingIndicator edge cases", () => {
it("accepts style override", () => {
const { getByLabelText } = render(
<TypingIndicator style={{ paddingVertical: 10 }} />,
);
expect(getByLabelText("Typing indicator")).toBeTruthy();
});
it("has correct accessibility attributes", () => {
const { getByLabelText } = render(<TypingIndicator />);
const indicator = getByLabelText("Typing indicator");
expect(indicator).toBeTruthy();
expect(indicator.getAttribute("role")).toBe("text");
});
});
@@ -0,0 +1,189 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
// ─── Mock react-native ───────────────────────────────────────────────────────
// jsdom doesn't have react-native, so we provide lightweight stand-ins.
vi.mock("react-native", () => {
const React = require("react");
const View = React.forwardRef(
(
{
children,
style,
testID,
accessibilityLabel,
accessibilityRole,
...rest
}: any,
ref: any,
) =>
React.createElement(
"div",
{
ref,
style,
"data-testid": testID,
"aria-label": accessibilityLabel,
role: accessibilityRole,
...rest,
},
children,
),
);
View.displayName = "View";
const Text = React.forwardRef(({ children, style, ...rest }: any, ref: any) =>
React.createElement("span", { ref, style, ...rest }, children),
);
Text.displayName = "Text";
const AnimatedValue = class {
_value: number;
constructor(value: number) {
this._value = value;
}
interpolate({ outputRange }: any) {
return outputRange[0];
}
};
const AnimatedView = React.forwardRef(
({ children, style, ...rest }: any, ref: any) =>
React.createElement("div", { ref, style, ...rest }, children),
);
AnimatedView.displayName = "Animated.View";
const timing = (_value: any, _config: any) => ({
start: vi.fn(),
stop: vi.fn(),
});
const sequence = (animations: any[]) => ({
start: vi.fn(),
stop: vi.fn(),
});
const loop = (animation: any) => ({
start: vi.fn(),
stop: vi.fn(),
});
const parallel = (animations: any[]) => ({
start: vi.fn(),
stop: vi.fn(),
});
const delay = (_ms: number) => ({
start: vi.fn(),
stop: vi.fn(),
});
const Animated = {
Value: AnimatedValue,
View: AnimatedView,
timing,
sequence,
loop,
parallel,
delay,
};
const StyleSheet = {
create: (styles: any) => styles,
flatten: (style: any) =>
Array.isArray(style) ? Object.assign({}, ...style) : style || {},
};
return {
View,
Text,
Animated,
StyleSheet,
};
});
// ─── Mock the Markdown component (B1 owns it, may not exist yet) ─────────────
vi.mock("../../Markdown", () => ({
CopilotMarkdown: ({ content }: { content: string }) => {
const React = require("react");
return React.createElement(
"div",
{ "data-testid": "copilot-markdown" },
content,
);
},
}));
// ─── Imports under test (after mocks) ────────────────────────────────────────
import { AssistantMessage } from "../AssistantMessage";
import { UserMessage } from "../UserMessage";
import { TypingIndicator } from "../TypingIndicator";
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("AssistantMessage", () => {
it("renders content via CopilotMarkdown", () => {
const { getByTestId } = render(
<AssistantMessage content="Hello from the assistant" />,
);
const markdown = getByTestId("copilot-markdown");
expect(markdown).toBeTruthy();
expect(markdown.textContent).toBe("Hello from the assistant");
});
it("shows TypingIndicator when isLoading is true", () => {
const { getByLabelText, queryByTestId } = render(
<AssistantMessage content="" isLoading />,
);
// Typing indicator should be present
expect(getByLabelText("Typing indicator")).toBeTruthy();
// Markdown should NOT render during loading
expect(queryByTestId("copilot-markdown")).toBeNull();
});
it("displays a timestamp when provided", () => {
const date = new Date(2025, 0, 15, 14, 30); // Jan 15 2025, 2:30 PM
const { container } = render(
<AssistantMessage content="Hi" timestamp={date} />,
);
expect(container.textContent).toContain("2:30 PM");
});
it("does not display timestamp when not provided", () => {
const { container } = render(<AssistantMessage content="No timestamp" />);
// Should only contain the message text (via markdown mock)
expect(container.textContent).toBe("No timestamp");
});
});
describe("UserMessage", () => {
it("renders plain text content", () => {
const { container } = render(<UserMessage content="Hello from the user" />);
expect(container.textContent).toContain("Hello from the user");
});
it("displays a timestamp when provided", () => {
const date = new Date(2025, 5, 20, 9, 5); // Jun 20 2025, 9:05 AM
const { container } = render(
<UserMessage content="Morning" timestamp={date} />,
);
expect(container.textContent).toContain("9:05 AM");
});
it("does not display timestamp when not provided", () => {
const { container } = render(<UserMessage content="Just text" />);
expect(container.textContent).toBe("Just text");
});
});
describe("TypingIndicator", () => {
it("renders three animated dots", () => {
const { getByLabelText } = render(<TypingIndicator />);
const indicator = getByLabelText("Typing indicator");
expect(indicator).toBeTruthy();
// Three Animated.View dots inside the container
expect(indicator.children.length).toBe(3);
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { formatTimestamp } from "../utils";
describe("formatTimestamp", () => {
it("formats AM times correctly", () => {
const date = new Date(2025, 0, 15, 9, 5); // 9:05 AM
expect(formatTimestamp(date)).toBe("9:05 AM");
});
it("formats PM times correctly", () => {
const date = new Date(2025, 0, 15, 14, 30); // 2:30 PM
expect(formatTimestamp(date)).toBe("2:30 PM");
});
it("formats 12:00 PM (noon) correctly", () => {
const date = new Date(2025, 0, 15, 12, 0);
expect(formatTimestamp(date)).toBe("12:00 PM");
});
it("formats 12:00 AM (midnight) correctly", () => {
const date = new Date(2025, 0, 15, 0, 0);
expect(formatTimestamp(date)).toBe("12:00 AM");
});
it("pads single-digit minutes with leading zero", () => {
const date = new Date(2025, 0, 15, 8, 3);
expect(formatTimestamp(date)).toBe("8:03 AM");
});
it("formats 11:59 PM correctly", () => {
const date = new Date(2025, 0, 15, 23, 59);
expect(formatTimestamp(date)).toBe("11:59 PM");
});
it("formats 1:00 AM correctly", () => {
const date = new Date(2025, 0, 15, 1, 0);
expect(formatTimestamp(date)).toBe("1:00 AM");
});
});
@@ -0,0 +1,8 @@
export { AssistantMessage } from "./AssistantMessage";
export type { AssistantMessageProps } from "./AssistantMessage";
export { UserMessage } from "./UserMessage";
export type { UserMessageProps } from "./UserMessage";
export { TypingIndicator } from "./TypingIndicator";
export type { TypingIndicatorProps } from "./TypingIndicator";
@@ -0,0 +1,11 @@
/**
* Format a Date as a 12-hour time string (e.g. "2:05 PM").
*/
export function formatTimestamp(date: Date): string {
const hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
const displayMinutes = minutes.toString().padStart(2, "0");
return `${displayHours}:${displayMinutes} ${ampm}`;
}
@@ -0,0 +1,150 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
/**
* Props passed to a render tool function.
*/
export interface RenderToolProps<T = Record<string, unknown>> {
args: T;
status: "executing" | "complete";
result?: string;
}
/**
* A render function that returns a React Native element for a tool call.
* Returns `ReactElement | null` (not ReactNode) because React Native's
* FlatList cannot render strings or portals.
*/
export type RenderToolFunction<T = Record<string, unknown>> = (
props: RenderToolProps<T>,
) => React.ReactElement | null;
/**
* The registry maps tool names to their render functions.
*/
export type RenderToolRegistry = Map<string, RenderToolFunction>;
/**
* Internal store that notifies subscribers when the registry changes.
* This avoids unnecessary re-renders of the entire tree when a single
* tool's render function is registered or removed.
*/
interface RegistryStore {
registry: RenderToolRegistry;
version: number;
listeners: Set<() => void>;
}
function createRegistryStore(): RegistryStore {
return {
registry: new Map(),
version: 0,
listeners: new Set(),
};
}
function emitChange(store: RegistryStore) {
store.version++;
// Create a new Map so useSyncExternalStore detects the reference change
store.registry = new Map(store.registry);
for (const listener of store.listeners) {
listener();
}
}
interface RenderToolContextValue {
store: RegistryStore;
register: (name: string, render: RenderToolFunction) => () => void;
}
const RenderToolCtx = createContext<RenderToolContextValue | null>(null);
/**
* Provider that maintains the render tool registry.
* Should be nested inside CopilotKitProvider.
*/
export function RenderToolProvider({
children,
}: {
children: React.ReactNode;
}) {
const storeRef = useRef<RegistryStore>(createRegistryStore());
const register = useCallback(
(name: string, render: RenderToolFunction): (() => void) => {
const store = storeRef.current;
store.registry.set(name, render);
emitChange(store);
// Return unregister function
return () => {
// Only delete if the current render function is the one we registered
if (store.registry.get(name) === render) {
store.registry.delete(name);
emitChange(store);
}
};
},
[],
);
const value = useMemo<RenderToolContextValue>(
() => ({ store: storeRef.current, register }),
[register],
);
return (
<RenderToolCtx.Provider value={value}>{children}</RenderToolCtx.Provider>
);
}
/**
* Returns the current render tool registry (a Map of tool name to render function).
* Re-renders when the registry changes.
*
* @throws if called outside of RenderToolProvider
*/
export function useRenderToolRegistry(): RenderToolRegistry {
const ctx = useContext(RenderToolCtx);
if (!ctx) {
throw new Error(
"useRenderToolRegistry must be used within a RenderToolProvider",
);
}
const { store } = ctx;
// Subscribe to registry changes via useSyncExternalStore for tear-safe reads
return useSyncExternalStore(
(onStoreChange) => {
store.listeners.add(onStoreChange);
return () => {
store.listeners.delete(onStoreChange);
};
},
() => store.registry,
() => store.registry,
);
}
/**
* Internal hook used by useRenderTool to register a render function.
* Not exported from the package — consumers use useRenderTool instead.
*
* @throws if called outside of RenderToolProvider
*/
export function useRenderToolContext() {
const ctx = useContext(RenderToolCtx);
if (!ctx) {
throw new Error(
"useRenderTool must be used within a RenderToolProvider (inside CopilotKitProvider)",
);
}
return ctx;
}
@@ -0,0 +1,264 @@
import React from "react";
import { render, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
RenderToolProvider,
useRenderToolRegistry,
useRenderToolContext,
} from "../RenderToolContext";
describe("RenderToolContext", () => {
describe("RenderToolProvider", () => {
it("renders children", () => {
const { getByText } = render(
<RenderToolProvider>
<span>child content</span>
</RenderToolProvider>,
);
expect(getByText("child content")).toBeTruthy();
});
it("provides an initially empty registry", () => {
let registry: Map<string, any> | null = null;
function Reader() {
registry = useRenderToolRegistry();
return null;
}
render(
<RenderToolProvider>
<Reader />
</RenderToolProvider>,
);
expect(registry).not.toBeNull();
expect(registry!.size).toBe(0);
});
});
describe("register and unregister", () => {
it("register adds a render function to the registry", () => {
let registry: Map<string, any> | null = null;
let registerFn: ((name: string, render: any) => () => void) | null = null;
function Writer() {
const ctx = useRenderToolContext();
registerFn = ctx.register;
return null;
}
function Reader() {
registry = useRenderToolRegistry();
return null;
}
render(
<RenderToolProvider>
<Writer />
<Reader />
</RenderToolProvider>,
);
const renderFn = () => React.createElement("div");
act(() => {
registerFn!("test-tool", renderFn);
});
expect(registry!.has("test-tool")).toBe(true);
});
it("unregister removes the render function", () => {
let registry: Map<string, any> | null = null;
let registerFn: ((name: string, render: any) => () => void) | null = null;
let unregister: (() => void) | null = null;
function Writer() {
const ctx = useRenderToolContext();
registerFn = ctx.register;
return null;
}
function Reader() {
registry = useRenderToolRegistry();
return null;
}
render(
<RenderToolProvider>
<Writer />
<Reader />
</RenderToolProvider>,
);
const renderFn = () => React.createElement("div");
act(() => {
unregister = registerFn!("removal-tool", renderFn);
});
expect(registry!.has("removal-tool")).toBe(true);
act(() => {
unregister!();
});
expect(registry!.has("removal-tool")).toBe(false);
});
it("unregister is safe to call if tool was already replaced", () => {
let registerFn: ((name: string, render: any) => () => void) | null = null;
let registry: Map<string, any> | null = null;
function Writer() {
const ctx = useRenderToolContext();
registerFn = ctx.register;
return null;
}
function Reader() {
registry = useRenderToolRegistry();
return null;
}
render(
<RenderToolProvider>
<Writer />
<Reader />
</RenderToolProvider>,
);
const render1 = () => React.createElement("div", null, "v1");
const render2 = () => React.createElement("div", null, "v2");
let unregister1: () => void;
act(() => {
unregister1 = registerFn!("shared-tool", render1);
});
// Re-register with a different render function (simulates a new component mounting)
act(() => {
registerFn!("shared-tool", render2);
});
// Calling unregister1 should NOT remove the tool because the current
// render function is render2, not render1
act(() => {
unregister1!();
});
expect(registry!.has("shared-tool")).toBe(true);
});
});
describe("useSyncExternalStore integration", () => {
it("returns updated registry after registration", () => {
let registry: Map<string, any> | null = null;
let registerFn: ((name: string, render: any) => () => void) | null = null;
function Writer() {
const ctx = useRenderToolContext();
registerFn = ctx.register;
return null;
}
function Reader() {
registry = useRenderToolRegistry();
return null;
}
render(
<RenderToolProvider>
<Writer />
<Reader />
</RenderToolProvider>,
);
const sizeBefore = registry!.size;
act(() => {
registerFn!("tool-a", () => React.createElement("div"));
});
// After registration, the registry should contain the new tool
expect(registry!.has("tool-a")).toBe(true);
expect(registry!.size).toBe(sizeBefore + 1);
});
it("notifies multiple subscribers when registry changes", () => {
let registry1: Map<string, any> | null = null;
let registry2: Map<string, any> | null = null;
let registerFn: ((name: string, render: any) => () => void) | null = null;
function Writer() {
const ctx = useRenderToolContext();
registerFn = ctx.register;
return null;
}
function Reader1() {
registry1 = useRenderToolRegistry();
return null;
}
function Reader2() {
registry2 = useRenderToolRegistry();
return null;
}
render(
<RenderToolProvider>
<Writer />
<Reader1 />
<Reader2 />
</RenderToolProvider>,
);
act(() => {
registerFn!("multi-sub-tool", () => React.createElement("div"));
});
// Both readers should see the same registry
expect(registry1!.has("multi-sub-tool")).toBe(true);
expect(registry2!.has("multi-sub-tool")).toBe(true);
// They should be the exact same reference
expect(registry1).toBe(registry2);
});
});
describe("error handling", () => {
it("useRenderToolContext throws outside provider", () => {
function TestComponent() {
useRenderToolContext();
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow("useRenderTool must be used within a RenderToolProvider");
spy.mockRestore();
});
it("useRenderToolRegistry throws outside provider", () => {
function TestComponent() {
useRenderToolRegistry();
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow(
"useRenderToolRegistry must be used within a RenderToolProvider",
);
spy.mockRestore();
});
});
});
@@ -0,0 +1,334 @@
import React from "react";
import { render } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ─── Mocks ────────────────────────────────────────────────────────────────────
const hoisted = vi.hoisted(() => {
const _React = require("react");
return {
RealContext: _React.createContext(null),
mockAddTool: vi.fn(),
mockRemoveTool: vi.fn(),
mockGetTool: vi.fn(() => undefined),
mockAddHookRenderToolCall: vi.fn(),
mockSubscribe: vi.fn(() => ({ unsubscribe: vi.fn() })),
};
});
vi.mock("@copilotkit/react-core/v2/headless", () => {
return {
useFrontendTool: vi.fn((_tool: any, _deps?: any) => {
// Require context — mirrors real behavior
const ctx = require("react").useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
// Simulate addTool call
hoisted.mockAddTool(_tool);
}),
CopilotKitCoreReact: function CopilotKitCoreReact() {},
CopilotChatConfigurationProvider: ({ children }: any) => children,
useCopilotChatConfiguration: () => null,
CopilotChatDefaultLabels: {},
};
});
vi.mock("@copilotkit/react-core/v2/context", () => {
const _React = require("react");
return {
CopilotKitContext: hoisted.RealContext,
LicenseContext: _React.createContext({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
useCopilotKit: () => {
const ctx = _React.useContext(hoisted.RealContext);
if (!ctx) {
throw new Error("useCopilotKit must be used within CopilotKitProvider");
}
return ctx;
},
useLicenseContext: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
};
});
vi.mock("@copilotkit/shared", () => ({
createLicenseContextValue: () => ({
status: null,
license: null,
checkFeature: () => true,
getLimit: () => null,
}),
}));
// Import after mocks
import { useRenderTool } from "../useRenderTool";
import {
RenderToolProvider,
useRenderToolRegistry,
} from "../RenderToolContext";
// Minimal wrapper that provides both CopilotKit context and RenderToolProvider
function TestProviders({ children }: { children: React.ReactNode }) {
const mockCtx = {
copilotkit: {
addTool: hoisted.mockAddTool,
removeTool: hoisted.mockRemoveTool,
getTool: hoisted.mockGetTool,
addHookRenderToolCall: hoisted.mockAddHookRenderToolCall,
subscribe: hoisted.mockSubscribe,
},
executingToolCallIds: new Set<string>(),
};
return (
<hoisted.RealContext.Provider value={mockCtx as any}>
<RenderToolProvider>{children}</RenderToolProvider>
</hoisted.RealContext.Provider>
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("useRenderTool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers a tool via useFrontendTool", () => {
const mockSchema = { "~standard": { vendor: "test", version: 1 } };
function TestComponent() {
useRenderTool({
name: "test-render-tool",
description: "A tool with render",
parameters: mockSchema as any,
render: ({ args, status }) =>
React.createElement("View", null, `${status}`),
handler: async () => "done",
});
return null;
}
render(
<TestProviders>
<TestComponent />
</TestProviders>,
);
// useFrontendTool should have been called with the tool config
expect(hoisted.mockAddTool).toHaveBeenCalledWith(
expect.objectContaining({
name: "test-render-tool",
description: "A tool with render",
}),
);
});
it("stores render function in the registry", () => {
let registry: Map<string, any> | null = null;
const mockSchema = { "~standard": { vendor: "test", version: 1 } };
const renderFn = ({ args, status }: any) =>
React.createElement("View", null, `${status}`);
function ToolRegistrar() {
useRenderTool({
name: "weather-tool",
description: "Show weather",
parameters: mockSchema as any,
render: renderFn,
});
return null;
}
function RegistryReader() {
registry = useRenderToolRegistry();
return null;
}
render(
<TestProviders>
<ToolRegistrar />
<RegistryReader />
</TestProviders>,
);
expect(registry).not.toBeNull();
expect(registry!.has("weather-tool")).toBe(true);
// The stored function is a stable wrapper, not the original
expect(typeof registry!.get("weather-tool")).toBe("function");
});
it("render function in registry produces expected output", () => {
let registry: Map<string, any> | null = null;
const mockSchema = { "~standard": { vendor: "test", version: 1 } };
function ToolRegistrar() {
useRenderTool({
name: "greeting-tool",
description: "Greet someone",
parameters: mockSchema as any,
render: ({ args, status }) =>
React.createElement("Text", null, `Hello ${status}`),
});
return null;
}
function RegistryReader() {
registry = useRenderToolRegistry();
return null;
}
render(
<TestProviders>
<ToolRegistrar />
<RegistryReader />
</TestProviders>,
);
const renderFn = registry!.get("greeting-tool");
const element = renderFn({ args: {}, status: "executing" });
expect(element).not.toBeNull();
expect(element.type).toBe("Text");
expect(element.props.children).toBe("Hello executing");
});
it("throws when useRenderTool is called outside RenderToolProvider", () => {
const mockSchema = { "~standard": { vendor: "test", version: 1 } };
function TestComponent() {
useRenderTool({
name: "orphan-tool",
description: "No provider",
parameters: mockSchema as any,
render: () => React.createElement("View"),
});
return null;
}
// Need CopilotKit context but no RenderToolProvider
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(
<hoisted.RealContext.Provider
value={{ copilotkit: {}, executingToolCallIds: new Set() } as any}
>
<TestComponent />
</hoisted.RealContext.Provider>,
);
}).toThrow("useRenderTool must be used within a RenderToolProvider");
spy.mockRestore();
});
it("throws when useRenderToolRegistry is called outside RenderToolProvider", () => {
function TestComponent() {
useRenderToolRegistry();
return null;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow(
"useRenderToolRegistry must be used within a RenderToolProvider",
);
spy.mockRestore();
});
it("unregisters the render function on unmount", () => {
let registry: Map<string, any> | null = null;
const mockSchema = { "~standard": { vendor: "test", version: 1 } };
function ToolRegistrar() {
useRenderTool({
name: "ephemeral-tool",
description: "Will unmount",
parameters: mockSchema as any,
render: () => React.createElement("View"),
});
return null;
}
function RegistryReader() {
registry = useRenderToolRegistry();
return null;
}
const { rerender } = render(
<TestProviders>
<ToolRegistrar />
<RegistryReader />
</TestProviders>,
);
expect(registry!.has("ephemeral-tool")).toBe(true);
// Re-render without the ToolRegistrar
rerender(
<TestProviders>
<RegistryReader />
</TestProviders>,
);
expect(registry!.has("ephemeral-tool")).toBe(false);
});
it("supports multiple tools registered simultaneously", () => {
let registry: Map<string, any> | null = null;
const mockSchema = { "~standard": { vendor: "test", version: 1 } };
function ToolA() {
useRenderTool({
name: "tool-a",
description: "Tool A",
parameters: mockSchema as any,
render: () => React.createElement("View"),
});
return null;
}
function ToolB() {
useRenderTool({
name: "tool-b",
description: "Tool B",
parameters: mockSchema as any,
render: () => React.createElement("View"),
});
return null;
}
function RegistryReader() {
registry = useRenderToolRegistry();
return null;
}
render(
<TestProviders>
<ToolA />
<ToolB />
<RegistryReader />
</TestProviders>,
);
expect(registry!.has("tool-a")).toBe(true);
expect(registry!.has("tool-b")).toBe(true);
expect(registry!.size).toBe(2);
});
});
+9
View File
@@ -0,0 +1,9 @@
export { useRenderTool } from "./useRenderTool";
export type { UseRenderToolOptions } from "./useRenderTool";
export { RenderToolProvider, useRenderToolRegistry } from "./RenderToolContext";
export type {
RenderToolProps,
RenderToolFunction,
RenderToolRegistry,
} from "./RenderToolContext";
@@ -0,0 +1,274 @@
// packages/react-native/src/hooks/use-attachments.ts
import { useCallback, useRef, useState } from "react";
import {
randomUUID,
getModalityFromMimeType,
formatFileSize,
} from "@copilotkit/shared";
import type {
Attachment,
AttachmentUploadResult,
AttachmentUploadErrorReason,
} from "@copilotkit/shared";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Platform-neutral file descriptor for React Native.
* Replaces the web `File` object in all attachment APIs.
*/
export interface NativeFileInput {
/** Local file URI (e.g. `file:///path/to/file`). */
uri: string;
/** Filename with extension. */
name: string;
/** File size in bytes. */
size: number;
/** MIME type (e.g. `"image/jpeg"`). */
mimeType: string;
}
/**
* React Native variant of AttachmentsConfig.
* Identical to the shared AttachmentsConfig except `onUpload` receives
* a `NativeFileInput` instead of a web `File`.
*/
export interface NativeAttachmentsConfig {
/** Enable file attachments in the chat input. */
enabled: boolean;
/** MIME type filter for the file picker, default all files. */
accept?: string;
/** Maximum file size in bytes, default 20MB (20 * 1024 * 1024). */
maxSize?: number;
/** Custom upload handler. Receives the native file descriptor. */
onUpload?: (
file: NativeFileInput,
) => AttachmentUploadResult | Promise<AttachmentUploadResult>;
/** Called when an attachment fails validation or upload. */
onUploadFailed?: (error: {
reason: AttachmentUploadErrorReason;
file: NativeFileInput;
message: string;
}) => void;
}
export interface UseNativeAttachmentsProps {
config?: NativeAttachmentsConfig;
}
export interface UseNativeAttachmentsReturn {
/** Currently selected attachments (uploading + ready). */
attachments: Attachment[];
/** Whether attachments are enabled. */
enabled: boolean;
/** Open the native document picker. */
openPicker: () => Promise<void>;
/** Process an array of NativeFileInput objects (validate, read, add to state). */
processFiles: (files: NativeFileInput[]) => Promise<void>;
/** Remove an attachment by ID. */
removeAttachment: (id: string) => void;
/** Consume ready attachments and clear the queue. Returns the consumed attachments. */
consumeAttachments: () => Attachment[];
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_MAX_SIZE = 20 * 1024 * 1024; // 20 MB
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* React Native hook that manages file attachment state -- picking, uploading,
* and lifecycle. All returned callbacks are referentially stable via useCallback.
*
* This is the RN counterpart of the web `useAttachments` hook from
* `@copilotkit/react-core`. It replaces web APIs (FileReader, DragEvent,
* HTMLInputElement) with Expo modules (expo-document-picker, expo-file-system).
*/
export function useAttachments({
config,
}: UseNativeAttachmentsProps): UseNativeAttachmentsReturn {
const enabled = config?.enabled ?? false;
const [attachments, setAttachments] = useState<Attachment[]>([]);
// Refs for stable callbacks to read latest values
const configRef = useRef(config);
configRef.current = config;
const attachmentsRef = useRef<Attachment[]>([]);
attachmentsRef.current = attachments;
/**
* Simple MIME accept filter for NativeFileInput.
* Handles wildcards like "image/*" and exact types like "application/pdf".
* Comma-separated lists are supported.
*/
const matchesAccept = useCallback(
(file: NativeFileInput, accept: string): boolean => {
if (!accept || accept === "*/*") return true;
const filters = accept.split(",").map((f) => f.trim());
return filters.some((filter) => {
if (filter.startsWith(".")) {
return file.name.toLowerCase().endsWith(filter.toLowerCase());
}
if (filter.endsWith("/*")) {
const prefix = filter.slice(0, -2);
return file.mimeType.startsWith(prefix + "/");
}
return file.mimeType === filter;
});
},
[],
);
const processFiles = useCallback(
async (files: NativeFileInput[]) => {
const cfg = configRef.current;
const accept = cfg?.accept ?? "*/*";
const maxSize = cfg?.maxSize ?? DEFAULT_MAX_SIZE;
// Filter by accept type
const rejectedFiles = files.filter((f) => !matchesAccept(f, accept));
for (const file of rejectedFiles) {
cfg?.onUploadFailed?.({
reason: "invalid-type",
file,
message: `File "${file.name}" is not accepted. Supported types: ${accept}`,
});
}
const validFiles = files.filter((f) => matchesAccept(f, accept));
for (const file of validFiles) {
// Size check
if (file.size > maxSize) {
cfg?.onUploadFailed?.({
reason: "file-too-large",
file,
message: `File "${file.name}" exceeds the maximum size of ${formatFileSize(maxSize)}`,
});
continue;
}
const modality = getModalityFromMimeType(file.mimeType);
const placeholderId = randomUUID();
const placeholder: Attachment = {
id: placeholderId,
type: modality,
source: { type: "data", value: "", mimeType: file.mimeType },
filename: file.name,
size: file.size,
status: "uploading",
};
setAttachments((prev) => [...prev, placeholder]);
try {
let source: Attachment["source"];
let uploadMetadata: Record<string, unknown> | undefined;
if (cfg?.onUpload) {
const { metadata: meta, ...uploadSource } =
await cfg.onUpload(file);
source = uploadSource;
uploadMetadata = meta;
} else {
// Default: read file as base64 via expo-file-system
const base64 = await FileSystem.readAsStringAsync(file.uri, {
encoding: FileSystem.EncodingType.Base64,
});
source = { type: "data", value: base64, mimeType: file.mimeType };
}
setAttachments((prev) =>
prev.map((att) =>
att.id === placeholderId
? {
...att,
source,
status: "ready" as const,
metadata: uploadMetadata,
}
: att,
),
);
} catch (error) {
// Remove placeholder on failure
setAttachments((prev) =>
prev.filter((att) => att.id !== placeholderId),
);
console.error(`[CopilotKit] Failed to upload "${file.name}":`, error);
cfg?.onUploadFailed?.({
reason: "upload-failed",
file,
message:
error instanceof Error
? error.message
: `Failed to upload "${file.name}"`,
});
}
}
},
[matchesAccept],
);
const openPicker = useCallback(async () => {
const cfg = configRef.current;
const accept = cfg?.accept ?? "*/*";
// Convert accept string to array for DocumentPicker
const typeArray = accept
.split(",")
.map((t) => t.trim())
.filter(Boolean);
try {
const result = await DocumentPicker.getDocumentAsync({
type: typeArray.length > 0 ? typeArray : ["*/*"],
copyToCacheDirectory: true,
multiple: true,
});
if (result.canceled || !result.assets?.length) return;
const nativeFiles: NativeFileInput[] = result.assets.map((asset) => ({
uri: asset.uri,
name: asset.name ?? "unknown",
size: asset.size ?? 0,
mimeType: asset.mimeType ?? "application/octet-stream",
}));
await processFiles(nativeFiles);
} catch (error) {
console.error("[CopilotKit] Document picker error:", error);
}
}, [processFiles]);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const consumeAttachments = useCallback(() => {
const ready = attachmentsRef.current.filter((a) => a.status === "ready");
if (ready.length === 0) return ready;
setAttachments((prev) => prev.filter((a) => a.status !== "ready"));
return ready;
}, []);
return {
attachments,
enabled,
openPicker,
processFiles,
removeAttachment,
consumeAttachments,
};
}
@@ -0,0 +1,96 @@
import { useEffect, useRef } from "react";
import { useFrontendTool } from "@copilotkit/react-core/v2/headless";
import type { StandardSchemaV1 } from "@copilotkit/shared";
import type { RenderToolFunction } from "./RenderToolContext";
import { useRenderToolContext } from "./RenderToolContext";
/**
* Options for the useRenderTool hook.
*/
export interface UseRenderToolOptions<T extends Record<string, unknown>> {
/** Unique name for the tool. Must match what the agent calls. */
name: string;
/** Human-readable description shown to the agent. */
description: string;
/**
* Schema describing the tool's parameters.
* Accepts any StandardSchemaV1-compatible schema (Zod, Valibot, ArkType, etc.).
*/
parameters: StandardSchemaV1<unknown, T>;
/**
* Render function that returns a React Native element for the tool call.
* Called by CopilotChat when it encounters a tool call message for this tool.
*
* Returns ReactElement | null (not ReactNode) because React Native's
* FlatList cannot render strings or portals.
*/
render: RenderToolFunction<T>;
/**
* Optional handler executed when the tool is called.
* If omitted, the tool is render-only (the render function shows UI
* but the tool returns no result to the agent).
*/
handler?: (args: T) => Promise<unknown>;
/**
* Optional agent ID to scope this tool to a specific agent.
*/
agentId?: string;
}
/**
* Hook that registers a frontend tool AND a render function for it.
*
* This bridges `useFrontendTool` (which handles tool registration and
* handler execution) with a render registry so that CopilotChat can
* render React Native elements inline when it encounters tool call messages.
*
* @example
* ```tsx
* useRenderTool({
* name: "showWeather",
* description: "Display weather information",
* parameters: z.object({ city: z.string(), temp: z.number() }),
* render: ({ args, status }) => (
* <View>
* <Text>{args.city}: {args.temp}</Text>
* {status === "executing" && <ActivityIndicator />}
* </View>
* ),
* handler: async ({ city }) => {
* return { forecast: "sunny" };
* },
* });
* ```
*/
export function useRenderTool<
T extends Record<string, unknown> = Record<string, unknown>,
>(options: UseRenderToolOptions<T>, deps?: ReadonlyArray<unknown>): void {
const { name, description, parameters, render, handler, agentId } = options;
const { register } = useRenderToolContext();
// Register the tool with the core system via useFrontendTool
useFrontendTool<T>(
{
name,
description,
parameters,
handler,
agentId,
},
deps,
);
// Use a ref so the effect cleanup always has the latest render function
const renderRef = useRef(render);
renderRef.current = render;
// Register the render function in the RenderToolContext
useEffect(() => {
// Wrap in a stable function that delegates to the ref
const stableRender: RenderToolFunction<T> = (props) =>
renderRef.current(props);
const unregister = register(name, stableRender as RenderToolFunction);
return unregister;
}, [name, register]);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* @copilotkit/react-native
*
* React Native bindings for CopilotKit. Provides a lightweight provider
* and re-exports platform-agnostic hooks from @copilotkit/react-core.
*
* Polyfills (DOMException, ReadableStream, TextEncoder, etc.) are
* auto-imported when this module loads -- no manual
* `import "@copilotkit/react-native/polyfills"` needed.
*
* Quick start:
* ```tsx
* import { CopilotKitProvider, useAgent, useCopilotKit } from "@copilotkit/react-native";
* ```
*/
// Auto-install polyfills so consumers don't need a manual import.
// Must run before any CopilotKit code that relies on ReadableStream / fetch streaming.
import "./polyfills";
// React Native provider (no web dependencies)
export { CopilotKitProvider } from "./CopilotKitProvider";
export type { CopilotKitNativeProviderProps } from "./CopilotKitProvider";
// Provider props alias (mirrors web's CopilotKitProviderProps)
export type { CopilotKitNativeProviderProps as CopilotKitProviderProps } from "./CopilotKitProvider";
// Headless chat components (no DOM, consumer provides UI)
export { CopilotChat, useCopilotChatContext } from "./CopilotChat";
export type { CopilotChatProps, CopilotChatContextValue } from "./CopilotChat";
export { CopilotModal } from "./CopilotModal";
export type { CopilotModalProps } from "./CopilotModal";
// Native attachments hook and types
export { useAttachments } from "./hooks/use-attachments";
export type {
NativeAttachmentsConfig,
NativeFileInput,
UseNativeAttachmentsProps,
UseNativeAttachmentsReturn,
} from "./hooks/use-attachments";
// Pre-built UI components
export { CopilotSidebar } from "./CopilotSidebar";
export type {
CopilotSidebarProps,
CopilotSidebarHandle,
} from "./CopilotSidebar";
export { CopilotPopup } from "./CopilotPopup";
export type { CopilotPopupProps, CopilotPopupHandle } from "./CopilotPopup";
// Re-export context and hooks from react-core (platform-agnostic)
export {
useCopilotKit,
useLicenseContext,
CopilotKitContext,
type CopilotKitContextValue,
} from "@copilotkit/react-core/v2/context";
// Re-export hooks that work without web deps
// These consume the CopilotKitContext which our provider sets
export {
useAgent,
useFrontendTool,
useComponent,
useHumanInTheLoop,
useInterrupt,
useSuggestions,
useConfigureSuggestions,
useAgentContext,
useThreads,
useCapabilities,
defineToolCallRenderer,
CopilotChatDefaultLabels,
type UseAgentUpdate,
type UseInterruptConfig,
type AgentContextInput,
type JsonSerializable,
type Thread,
type UseThreadsInput,
type UseThreadsResult,
type CopilotChatLabels,
type CopilotChatConfigurationValue,
type InterruptEvent,
type InterruptHandlerProps,
type InterruptRenderProps,
type Interrupt,
type ResumeEntry,
type ResumeStatus,
type ReactFrontendTool,
type ReactHumanInTheLoop,
type RenderToolInProgressProps,
type RenderToolExecutingProps,
type RenderToolCompleteProps,
} from "@copilotkit/react-core/v2/headless";
// useRenderToolCall — web-specific (depends on DOM elements via DefaultToolCallRenderer)
// useRenderCustomMessages — web-specific (tightly coupled to web chat UI rendering pipeline)
// useRenderActivityMessage — web-specific (tightly coupled to web chat UI rendering pipeline)
// useDefaultRenderTool — web-specific (DefaultToolCallRenderer uses <div>, <svg>, etc.)
// Re-export core types commonly needed
export type {
CopilotKitCoreRuntimeConnectionStatus,
CopilotKitCoreErrorCode,
Suggestion,
FrontendTool,
ToolCallStatus,
} from "@copilotkit/core";
// Re-export AG-UI types for consumer convenience (matches web SDK surface)
export type {
Message,
AssistantMessage as AssistantMessageType,
ToolCall,
ToolMessage,
AbstractAgent,
AgentCapabilities,
} from "@ag-ui/client";
// Render tool hook (React Native version with render registry integration)
export { useRenderTool } from "./hooks/useRenderTool";
export type { UseRenderToolOptions } from "./hooks/useRenderTool";
export {
RenderToolProvider,
useRenderToolRegistry,
} from "./hooks/RenderToolContext";
export type { RenderToolProps } from "./hooks/RenderToolContext";
+23
View File
@@ -0,0 +1,23 @@
/**
* All polyfills required for CopilotKit to work in React Native.
*
* These are auto-imported when `@copilotkit/react-native` is loaded.
* A manual `import "@copilotkit/react-native/polyfills"` is no longer
* required but still works for advanced / selective bootstrap scenarios.
*
* For granular control, import individual polyfills instead:
* import "@copilotkit/react-native/polyfills/streams";
* import "@copilotkit/react-native/polyfills/encoding";
* import "@copilotkit/react-native/polyfills/crypto";
* import "@copilotkit/react-native/polyfills/dom";
* import "@copilotkit/react-native/polyfills/location";
*/
import "./polyfills/streams";
import "./polyfills/encoding";
import "./polyfills/crypto";
import "./polyfills/dom";
import "./polyfills/location";
import { installStreamingFetch } from "./streaming-fetch";
installStreamingFetch();
@@ -0,0 +1,34 @@
/**
* Polyfill: crypto.getRandomValues
*
* Required by uuid generation in CopilotKit. Uses Math.random — NOT
* cryptographically secure. For secure randomness, install
* react-native-get-random-values before this polyfill.
*
* Skipped if crypto.getRandomValues is already defined.
*
* Usage:
* import "@copilotkit/react-native/polyfills/crypto";
*/
export {};
const g = globalThis as Record<string, unknown>;
if (typeof g.crypto === "undefined") {
g.crypto = {};
}
const cryptoObj = g.crypto as Record<string, unknown>;
if (!cryptoObj.getRandomValues) {
console.warn(
"[CopilotKit] Installing non-cryptographic crypto.getRandomValues polyfill (Math.random). " +
"This is NOT secure for cryptographic operations. Install 'react-native-get-random-values' " +
"for a secure implementation.",
);
cryptoObj.getRandomValues = function (array: Uint8Array): Uint8Array {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
};
}
@@ -0,0 +1,85 @@
/**
* Polyfill: DOMException, Headers
*
* DOMException is used by CopilotKit's isAbortError check.
* Headers is used by CopilotKit's request construction.
* Skipped if the globals are already defined.
*
* Usage:
* import "@copilotkit/react-native/polyfills/dom";
*/
export {};
const g = globalThis as Record<string, unknown>;
// DOMException
if (typeof g.DOMException === "undefined") {
class DOMExceptionPolyfill extends Error {
code: number;
constructor(message?: string, name?: string) {
super(message);
this.name = name || "DOMException";
this.code = 0;
}
}
g.DOMException = DOMExceptionPolyfill;
}
// Headers
if (typeof g.Headers === "undefined") {
class HeadersPolyfill {
private _map: Record<string, string> = {};
constructor(init?: Record<string, string> | HeadersPolyfill) {
if (init) {
if (init instanceof HeadersPolyfill) {
this._map = { ...init._map };
} else {
for (const [key, value] of Object.entries(init)) {
this._map[key.toLowerCase()] = value;
}
}
}
}
get(name: string): string | null {
return this._map[name.toLowerCase()] ?? null;
}
set(name: string, value: string): void {
this._map[name.toLowerCase()] = value;
}
has(name: string): boolean {
return name.toLowerCase() in this._map;
}
delete(name: string): void {
delete this._map[name.toLowerCase()];
}
append(name: string, value: string): void {
const key = name.toLowerCase();
if (key in this._map) {
this._map[key] += ", " + value;
} else {
this._map[key] = value;
}
}
entries(): IterableIterator<[string, string]> {
return Object.entries(this._map)[Symbol.iterator]();
}
keys(): IterableIterator<string> {
return Object.keys(this._map)[Symbol.iterator]();
}
values(): IterableIterator<string> {
return Object.values(this._map)[Symbol.iterator]();
}
forEach(
callback: (value: string, key: string, parent: HeadersPolyfill) => void,
): void {
for (const [key, value] of Object.entries(this._map)) {
callback(value, key, this);
}
}
[Symbol.iterator](): IterableIterator<[string, string]> {
return this.entries();
}
}
g.Headers = HeadersPolyfill;
}
@@ -0,0 +1,20 @@
/**
* Polyfill: TextEncoder, TextDecoder
*
* Required for stream chunk processing in CopilotKit.
* Skipped if the globals are already defined.
*
* Usage:
* import "@copilotkit/react-native/polyfills/encoding";
*/
import { TextEncoder, TextDecoder } from "text-encoding";
const g = globalThis as Record<string, unknown>;
if (typeof g.TextEncoder === "undefined") {
g.TextEncoder = TextEncoder;
}
if (typeof g.TextDecoder === "undefined") {
g.TextDecoder = TextDecoder;
}
@@ -0,0 +1,30 @@
/**
* Polyfill: window.location
*
* Required by shouldShowDevConsole (hostname check) and agent runtime
* URL resolution (window.location.origin). Uses an obviously invalid
* hostname to avoid tricking localhost-detection code.
*
* Skipped if window.location is already defined.
*
* Usage:
* import "@copilotkit/react-native/polyfills/location";
*/
export {};
if (typeof window !== "undefined") {
const w = window as unknown as Record<string, unknown>;
if (!w.location) {
w.location = {
hostname: "react-native.invalid",
href: "http://react-native.invalid",
origin: "http://react-native.invalid",
protocol: "http:",
host: "react-native.invalid",
pathname: "/",
search: "",
hash: "",
};
}
}
@@ -0,0 +1,27 @@
/**
* Polyfill: ReadableStream, WritableStream, TransformStream
*
* Required for SSE streaming in CopilotKit.
* Skipped individually if the global already has each class defined.
*
* Usage:
* import "@copilotkit/react-native/polyfills/streams";
*/
import {
ReadableStream,
WritableStream,
TransformStream,
} from "web-streams-polyfill";
const g = globalThis as Record<string, unknown>;
if (typeof g.ReadableStream === "undefined") {
g.ReadableStream = ReadableStream;
}
if (typeof g.WritableStream === "undefined") {
g.WritableStream = WritableStream;
}
if (typeof g.TransformStream === "undefined") {
g.TransformStream = TransformStream;
}
@@ -0,0 +1,368 @@
/**
* Streaming fetch implementation for React Native.
*
* React Native's built-in fetch doesn't support response.body.getReader()
* (ReadableStream). This replaces global.fetch with an XHR-based
* implementation that streams chunks via ReadableStream, enabling
* CopilotKit's SSE-based agent communication.
*
* If native fetch already supports ReadableStream bodies (newer RN / Hermes),
* the replacement is skipped entirely.
*
* THREADING NOTE: In React Native, XHR callbacks (onprogress, onload, etc.)
* may fire on a native networking thread. Pushing data into the ReadableStream
* from that thread can trigger downstream React setState calls on the wrong
* thread, causing iOS to kill the process with "deleted thread with uncommitted
* CATransaction". All stream-mutating operations are therefore deferred via
* setTimeout(fn, 0) to bounce back to the JS thread (main thread in Hermes).
*
* Call `installStreamingFetch()` once at app startup after polyfills.
*/
declare const global: typeof globalThis;
/** Subset of the Response interface implemented by the streaming fetch polyfill. */
interface StreamingFetchResponse {
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly url: string;
readonly type: string;
readonly redirected: boolean;
readonly bodyUsed: boolean;
readonly headers: Headers;
readonly body: ReadableStream<Uint8Array>;
json(): Promise<unknown>;
text(): Promise<string>;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;
clone(): never;
formData(): Promise<never>;
}
function createAbortError(): DOMException {
return new (global as any).DOMException(
"The operation was aborted.",
"AbortError",
);
}
export function installStreamingFetch(): void {
// Skip if native fetch already supports ReadableStream body.
// Newer React Native versions (Hermes) may support this natively.
try {
const testResponse = new Response("");
if (
testResponse.body != null &&
typeof testResponse.body.getReader === "function"
) {
return;
}
} catch (e) {
// Response constructor unavailable — expected in older RN environments.
if (
__DEV__ &&
e instanceof Error &&
!(e instanceof ReferenceError) &&
!(e instanceof TypeError)
) {
console.warn(
"[CopilotKit] Unexpected error during streaming fetch feature detection, " +
"installing XHR-based polyfill:",
e,
);
}
}
const originalFetch = global.fetch;
const TextEncoder = global.TextEncoder;
const streamingFetch = function streamingFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
// Extract defaults from Request object when input is a Request
const request =
typeof input !== "string" && !(input instanceof URL) ? input : null;
let url: string;
if (typeof input === "string") {
url = input;
} else if (input instanceof URL) {
url = input.href;
} else {
url = (input as Request).url;
}
const method = init?.method || request?.method || "GET";
const headers = init?.headers || (request ? request.headers : {});
const body = (init?.body ?? request?.body) as string | null | undefined;
const signal = init?.signal || request?.signal;
return new Promise((resolve, reject) => {
// Reject immediately if signal is already aborted (per fetch spec)
if (signal?.aborted) {
reject(createAbortError());
return;
}
const xhr = new XMLHttpRequest();
xhr.open(method, url);
// Default 60s timeout to prevent hanging on stalled mobile connections
// (WiFi→cellular transitions, tunnels, serverless cold starts).
// Callers can still use AbortSignal.timeout() for finer control.
xhr.timeout = 60_000;
let headerEntries: [string, string][];
if (headers instanceof Headers) {
headerEntries = Array.from(headers.entries());
} else if (Array.isArray(headers)) {
headerEntries = headers as [string, string][];
} else {
headerEntries = Object.entries(headers as Record<string, string>);
}
for (const [key, value] of headerEntries) {
xhr.setRequestHeader(key, value as string);
}
xhr.responseType = "text";
let streamController: ReadableStreamDefaultController<Uint8Array> | null =
null;
let lastIndex = 0;
let streamClosed = false;
let settled = false;
const encoder = new TextEncoder();
// Promise that resolves/rejects when XHR completes or fails
let resolveFullText: (text: string) => void;
let rejectFullText: (error: Error) => void;
const fullTextPromise = new Promise<string>((res, rej) => {
resolveFullText = res;
rejectFullText = rej;
});
// Prevent unhandled rejection when error occurs but .text()/.json() is never called
fullTextPromise.catch(() => {});
function closeStream() {
if (streamController && !streamClosed) {
streamClosed = true;
streamController.close();
}
}
function errorStream(err: Error) {
if (streamController && !streamClosed) {
streamClosed = true;
streamController.error(err);
}
}
function flushChunks() {
if (
streamController &&
!streamClosed &&
xhr.responseText.length > lastIndex
) {
const newData = xhr.responseText.slice(lastIndex);
lastIndex = xhr.responseText.length;
streamController.enqueue(encoder.encode(newData));
}
}
/** Centralized error handler — errors the stream, rejects fullTextPromise,
* and rejects the outer fetch promise if not yet settled. */
function fail(err: Error) {
cleanupAbortListener();
errorStream(err);
rejectFullText(err);
if (!settled) {
settled = true;
reject(err);
}
}
const onAbort = () => {
fail(createAbortError());
xhr.abort();
};
if (signal) {
signal.addEventListener("abort", onAbort);
}
function cleanupAbortListener() {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
cancel() {
xhr.abort();
rejectFullText(createAbortError());
},
});
// All XHR callbacks are wrapped with setTimeout(fn, 0) to ensure they
// run on the JS thread. In React Native, XHR callbacks may fire on a
// native networking thread; calling streamController.enqueue() there
// triggers downstream React setState on the wrong thread, which causes
// iOS to kill the process ("deleted thread with uncommitted CATransaction").
// setTimeout(fn, 0) defers execution to the JS event loop (main thread
// in Hermes) with negligible latency — streaming still feels real-time.
xhr.onprogress = function () {
setTimeout(() => {
try {
flushChunks();
} catch (err) {
fail(err instanceof Error ? err : new Error(String(err)));
xhr.abort();
}
}, 0);
};
xhr.onload = function () {
setTimeout(() => {
cleanupAbortListener();
try {
flushChunks();
} catch (err) {
fail(err instanceof Error ? err : new Error(String(err)));
return;
}
closeStream();
resolveFullText(xhr.responseText);
}, 0);
};
xhr.onerror = function () {
setTimeout(() => {
fail(new TypeError("Network request failed"));
}, 0);
};
xhr.ontimeout = function () {
setTimeout(() => {
fail(new TypeError("Network request timed out"));
}, 0);
};
// Resolve with Response once headers arrive.
// Guard against status === 0 which XHR produces for CORS failures,
// DNS errors, and mixed-content blocks — let onerror handle those.
let resp: StreamingFetchResponse | null = null;
xhr.onreadystatechange = function () {
// Capture XHR state synchronously before deferring — XHR properties
// may change between now and when setTimeout fires.
const readyState = xhr.readyState;
const xhrStatus = xhr.status;
const xhrStatusText = xhr.statusText;
const rawHeaders = xhr.getAllResponseHeaders() || "";
setTimeout(() => {
// Safety net: if XHR completed but we never resolved/rejected, fail explicitly.
// This can happen when status === 0 and onerror doesn't fire (some RN networking impls).
if (readyState === 4 && !settled && !resp) {
fail(
new TypeError(
`Network request to ${url} completed with status ${xhrStatus} but no response was produced. ` +
`This may indicate a CORS failure, DNS error, or React Native networking issue.`,
),
);
return;
}
if (readyState >= 2 && !resp && xhrStatus !== 0) {
const respHeaders: Record<string, string> = {};
for (const line of rawHeaders.trim().split("\r\n")) {
const idx = line.indexOf(": ");
if (idx > 0) {
respHeaders[line.slice(0, idx).toLowerCase()] = line.slice(
idx + 2,
);
}
}
const responseHeaders = new Headers(respHeaders);
let bodyUsed = false;
resp = {
// Duck-typed Response object (not a native Response instance)
ok: xhrStatus >= 200 && xhrStatus < 300,
status: xhrStatus,
statusText: xhrStatusText,
url: url,
type: "basic",
redirected: false,
get bodyUsed() {
return bodyUsed;
},
headers: responseHeaders,
body: stream,
json: async () => {
bodyUsed = true;
const text = await fullTextPromise;
try {
return JSON.parse(text);
} catch (e) {
throw new TypeError(
`Failed to parse JSON from ${method} ${url} (status ${xhrStatus}): ${
text.length > 200 ? text.slice(0, 200) + "..." : text
}`,
{ cause: e },
);
}
},
text: async () => {
bodyUsed = true;
return fullTextPromise;
},
arrayBuffer: async () => {
bodyUsed = true;
return encoder.encode(await fullTextPromise).buffer;
},
blob: async () => {
bodyUsed = true;
const buf = encoder.encode(await fullTextPromise);
if (typeof Blob !== "undefined") {
return new Blob([buf], {
type: responseHeaders.get("content-type") || "",
});
}
throw new Error(
"Blob is not available in this React Native environment.",
);
},
clone: () => {
throw new Error(
"Response.clone() is not supported by the React Native streaming fetch polyfill.",
);
},
formData: async () => {
throw new Error(
"Response.formData() is not supported by the React Native streaming fetch polyfill.",
);
},
};
settled = true;
// NOTE: abort listener is NOT removed here — the signal must remain
// wired to xhr.abort() for mid-stream cancellation. Cleanup happens
// in terminal handlers (onload, onerror, ontimeout) or onAbort itself.
resolve(resp as unknown as Response);
}
}, 0);
};
xhr.send(body ?? null);
});
};
// Expose original fetch for opt-out (e.g., third-party libs that need native behavior)
(streamingFetch as any).__originalFetch = originalFetch;
global.fetch = streamingFetch as typeof fetch;
}
+23
View File
@@ -0,0 +1,23 @@
// React Native global provided by Metro bundler
declare const __DEV__: boolean;
// Polyfill files use `const g = globalThis as Record<string, unknown>` to assign
// web APIs (ReadableStream, TextEncoder, Headers, etc.) that may not exist in the
// React Native runtime. TypeScript's `globalThis` type doesn't include optional
// web APIs, so there's no way to assign them without a cast. We use a local alias
// (`g`) instead of repeating `(globalThis as any)` on every line — same pattern as
// packages/angular/src/test-setup.ts. Files without imports add `export {}` so
// TypeScript treats them as modules with isolated scope.
declare module "text-encoding" {
export class TextEncoder {
encode(input?: string): Uint8Array;
}
export class TextDecoder {
constructor(label?: string, options?: { fatal?: boolean });
decode(
input?: ArrayBufferView | ArrayBuffer,
options?: { stream?: boolean },
): string;
}
}