chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+18
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user