diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 369f7026..ea358958 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -40,6 +40,10 @@ "group": "Get Started", "pages": ["introduction"] }, + { + "group": "Guides", + "pages": ["guides/resource-scoping", "guides/thread-management"] + }, { "group": "API Reference", "pages": [ @@ -56,12 +60,13 @@ }, { "group": "Components", - "pages": ["reference/copilot-chat"] + "pages": ["reference/copilot-chat", "reference/copilot-thread-list"] }, { "group": "Hooks", "pages": [ "reference/use-copilotkit", + "reference/use-threads", "reference/use-agent", "reference/use-agent-context", "reference/use-frontend-tool", diff --git a/apps/docs/guides/resource-scoping.mdx b/apps/docs/guides/resource-scoping.mdx new file mode 100644 index 00000000..6b42d171 --- /dev/null +++ b/apps/docs/guides/resource-scoping.mdx @@ -0,0 +1,417 @@ +--- +title: "Resource Scoping & Thread Security" +description: "Learn how to secure threads with resource-based access control" +--- + +# Resource Scoping & Thread Security + +Resource scoping allows you to control which users have access to which threads. By associating threads with resource IDs (like user IDs or workspace IDs), you ensure that users can only access threads they're authorized to see. + +## Quick Start + +### 1. Client-Side: Declare Resource ID + +Set the `resourceId` prop on `CopilotKitProvider`: + +```tsx +import { CopilotKitProvider } from "@copilotkitnext/react"; + +function App() { + const { userId } = useAuth(); + + return ( + + + + ); +} +``` + +### 2. Server-Side: Validate and Enforce + +Configure the `` in your runtime: + +```typescript +import { CopilotRuntime } from "@copilotkitnext/runtime"; + +const runtime = new CopilotRuntime({ + agents: { myAgent }, + resolveThreadsScope: async ({ request, clientDeclared }) => { + // Authenticate the user + const user = await authenticate(request); + + // Validate client-declared resourceId matches + if (clientDeclared && clientDeclared !== user.id) { + throw new Error("Unauthorized"); + } + + // Return the validated resourceId + return { resourceId: user.id }; + }, +}); +``` + +## How It Works + +### Client → Server Flow + +1. **Client declares intent**: `` +2. **Header transport**: Client sends `X-CopilotKit-Resource-ID: userId` header +3. **Server validates**: `resolveThreadsScope` receives `{ request, clientDeclared: userId }` +4. **Server enforces**: Only threads with matching `resourceId` are accessible + + + Resource scoping flow diagram + + + + **Client-hint architecture**: The client declares its intended scope, but the server always has final authority. This is similar to CORS or OAuth scopes. + + +## Common Patterns + +### Pattern 1: Single User Per Thread + +The simplest pattern - one user, one thread: + +```tsx +// Client + +``` + +```typescript +// Server +resolveThreadsScope: async ({ request, clientDeclared }) => { + const user = await authenticate(request); + if (clientDeclared && clientDeclared !== user.id) { + throw new Error("Unauthorized"); + } + return { resourceId: user.id }; +} +``` + +### Pattern 2: Multi-Resource Threads + +Threads accessible by multiple resources (e.g., shared workspaces): + +```tsx +// Client - Request threads from specific workspace + +``` + +```typescript +// Server - Filter to authorized workspaces only +import { filterAuthorizedResourceIds } from "@copilotkitnext/runtime"; + +resolveThreadsScope: async ({ request, clientDeclared }) => { + const user = await authenticate(request); + const userWorkspaces = await getUserWorkspaces(user); + + // Filter client-declared IDs to only those user has access to + const resourceId = filterAuthorizedResourceIds( + clientDeclared, + userWorkspaces.map(w => w.id) + ); + + return { resourceId }; +} +``` + +### Pattern 3: Workspace Switcher + +Let users dynamically change scope: + +```tsx +function WorkspaceSwitcher() { + const { setResourceId } = useCopilotKit(); + const [currentWorkspace, setCurrentWorkspace] = useState("ws-1"); + + const switchWorkspace = (workspaceId: string) => { + setCurrentWorkspace(workspaceId); + setResourceId(workspaceId); // 👈 Update resource scope + }; + + return ( + + ); +} +``` + +### Pattern 4: Shared Threads (Multi-User) + +Threads accessible by multiple users: + +```tsx +// Client - Thread accessible by user AND team + +``` + +```typescript +// Server - Thread is accessible if ANY resourceId matches +resolveThreadsScope: async ({ request }) => { + const user = await authenticate(request); + const team = await getUserTeam(user); + + // Thread will be accessible by both user ID and team ID + return { resourceId: [user.id, team.id] }; +} +``` + +## Validation Helpers + +CopilotKit provides helper functions for common validation patterns: + +### `validateResourceIdMatch` + +Strict validation - throws if client-declared doesn't match server-authorized: + +```typescript +import { validateResourceIdMatch } from "@copilotkitnext/runtime"; + +resolveThreadsScope: async ({ request, clientDeclared }) => { + const user = await authenticate(request); + validateResourceIdMatch(clientDeclared, user.id); // Throws if mismatch + return { resourceId: user.id }; +} +``` + +### `filterAuthorizedResourceIds` + +Filtering validation - returns only authorized IDs: + +```typescript +import { filterAuthorizedResourceIds } from "@copilotkitnext/runtime"; + +resolveThreadsScope: async ({ request, clientDeclared }) => { + const user = await authenticate(request); + const userWorkspaces = await getUserWorkspaces(user); + + const resourceId = filterAuthorizedResourceIds( + clientDeclared, + userWorkspaces.map(w => w.id) + ); + + return { resourceId }; +} +``` + +### `createStrictThreadScopeResolver` + +Factory for strict validation: + +```typescript +import { createStrictThreadScopeResolver } from "@copilotkitnext/runtime"; + +const runtime = new CopilotRuntime({ + agents: { myAgent }, + resolveThreadsScope: createStrictThreadScopeResolver(async (request) => { + const user = await authenticate(request); + return user.id; + }), +}); +``` + +### `createFilteringThreadScopeResolver` + +Factory for filtering validation: + +```typescript +import { createFilteringThreadScopeResolver } from "@copilotkitnext/runtime"; + +const runtime = new CopilotRuntime({ + agents: { myAgent }, + resolveThreadsScope: createFilteringThreadScopeResolver(async (request) => { + const user = await authenticate(request); + return await getUserAccessibleWorkspaces(user); + }), +}); +``` + +## Security Best Practices + +### 1. Always Validate on the Server + + + **Never trust client input**. The client declares which resource it wants to access, but you must validate this against your authentication system. The security boundary is in your server-side validation logic - not in what the client sends. + + +```typescript +// ❌ BAD - Trusts client without validation +resolveThreadsScope: async ({ clientDeclared }) => { + return { resourceId: clientDeclared }; // Dangerous! +} + +// ✅ GOOD - Server validates and enforces +resolveThreadsScope: async ({ request, clientDeclared }) => { + const user = await authenticate(request); + if (clientDeclared && clientDeclared !== user.id) { + throw new Error("Unauthorized"); + } + return { resourceId: user.id }; +} +``` + +### 2. Use Environment-Specific Warnings + +CopilotKit warns you when resourceId is missing: + +```tsx +// Development - Warning in console + + {/* Console: "No resourceId set. All threads are globally accessible." */} + + +// Production - Error in console + + {/* Console: "Security Warning: No resourceId set in production!" */} + +``` + +### 3. Handle Admin Bypass Carefully + +Use `null` scope for admin access, but validate the user is actually an admin: + +```typescript +resolveThreadsScope: async ({ request }) => { + const user = await authenticate(request); + + if (user.isAdmin) { + return null; // Admin bypass - sees all threads + } + + return { resourceId: user.id }; +} +``` + +### 4. Prevent Resource Enumeration + +Always return 404 (not 403) for unauthorized threads: + +```typescript +// ✅ GOOD - Prevents resource enumeration +const thread = await runtime.runner.getThreadMetadata(threadId, scope); +if (!thread) { + return new Response("Thread not found", { status: 404 }); +} +``` + +## Advanced: Custom Request Handlers + +If you're building custom request handlers outside the standard CopilotKit flow, you may need to manually parse the `X-CopilotKit-Resource-ID` header. + +### `parseClientDeclaredResourceId` + +This static helper method extracts and parses the resource ID(s) from the request header: + +```typescript +import { CopilotRuntime } from "@copilotkitnext/runtime"; + +// In your custom handler +export async function customHandler(request: Request) { + // Parse the client-declared resource ID + const clientDeclared = CopilotRuntime.parseClientDeclaredResourceId(request); + + // Validate and resolve scope + const scope = await runtime.resolveThreadsScope({ request, clientDeclared }); + + // Use scope for your custom operation + // ... +} +``` + +**When to use this:** +- Building custom API endpoints that need resource scoping +- Extending the runtime with additional handlers +- Debugging scope resolution issues + +**You don't need this for:** +- Standard agent runs, connections, or thread operations (handled automatically) +- Implementing `resolveThreadsScope` (it receives `clientDeclared` as a parameter) + +### Return Value + +The method returns: +- `string` - Single resource ID (e.g., `"user-123"`) +- `string[]` - Multiple comma-separated IDs (e.g., `["workspace-1", "workspace-2"]`) +- `undefined` - Header not present + +### Example: Custom Thread Export + +```typescript +import { CopilotRuntime } from "@copilotkitnext/runtime"; + +export async function handleExportThreads(runtime: CopilotRuntime, request: Request) { + // Parse client-declared resource ID + const clientDeclared = CopilotRuntime.parseClientDeclaredResourceId(request); + + // Validate via resolveThreadsScope + const scope = await runtime.resolveThreadsScope({ request, clientDeclared }); + if (!scope) { + return new Response("Unauthorized", { status: 401 }); + } + + // List threads with proper scoping + const { threads } = await runtime.runner.listThreads({ scope, limit: 100, offset: 0 }); + + // Export as CSV or other format + const csv = threadsToCSV(threads); + return new Response(csv, { + headers: { "Content-Type": "text/csv" } + }); +} +``` + +## Troubleshooting + +### "Unauthorized: Cannot access thread owned by different resource" + +The client is trying to access a thread that belongs to a different resourceId. + +**Fix**: Ensure the client's resourceId matches the thread's resourceId. + +### "No threads returned even though threads exist" + +The scope filtering is too restrictive. + +**Debug**: +```typescript +resolveThreadsScope: async ({ request, clientDeclared }) => { + const user = await authenticate(request); + console.log("User ID:", user.id); + console.log("Client declared:", clientDeclared); + + return { resourceId: user.id }; +} +``` + +### "Empty array causes SQL error" + +If you're passing an empty array as resourceId, it will return no threads (as expected). + +```typescript +// Returns no threads (empty array = no access) +return { resourceId: [] }; +``` + +## Next Steps + + + + Learn how to list, create, and delete threads + + + Full API reference for CopilotRuntime + + + Full API reference for CopilotKitProvider + + + Complete security best practices + + diff --git a/apps/docs/guides/thread-management.mdx b/apps/docs/guides/thread-management.mdx new file mode 100644 index 00000000..beee4fe0 --- /dev/null +++ b/apps/docs/guides/thread-management.mdx @@ -0,0 +1,782 @@ +--- +title: "Thread Management" +description: "Learn how to retrieve, list, switch, and delete conversation threads in CopilotKit" +--- + +# Thread Management + +CopilotKit provides comprehensive support for managing multiple conversation threads, allowing you to build chat applications with conversation history similar to ChatGPT. + +## Overview + +Threads are automatically created and stored when you use CopilotChat. Each thread maintains: +- Complete conversation history +- Message metadata +- Running state +- Creation and last activity timestamps + +**New Features:** +- ✅ Simple thread deletion with automatic rollback +- ✅ Configurable refresh intervals +- ✅ Support for external state management (React Query, SWR, etc.) + +## Quick Start + +The easiest way to add thread management is using the `CopilotThreadList` component: + +```tsx +import { + CopilotKitProvider, + CopilotThreadList, + CopilotChat, + CopilotChatConfigurationProvider, +} from "@copilotkitnext/react"; + +export function ChatApp() { + return ( + + +
+ +
+ +
+
+
+
+ ); +} +``` + +## Using the Thread List Component + +### Basic Usage + +`CopilotThreadList` provides a complete thread management UI out of the box: + +```tsx +import { CopilotThreadList } from "@copilotkitnext/react"; + + console.log("Selected:", threadId)} +/> +``` + +### Refresh Configuration + +Control how threads are refreshed automatically: + +#### Custom Refresh Interval + +```tsx +// Refresh every 5 seconds (default is 2 seconds) + + +// Fast refresh for real-time updates + + +// Slow refresh to reduce server load + +``` + +#### Disable Auto-Refresh + +Disable automatic polling when using external state management: + +```tsx +// No automatic polling + +``` + +**When to disable auto-refresh:** +- Using React Query, SWR, or similar libraries +- Implementing WebSocket-based updates +- Using Server-Sent Events (SSE) +- Custom invalidation logic +- Reducing server load + +### Props Reference + +```typescript +interface CopilotThreadListProps { + // Basic + limit?: number; // Max threads to load (default: 50) + onThreadSelect?: (threadId: string) => void; + className?: string; + + // Refresh Configuration (NEW) + refreshInterval?: number; // Milliseconds (default: 2000) + disableAutoRefresh?: boolean; // Disable polling (default: false) + + // Customization + threadItem?: SlotValue; // Custom thread renderer + newThreadButton?: SlotValue; // Custom new button + container?: SlotValue; // Custom container +} +``` + +## Using the useThreads Hook + +For more control over thread data, use the `useThreads` hook: + +```tsx +import { useThreads } from "@copilotkitnext/react"; + +function MyThreadManager() { + const { + threads, + total, + isLoading, + error, + fetchThreads, + deleteThread, // NEW + refresh, + } = useThreads({ + limit: 20, + autoFetch: true, + }); + + const handleDelete = async (threadId: string) => { + try { + await deleteThread(threadId); + // Success! Thread removed with automatic rollback on error + } catch (error) { + console.error("Failed to delete:", error); + } + }; + + if (isLoading) return
Loading threads...
; + if (error) return
Error: {error.message}
; + + return ( +
+

Your Conversations ({total})

+ {threads.map((thread) => ( +
+

{thread.firstMessage || "New conversation"}

+

{thread.messageCount} messages

+ +
+ ))} +
+ ); +} +``` + +### Deleting Threads + +The `deleteThread` method provides optimistic updates with automatic rollback: + +```tsx +const { deleteThread } = useThreads(); + +// 1. Immediate UI update (thread removed from list) +// 2. API request sent to server +// 3. Success: List refreshed for consistency +// 4. Failure: Thread restored to original position +await deleteThread(threadId); +``` + +**Features:** +- Instant UI feedback +- Automatic rollback on error +- No manual URL construction needed +- Proper error handling + +## Core API + +### deleteThread + +Delete a thread using the core helper method: + +```tsx +import { useCopilotKit } from "@copilotkitnext/react"; + +function CustomThreadManager() { + const { copilotkit } = useCopilotKit(); + + const deleteThread = async (threadId: string) => { + try { + await copilotkit.deleteThread(threadId); + console.log("Thread deleted successfully"); + } catch (error) { + if (error.message.includes("Not Found")) { + console.log("Thread doesn't exist"); + } else if (error.message.includes("Forbidden")) { + console.log("No permission to delete"); + } else { + console.error("Delete failed:", error); + } + } + }; +} +``` + +**Benefits:** +- No manual URL construction +- Automatic header and resource ID inclusion +- Consistent error handling +- Cleaner, more maintainable code + +## External State Management + +### React Query Integration + +```tsx +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { CopilotThreadList, useThreads } from "@copilotkitnext/react"; + +function ThreadListWithReactQuery() { + const queryClient = useQueryClient(); + const { deleteThread } = useThreads(); + + // React Query handles fetching and caching + const { data, refetch } = useQuery({ + queryKey: ['threads'], + queryFn: fetchThreads, + refetchInterval: 3000, // React Query controls refresh + }); + + const deleteMutation = useMutation({ + mutationFn: deleteThread, + onSuccess: () => { + // Invalidate to trigger refetch + queryClient.invalidateQueries(['threads']); + }, + }); + + return ( + + ); +} +``` + +### WebSocket Integration + +```tsx +import { useEffect } from 'react'; +import { CopilotThreadList, useThreads } from "@copilotkitnext/react"; + +function ThreadListWithWebSocket() { + const { refresh } = useThreads(); + + useEffect(() => { + const ws = new WebSocket('wss://your-server.com/threads'); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'thread_created' || + data.type === 'thread_updated' || + data.type === 'thread_deleted') { + refresh(); // Only refresh when notified + } + }; + + return () => ws.close(); + }, [refresh]); + + return ( + + ); +} +``` + +### Server-Sent Events (SSE) + +```tsx +import { useEffect } from 'react'; +import { CopilotThreadList, useThreads } from "@copilotkitnext/react"; + +function ThreadListWithSSE() { + const { refresh } = useThreads(); + + useEffect(() => { + const eventSource = new EventSource('/api/threads/events'); + + eventSource.addEventListener('thread-update', () => { + refresh(); + }); + + return () => eventSource.close(); + }, [refresh]); + + return ; +} +``` + +## Thread Metadata + +Each thread includes the following metadata: + +```typescript +interface ThreadMetadata { + threadId: string; // Unique thread identifier + createdAt: number; // Timestamp (ms) + lastActivityAt: number; // Timestamp (ms) + isRunning: boolean; // Is thread currently active? + messageCount: number; // Total messages in thread + firstMessage?: string; // Preview of first message +} +``` + +## Creating New Threads + +To create a new thread, generate a new UUID and pass it to CopilotChat: + +```tsx +import { CopilotChat } from "@copilotkitnext/react"; +import { randomUUID } from "@copilotkitnext/shared"; +import { useState } from "react"; + +function ChatWithNewThread() { + const [threadId, setThreadId] = useState(randomUUID()); + + const handleNewThread = () => { + setThreadId(randomUUID()); + }; + + return ( +
+ + +
+ ); +} +``` + +## Switching Between Threads + +CopilotChat automatically handles thread switching when the `threadId` prop changes: + +```tsx +function MultiThreadChat() { + const [currentThread, setCurrentThread] = useState("thread-1"); + const { threads } = useThreads(); + + return ( +
+ {/* Thread selector */} + + + {/* Chat will automatically load the selected thread */} + +
+ ); +} +``` + +## Customizing the Thread List + +### Custom Thread Renderer + +```tsx + ( +
+ + +
+ )} +/> +``` + +### Custom Empty State + +```tsx +import { randomUUID } from "@copilotkitnext/shared"; +import { CopilotThreadList, useThreads, useCopilotChatConfiguration } from "@copilotkitnext/react"; + +function ThreadListWithEmptyState() { + const { threads, isLoading } = useThreads(); + const config = useCopilotChatConfiguration(); + + if (isLoading) { + return
Loading threads…
; + } + + if (threads.length === 0) { + return ( +
+

No conversations yet

+ +
+ ); + } + + return ; +} +``` + +### Delete with Confirmation + +```tsx +import { useThreads } from "@copilotkitnext/react"; + +function ThreadManager() { + const { threads, deleteThread } = useThreads(); + + const handleDelete = async (threadId: string, threadName: string) => { + const confirmed = window.confirm( + `Delete "${threadName}"? This action cannot be undone.` + ); + + if (!confirmed) return; + + try { + await deleteThread(threadId); + // Success - optimistic update with rollback on error + } catch (error) { + alert("Failed to delete thread. Please try again."); + } + }; + + return ( +
+ {threads.map(thread => ( +
+ {thread.firstMessage || "Untitled"} + +
+ ))} +
+ ); +} +``` + +### Pagination + +```tsx +function PaginatedThreadList() { + const [offset, setOffset] = useState(0); + const limit = 20; + const { threads, total, fetchThreads } = useThreads({ limit }); + + const handleNextPage = () => { + const newOffset = offset + limit; + setOffset(newOffset); + fetchThreads(newOffset); + }; + + const handlePrevPage = () => { + const newOffset = Math.max(0, offset - limit); + setOffset(newOffset); + fetchThreads(newOffset); + }; + + return ( +
+ +
+ + + Showing {offset + 1}-{Math.min(offset + limit, total)} of {total} + + +
+
+ ); +} +``` + +## Backend Configuration + +Thread storage is handled automatically by the AgentRunner. Choose the appropriate runner for your deployment: + +### In-Memory (Development) + +```typescript +import { CopilotRuntime, InMemoryAgentRunner } from "@copilotkitnext/runtime"; + +const runtime = new CopilotRuntime({ + agents: myAgents, + runner: new InMemoryAgentRunner(), // Default +}); +``` + +### SQLite (Single Server) + +```typescript +import { SqliteAgentRunner } from "@copilotkitnext/runtime"; + +const runtime = new CopilotRuntime({ + agents: myAgents, + runner: new SqliteAgentRunner({ + dbPath: "./copilot.db", // Persistent storage + }), +}); +``` + +### Enterprise (Multi-Server with Redis) + +```typescript +import { EnterpriseAgentRunner } from "@copilotkitnext/runtime"; +import { Kysely } from "kysely"; +import Redis from "ioredis"; + +const runtime = new CopilotRuntime({ + agents: myAgents, + runner: new EnterpriseAgentRunner({ + kysely: new Kysely({ + // Your database config + }), + redis: new Redis({ + // Your Redis config + }), + }), +}); +``` + +## API Reference + +### CopilotThreadList Component + +```typescript +interface CopilotThreadListProps { + limit?: number; // Default: 50 + onThreadSelect?: (threadId: string) => void; + className?: string; + refreshInterval?: number; // Default: 2000 (ms) + disableAutoRefresh?: boolean; // Default: false + threadItem?: SlotValue; + newThreadButton?: SlotValue; + container?: SlotValue; +} +``` + +### useThreads Hook + +```typescript +interface UseThreadsOptions { + limit?: number; // Default: 50 + autoFetch?: boolean; // Default: true +} + +interface UseThreadsResult { + threads: ThreadMetadata[]; + total: number; + isLoading: boolean; + error: Error | null; + fetchThreads: (offset?: number) => Promise; + getThreadMetadata: (threadId: string) => Promise; + refresh: () => Promise; + addOptimisticThread: (threadId: string) => void; + deleteThread: (threadId: string) => Promise; // NEW + currentThreadId?: string; +} +``` + +### Core Methods + +```typescript +// List all threads +core.listThreads(params?: { limit?: number; offset?: number }): Promise<{ + threads: ThreadMetadata[]; + total: number; +}> + +// Get single thread metadata +core.getThreadMetadata(threadId: string): Promise + +// Delete a thread (NEW) +core.deleteThread(threadId: string): Promise +``` + +## Best Practices + +### 1. Use Core Helper Methods + +❌ **Don't** manually construct URLs: +```typescript +// BAD +const url = `${core.runtimeUrl}/threads/${threadId}`; +const response = await fetch(url, { method: "DELETE" }); +``` + +✅ **Do** use the core helper: +```typescript +// GOOD +await core.deleteThread(threadId); +``` + +### 2. Choose the Right Refresh Strategy + +**For Normal Use:** +```tsx +// Use default 2-second interval + +``` + +**For Real-Time Updates:** +```tsx +// Option A: Fast polling (simple but inefficient) + + +// Option B: WebSockets (efficient, recommended) + +// + WebSocket integration +``` + +**For Reduced Server Load:** +```tsx +// Slow refresh or disable + +``` + +### 3. Handle Errors Gracefully + +```typescript +const { deleteThread } = useThreads(); + +const handleDelete = async (threadId: string) => { + if (!window.confirm("Delete this thread?")) return; + + try { + await deleteThread(threadId); + toast.success("Thread deleted"); + } catch (error) { + toast.error("Failed to delete thread"); + // Rollback happens automatically + } +}; +``` + +### 4. Always Use Stable Thread IDs + +Generate thread IDs once and persist them in your app state. + +### 5. Implement Loading States + +Thread fetching is async - always show loading indicators. + +### 6. Provide Visual Feedback + +Show active threads, running status, and delete confirmations. + +## Troubleshooting + +### Threads not showing up + +- Ensure `runtimeUrl` is set in CopilotKitProvider +- Check that threads have been created (send at least one message) +- Verify your backend is running and accessible + +### Thread deletion not working + +- Check runtime URL is set correctly +- Verify authentication headers are included +- Review resource ID permissions +- Check server logs for errors + +### Auto-refresh not working + +- Ensure thread has `isRunning: true` or `firstMessage: undefined` +- Check `disableAutoRefresh` is not set to `true` +- Verify component is mounted + +### Performance issues + +- Increase `refreshInterval` (e.g., 5000ms) +- Use `disableAutoRefresh={true}` with external state management +- Implement WebSocket or SSE for real-time updates +- Consider pagination for apps with many threads + +## Migration Guide + +### From Manual URL Construction + +**Before:** +```typescript +const url = `${core.runtimeUrl}/threads/${threadId}`; +const response = await fetch(url, { + method: "DELETE", + headers: { ...core.headers }, +}); +if (!response.ok) throw new Error("Failed"); +``` + +**After:** +```typescript +await core.deleteThread(threadId); +``` + +### Adding Refresh Control + +**Before:** +```tsx + +// Always refreshes every 2 seconds +``` + +**After:** +```tsx +// Option 1: Custom interval + + +// Option 2: Disable for external control + +``` + +## Examples + +For more examples, check out our Storybook: +```bash +pnpm storybook:react +``` + +Interactive examples include: +- Basic thread list +- Custom refresh intervals +- Disabled auto-refresh +- External state management (React Query) +- WebSocket integration +- Custom thread renderers +- Delete with confirmation diff --git a/apps/docs/reference/copilot-thread-list.mdx b/apps/docs/reference/copilot-thread-list.mdx new file mode 100644 index 00000000..8c61d9d2 --- /dev/null +++ b/apps/docs/reference/copilot-thread-list.mdx @@ -0,0 +1,320 @@ +--- +title: "CopilotThreadList" +description: "Component for displaying and managing conversation threads" +--- + +# CopilotThreadList + +The `CopilotThreadList` component provides a ready-to-use thread management UI with thread selection, deletion, and automatic refresh support. + +## Basic Usage + +```tsx +import { CopilotThreadList, CopilotKitProvider, CopilotChatConfigurationProvider } from "@copilotkitnext/react"; + +function App() { + return ( + + +
+ +
+ +
+
+
+
+ ); +} +``` + +## Props + +### limit + +`number` **(optional, default: 50)** + +Maximum number of threads to load. + +```tsx + +``` + +### onThreadSelect + +`(threadId: string) => void` **(optional)** + +Callback fired when a thread is selected. + +```tsx + { + console.log("Selected:", threadId); + }} +/> +``` + +### className + +`string` **(optional)** + +CSS class name for the container element. + +```tsx + +``` + +### refreshInterval + +`number` **(optional, default: 2000)** + +Interval in milliseconds for auto-refreshing threads when a thread is running or unnamed. + +```tsx + +``` + +The component automatically refreshes when: +- A thread has `isRunning: true` +- A thread has `firstMessage: undefined` + +Stops automatically when all threads are idle and named. + +### disableAutoRefresh + +`boolean` **(optional, default: false)** + +Disable automatic polling. Use when implementing external state management. + +```tsx + +``` + +Use this when: +- Using React Query, SWR, or similar libraries +- Implementing WebSocket-based updates +- Using Server-Sent Events (SSE) +- Implementing custom invalidation logic + +### threadItem + +`SlotValue` **(optional)** + +Custom renderer for thread items. + +```tsx + ( +
+ + +
+ )} +/> +``` + +Props passed to custom renderer: + +```typescript +interface ThreadListItemProps { + thread: ThreadMetadata; + isActive?: boolean; + onClick?: () => void; + onDelete?: () => void; +} +``` + +### newThreadButton + +`SlotValue` **(optional)** + +Custom renderer for the new thread button. + +```tsx + ( + + )} +/> +``` + +### container + +`SlotValue` **(optional)** + +Custom renderer for the container element. + +```tsx + ( +
+ {children} +
+ )} +/> +``` + +## Examples + +### Custom Refresh Intervals + +```tsx +// Fast refresh for real-time updates + + +// Slow refresh to reduce server load + +``` + +### Custom Thread Renderer + +```tsx + ( +
+ + +
+ )} +/> +``` + +### Custom Empty State + +```tsx +import { useThreads, useCopilotChatConfiguration } from "@copilotkitnext/react"; +import { randomUUID } from "@copilotkitnext/shared"; + +function ThreadListWithEmptyState() { + const { threads, isLoading } = useThreads(); + const config = useCopilotChatConfiguration(); + + if (isLoading) return
Loading...
; + + if (threads.length === 0) { + return ( +
+

No conversations yet

+ +
+ ); + } + + return ; +} +``` + +### With React Query + +```tsx +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +function ThreadListWithReactQuery() { + const queryClient = useQueryClient(); + + useQuery({ + queryKey: ['threads'], + queryFn: fetchThreads, + refetchInterval: 3000, + }); + + return ( + + ); +} +``` + +### With WebSocket Updates + +```tsx +import { useEffect } from 'react'; +import { useThreads } from "@copilotkitnext/react"; + +function ThreadListWithWebSocket() { + const { refresh } = useThreads(); + + useEffect(() => { + const ws = new WebSocket('wss://api.example.com/threads'); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'thread_update') { + refresh(); + } + }; + + return () => ws.close(); + }, [refresh]); + + return ; +} +``` + +### With Server-Sent Events + +```tsx +import { useEffect } from 'react'; +import { useThreads } from "@copilotkitnext/react"; + +function ThreadListWithSSE() { + const { refresh } = useThreads(); + + useEffect(() => { + const eventSource = new EventSource('/api/threads/events'); + eventSource.addEventListener('thread-update', () => refresh()); + return () => eventSource.close(); + }, [refresh]); + + return ; +} +``` + +## Refresh Behavior + +The component automatically refreshes under specific conditions: + +- Refreshes every 2 seconds by default (configurable via `refreshInterval`) +- Only when threads have `isRunning: true` or `firstMessage: undefined` +- Stops when all threads are idle and named + +To customize: + +```tsx +// Custom interval + + +// Disable auto-refresh + +``` + +## Related + +- [Thread Management Guide](/guides/thread-management) +- [useThreads Hook](/reference/use-threads) +- [CopilotChatConfigurationProvider](/reference/copilot-chat-configuration-provider) diff --git a/apps/docs/reference/copilotkit-provider.mdx b/apps/docs/reference/copilotkit-provider.mdx index 41da7f0f..06f8286e 100644 --- a/apps/docs/reference/copilotkit-provider.mdx +++ b/apps/docs/reference/copilotkit-provider.mdx @@ -84,6 +84,56 @@ Application-specific data that gets forwarded to agents as additional context. A
``` +### resourceId + +`string | string[]` **(optional, recommended for production)** + +Resource ID(s) for thread access control. This value is sent to the server as a hint for thread scoping. The server's `resolveThreadsScope` validates and enforces access control. + + + **Security**: Setting `resourceId` enables resource-based access control for threads. Users can only access threads that match their resource IDs. Learn more in the [Resource Scoping guide](/guides/resource-scoping). + + +```tsx +// Single resource - user can only access their own threads +{children} + +// Multiple resources - threads accessible by any of these IDs +{children} + +// Dynamic updates - change resource scope when user switches workspaces +function App() { + const [workspaceId, setWorkspaceId] = useState("ws-1"); + + return ( + + + {children} + + ); +} +``` + + + If `resourceId` is not set in production, all threads will be globally accessible. CopilotKit will log a security warning in the browser console. + + +You can also update the resourceId dynamically using the `setResourceId` method from `useCopilotKit`: + +```tsx +import { useCopilotKit } from "@copilotkitnext/react"; + +function WorkspaceSwitcher() { + const { setResourceId } = useCopilotKit(); + + const switchWorkspace = (newWorkspaceId: string) => { + setResourceId(newWorkspaceId); + }; + + return ; +} +``` + ### agents\_\_unsafe_dev_only `Record` **(optional, development only)** @@ -210,9 +260,7 @@ The provider makes a `CopilotKitContextValue` available to child components thro ```typescript interface CopilotKitContextValue { copilotkit: CopilotKitCore; - renderToolCalls: ReactToolCallRenderer[]; - currentRenderToolCalls: ReactToolCallRenderer[]; - setCurrentRenderToolCalls: React.Dispatch[]>>; + setResourceId: (resourceId: string | string[] | undefined) => void; } ``` @@ -222,10 +270,15 @@ Access this context using the `useCopilotKit` hook: import { useCopilotKit } from "@copilotkitnext/react"; function MyComponent() { - const { copilotkit } = useCopilotKit(); + const { copilotkit, setResourceId } = useCopilotKit(); // Access CopilotKitCore instance const agent = copilotkit.getAgent("assistant"); + + // Dynamically update resource ID + const switchWorkspace = (workspaceId: string) => { + setResourceId(workspaceId); + }; } ``` diff --git a/apps/docs/reference/use-copilotkit.mdx b/apps/docs/reference/use-copilotkit.mdx index aabbfb95..6079e8a2 100644 --- a/apps/docs/reference/use-copilotkit.mdx +++ b/apps/docs/reference/use-copilotkit.mdx @@ -45,27 +45,54 @@ The hook returns a `CopilotKitContextValue` object with the following properties The core CopilotKit instance that manages agents, tools, context, and runtime connections. This is the main interface for interacting with CopilotKit programmatically. -### renderToolCalls +### setResourceId -`ReactToolCallRenderer[]` +`(resourceId: string | string[] | undefined) => void` -An array of tool call render configurations defined at the provider level. These are used to render visual feedback when -tools are executed. +A function to dynamically update the resource ID for thread access control. Use this when users switch between contexts (e.g., workspace switcher) to update which threads they can access. -### currentRenderToolCalls - -`ReactToolCallRenderer[]` +```tsx +const { setResourceId } = useCopilotKit(); -The current list of render tool calls, including both static configurations and dynamically registered ones. +// Switch to a different workspace +setResourceId("workspace-2"); -### setCurrentRenderToolCalls +// Switch to multiple resources +setResourceId([userId, newWorkspaceId]); -`React.Dispatch[]>>` +// Clear resource ID (global access - not recommended in production) +setResourceId(undefined); +``` -A setter function to update the current render tool calls. Useful for dynamically adding or removing tool renderers. +See the [Resource Scoping guide](/guides/resource-scoping) for more information on thread security. ## Examples +### Workspace Switcher + +```tsx +import { useCopilotKit } from "@copilotkitnext/react"; +import { useState } from "react"; + +function WorkspaceSwitcher() { + const { setResourceId } = useCopilotKit(); + const [workspaceId, setWorkspaceId] = useState("workspace-1"); + + const handleWorkspaceChange = (newWorkspaceId: string) => { + setWorkspaceId(newWorkspaceId); + setResourceId(newWorkspaceId); // Update thread access scope + }; + + return ( + + ); +} +``` + ### Running an Agent ```tsx diff --git a/apps/docs/reference/use-threads.mdx b/apps/docs/reference/use-threads.mdx new file mode 100644 index 00000000..03e3eda4 --- /dev/null +++ b/apps/docs/reference/use-threads.mdx @@ -0,0 +1,352 @@ +--- +title: "useThreads" +description: "Hook for managing conversation threads" +--- + +# useThreads + +The `useThreads` hook provides access to conversation threads and methods for managing them, including fetching, refreshing, and deleting threads with built-in optimistic updates. + +## Basic Usage + +```tsx +import { useThreads } from "@copilotkitnext/react"; + +function ThreadList() { + const { threads, isLoading, error } = useThreads(); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; + + return ( +
+ {threads.map(thread => ( +
+

{thread.firstMessage || "New conversation"}

+

{thread.messageCount} messages

+
+ ))} +
+ ); +} +``` + +## Options + +### limit + +`number` **(optional, default: 50)** + +Maximum number of threads to fetch per request. + +```tsx +const { threads } = useThreads({ limit: 20 }); +``` + +### autoFetch + +`boolean` **(optional, default: true)** + +Whether to automatically fetch threads when the component mounts. + +```tsx +const { threads, fetchThreads } = useThreads({ autoFetch: false }); +``` + +## Return Value + +### threads + +`ThreadMetadata[]` + +Array of thread metadata objects. + +```typescript +interface ThreadMetadata { + threadId: string; + createdAt: number; + lastActivityAt: number; + isRunning: boolean; + messageCount: number; + firstMessage?: string; +} +``` + +### total + +`number` + +Total number of threads available. + +### isLoading + +`boolean` + +Loading state for fetch operations. + +### error + +`Error | null` + +Error state if fetch operations fail. + +### fetchThreads + +`(offset?: number) => Promise` + +Manually fetch threads with optional pagination offset. + +```tsx +const { fetchThreads } = useThreads(); + +// Fetch first page +await fetchThreads(0); + +// Fetch second page +await fetchThreads(50); +``` + +### getThreadMetadata + +`(threadId: string) => Promise` + +Get metadata for a specific thread. + +```tsx +const { getThreadMetadata } = useThreads(); + +const thread = await getThreadMetadata("thread-123"); +if (thread) { + console.log(`Thread has ${thread.messageCount} messages`); +} +``` + +### refresh + +`() => Promise` + +Refresh the thread list from the beginning (offset 0). + +```tsx +const { refresh } = useThreads(); +await refresh(); +``` + +### addOptimisticThread + +`(threadId: string) => void` + +Add a thread optimistically to the UI before it's created on the server. + +```tsx +const { addOptimisticThread } = useThreads(); + +const newThreadId = crypto.randomUUID(); +addOptimisticThread(newThreadId); +``` + +### deleteThread + +`(threadId: string) => Promise` + +Delete a thread with optimistic updates and automatic rollback on error. + +```tsx +const { deleteThread } = useThreads(); + +try { + await deleteThread("thread-123"); + // Thread removed and list refreshed +} catch (error) { + // Thread restored to original position + console.error("Delete failed:", error); +} +``` + +The delete operation: +- Immediately removes the thread from the UI (optimistic update) +- Sends the delete request to the server +- Refreshes the list on success +- Restores the thread on failure (automatic rollback) + +### currentThreadId + +`string | undefined` + +The currently active thread ID from the chat configuration. + +## Examples + +### Manual Fetching + +```tsx +function ManualThreadFetch() { + const { threads, fetchThreads, isLoading } = useThreads({ + autoFetch: false, + }); + + return ( +
+ + {threads.map(thread => ( +
{thread.firstMessage}
+ ))} +
+ ); +} +``` + +### With Pagination + +```tsx +import { useState } from "react"; + +function PaginatedThreads() { + const [offset, setOffset] = useState(0); + const limit = 20; + const { threads, total, fetchThreads } = useThreads({ limit }); + + const nextPage = () => { + const newOffset = offset + limit; + setOffset(newOffset); + fetchThreads(newOffset); + }; + + const prevPage = () => { + const newOffset = Math.max(0, offset - limit); + setOffset(newOffset); + fetchThreads(newOffset); + }; + + return ( +
+ {threads.map(thread => ( +
{thread.firstMessage}
+ ))} +
+ + + Showing {offset + 1}-{Math.min(offset + limit, total)} of {total} + + +
+
+ ); +} +``` + +### Delete with Confirmation + +```tsx +function ThreadManager() { + const { threads, deleteThread } = useThreads(); + + const handleDelete = async (threadId: string, name: string) => { + if (!window.confirm(`Delete "${name}"?`)) return; + + try { + await deleteThread(threadId); + } catch (error) { + alert("Failed to delete thread"); + } + }; + + return ( +
+ {threads.map(thread => ( +
+ {thread.firstMessage || "Untitled"} + +
+ ))} +
+ ); +} +``` + +### Auto-Switch After Delete + +```tsx +import { useCopilotChatConfiguration } from "@copilotkitnext/react"; + +function ThreadManager() { + const config = useCopilotChatConfiguration(); + const { threads, deleteThread } = useThreads(); + + const handleDelete = async (threadId: string) => { + await deleteThread(threadId); + + // Switch to another thread if deleted the active one + if (threadId === config?.threadId && threads.length > 0) { + config?.setThreadId(threads[0].threadId); + } + }; + + return ( +
+ {threads.map(thread => ( +
+ {thread.firstMessage} + +
+ ))} +
+ ); +} +``` + +### With External State Management + +```tsx +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +function ThreadListWithReactQuery() { + const queryClient = useQueryClient(); + const { threads, deleteThread } = useThreads(); + + const deleteMutation = useMutation({ + mutationFn: deleteThread, + onSuccess: () => { + queryClient.invalidateQueries(['threads']); + queryClient.invalidateQueries(['messages']); + }, + }); + + return ( +
+ {threads.map(thread => ( +
+ {thread.firstMessage} + +
+ ))} +
+ ); +} +``` + +## Related + +- [Thread Management Guide](/guides/thread-management) +- [CopilotThreadList Component](/reference/copilot-thread-list) +- [CopilotChatConfigurationProvider](/reference/copilot-chat-configuration-provider) diff --git a/apps/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts b/apps/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts index f4b3e29c..a5e3e278 100644 --- a/apps/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts +++ b/apps/react/demo/src/app/api/copilotkit/[[...slug]]/route.ts @@ -5,14 +5,14 @@ import { BasicAgent } from "@copilotkitnext/agent"; // Determine which model to use based on available API keys const getModelConfig = () => { if (process.env.OPENAI_API_KEY?.trim()) { - return "openai/gpt-4o"; + return "openai/gpt-4o-mini"; } else if (process.env.ANTHROPIC_API_KEY?.trim()) { return "anthropic/claude-sonnet-4.5"; } else if (process.env.GOOGLE_API_KEY?.trim()) { return "google/gemini-2.5-pro"; } // Default to OpenAI (will fail at runtime if no key is set) - return "openai/gpt-4o"; + return "openai/gpt-4o-mini"; }; const agent = new BasicAgent({ @@ -26,6 +26,27 @@ const runtime = new CopilotRuntime({ default: agent, }, runner: new InMemoryAgentRunner(), + // Resource scoping: Control which threads each user can access + // Security note: In production, authenticate via session cookies, JWTs, etc. + // The client declares which user they are (x-user-id header), but YOU must + // validate this against your auth system. This demo simulates that validation. + resolveThreadsScope: async ({ request }) => { + const userId = request.headers.get("x-user-id"); + const isAdmin = request.headers.get("x-is-admin") === "true"; + + // Admin bypass: sees all threads across all users + if (isAdmin) { + return null; // null = no filtering (admin access) + } + + // Regular user: only sees their own threads + // In production: const userId = await authenticateUser(request); + return { + resourceId: userId || "anonymous", + }; + }, + // Suppress warnings for demo purposes + suppressResourceIdWarning: true, }); const app = createCopilotEndpoint({ @@ -35,3 +56,4 @@ const app = createCopilotEndpoint({ export const GET = handle(app); export const POST = handle(app); +export const DELETE = handle(app); diff --git a/apps/react/demo/src/app/page.tsx b/apps/react/demo/src/app/page.tsx index a6107430..0b62b6f5 100644 --- a/apps/react/demo/src/app/page.tsx +++ b/apps/react/demo/src/app/page.tsx @@ -1,20 +1,29 @@ "use client"; import { - CopilotChat, CopilotKitProvider, useFrontendTool, defineToolCallRenderer, - useConfigureSuggestions, + CopilotSidebar, + CopilotChat, } from "@copilotkitnext/react"; import type { ToolsMenuItem } from "@copilotkitnext/react"; import { z } from "zod"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; // Disable static optimization for this page export const dynamic = "force-dynamic"; +// Simulated users for demo +const DEMO_USERS = [ + { id: "user-alice", name: "Alice 👩", color: "bg-blue-100 border-blue-400" }, + { id: "user-bob", name: "Bob 👨", color: "bg-green-100 border-green-400" }, + { id: "user-charlie", name: "Charlie 🧑", color: "bg-purple-100 border-purple-400" }, + { id: "admin", name: "Admin 👑 (null scope)", color: "bg-red-100 border-red-400" }, +]; + export default function Home() { + const [currentUser, setCurrentUser] = useState(DEMO_USERS[0]); // Define a wildcard renderer for any undefined tools const wildcardRenderer = defineToolCallRenderer({ name: "*", @@ -39,18 +48,56 @@ export default function Home() { }); return ( - +
- + {/* User selector banner */} +
+
+ Demo User: +
+ {DEMO_USERS.map((user) => ( + + ))} +
+
+ Resource ID: {currentUser.id === "admin" ? "null (sees all)" : currentUser.id} +
+
+
+ 💡 Try this: Create threads as Alice, then switch to Bob - you won't see Alice's + threads! Admin can see all threads. This demonstrates resource scoping for multi-tenant apps. +
+
+
+ +
); } function Chat() { - useConfigureSuggestions({ - instructions: "Suggest helpful next actions", - }); + // useConfigureSuggestions({ + // instructions: "Suggest helpful next actions", + // }); // useConfigureSuggestions({ // suggestions: [ @@ -107,5 +154,71 @@ function Chat() { [], ); - return ; + // Demo: Two approaches for thread management + // 1. Simple: Use CopilotThreadList component (recommended) + // 2. Advanced: Use useThreadSwitch hook for custom UI + + // Approach 1: Using CopilotThreadList (easiest) + // Grid layout with dedicated thread list sidebar + return ( +
+ +
+ ); + + // Approach 1b: Using CopilotSidebar with thread list button + // Uncomment below to see the modal sidebar with thread list button: + /* + return ( + + + + ); + */ + + // Approach 2: Using useThreadSwitch hook for custom UI + // Uncomment below to see custom implementation: + /* + const { switchThread, currentThreadId } = useThreadSwitch(); + const { threads } = useThreads({ autoFetch: true }); + + return ( +
+ +
+ + {threads.map(({ firstMessage, threadId }) => ( +
switchThread(threadId)} + className={`p-3 rounded-lg cursor-pointer transition ${ + threadId === currentThreadId + ? "bg-blue-100 border-2 border-blue-500" + : "bg-gray-100 hover:bg-gray-200" + }`} + > + {firstMessage || "New conversation"} +
+ ))} +
+ +
+
+ ); + */ } diff --git a/packages/core/src/__tests__/core-delete-thread.test.ts b/packages/core/src/__tests__/core-delete-thread.test.ts new file mode 100644 index 00000000..3ae2811d --- /dev/null +++ b/packages/core/src/__tests__/core-delete-thread.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { CopilotKitCore } from "../core"; + +describe("CopilotKitCore - deleteThread", () => { + let copilotKitCore: CopilotKitCore; + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("successful deletion", () => { + beforeEach(() => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + }); + + it("should successfully delete a thread", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + }); + + it("should handle runtimeUrl with trailing slash", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api/", + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + }); + + it("should include custom headers", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + }); + }); + }); + + describe("resource ID handling", () => { + it("should include resource ID header when set (single)", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + resourceId: "user-123", + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "X-CopilotKit-Resource-ID": "user-123", + }, + }); + }); + + it("should include resource ID header when set (multiple)", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + resourceId: ["user-123", "org-456"], + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "X-CopilotKit-Resource-ID": "user-123,org-456", + }, + }); + }); + + it("should not include resource ID header when not set", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + }); + }); + + describe("error handling", () => { + it("should throw error when runtimeUrl is not set", async () => { + copilotKitCore = new CopilotKitCore({}); + + await expect(copilotKitCore.deleteThread("thread-123")).rejects.toThrow( + "Runtime URL is required to delete a thread", + ); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should throw error when API returns 404", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + await expect(copilotKitCore.deleteThread("thread-123")).rejects.toThrow( + "Failed to delete thread: Not Found", + ); + }); + + it("should throw error when API returns 500", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(copilotKitCore.deleteThread("thread-123")).rejects.toThrow( + "Failed to delete thread: Internal Server Error", + ); + }); + + it("should throw error when API returns 403", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + }); + + await expect(copilotKitCore.deleteThread("thread-123")).rejects.toThrow( + "Failed to delete thread: Forbidden", + ); + }); + + it("should throw error when network fails", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + mockFetch.mockRejectedValue(new Error("Network error")); + + await expect(copilotKitCore.deleteThread("thread-123")).rejects.toThrow("Network error"); + }); + }); + + describe("thread ID handling", () => { + beforeEach(() => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + }); + + it("should handle UUID thread IDs", async () => { + const threadId = "550e8400-e29b-41d4-a716-446655440000"; + await copilotKitCore.deleteThread(threadId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://example.com/api/threads/${threadId}`, + expect.any(Object), + ); + }); + + it("should handle short thread IDs", async () => { + const threadId = "abc123"; + await copilotKitCore.deleteThread(threadId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://example.com/api/threads/${threadId}`, + expect.any(Object), + ); + }); + + it("should handle thread IDs with special characters", async () => { + const threadId = "thread-123-abc"; + await copilotKitCore.deleteThread(threadId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://example.com/api/threads/${threadId}`, + expect.any(Object), + ); + }); + }); + + describe("integration with setHeaders", () => { + it("should use updated headers after setHeaders call", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + headers: { + Authorization: "Bearer old-token", + }, + }); + + copilotKitCore.setHeaders({ + Authorization: "Bearer new-token", + "X-New-Header": "new-value", + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer new-token", + "X-New-Header": "new-value", + }, + }); + }); + }); + + describe("integration with setResourceId", () => { + it("should use updated resource ID after setResourceId call", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + resourceId: "user-123", + }); + + copilotKitCore.setResourceId("user-456"); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "X-CopilotKit-Resource-ID": "user-456", + }, + }); + }); + }); + + describe("integration with setRuntimeUrl", () => { + it("should use updated runtime URL after setRuntimeUrl call", async () => { + copilotKitCore = new CopilotKitCore({ + runtimeUrl: "https://example.com/api", + }); + + copilotKitCore.setRuntimeUrl("https://new-example.com/api"); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await copilotKitCore.deleteThread("thread-123"); + + expect(mockFetch).toHaveBeenCalledWith("https://new-example.com/api/threads/thread-123", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + }); + }); +}); diff --git a/packages/core/src/__tests__/core-resource-id.test.ts b/packages/core/src/__tests__/core-resource-id.test.ts new file mode 100644 index 00000000..a7464854 --- /dev/null +++ b/packages/core/src/__tests__/core-resource-id.test.ts @@ -0,0 +1,648 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CopilotKitCore } from "../core"; +import { HttpAgent } from "@ag-ui/client"; + +describe("CopilotKitCore - resourceId", () => { + const originalWindow = (global as any).window; + + beforeEach(() => { + vi.restoreAllMocks(); + // Mock window to simulate browser environment + (global as any).window = {}; + }); + + afterEach(() => { + // Restore window + if (originalWindow === undefined) { + delete (global as any).window; + } else { + (global as any).window = originalWindow; + } + }); + + describe("resourceId getter and constructor", () => { + it("should accept string resourceId in constructor", () => { + const core = new CopilotKitCore({ + resourceId: "user-123", + }); + + expect(core.resourceId).toBe("user-123"); + }); + + it("should accept array resourceId in constructor", () => { + const core = new CopilotKitCore({ + resourceId: ["user-123", "workspace-456"], + }); + + expect(core.resourceId).toEqual(["user-123", "workspace-456"]); + }); + + it("should accept undefined resourceId in constructor", () => { + const core = new CopilotKitCore({}); + + expect(core.resourceId).toBeUndefined(); + }); + + it("should handle empty array resourceId", () => { + const core = new CopilotKitCore({ + resourceId: [], + }); + + expect(core.resourceId).toEqual([]); + }); + + it("should preserve resourceId with special characters", () => { + const specialId = "user@example.com/workspace#123"; + const core = new CopilotKitCore({ + resourceId: specialId, + }); + + expect(core.resourceId).toBe(specialId); + }); + + it("should preserve resourceId with Unicode characters", () => { + const unicodeId = "用户-123-مستخدم"; + const core = new CopilotKitCore({ + resourceId: unicodeId, + }); + + expect(core.resourceId).toBe(unicodeId); + }); + + it("should handle very long resourceId", () => { + const longId = "user-" + "a".repeat(1000); + const core = new CopilotKitCore({ + resourceId: longId, + }); + + expect(core.resourceId).toBe(longId); + }); + + it("should handle array with many resourceIds", () => { + const manyIds = Array.from({ length: 100 }, (_, i) => `id-${i}`); + const core = new CopilotKitCore({ + resourceId: manyIds, + }); + + expect(core.resourceId).toEqual(manyIds); + }); + }); + + describe("setResourceId method", () => { + it("should update resourceId from string to string", () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + core.setResourceId("user-2"); + + expect(core.resourceId).toBe("user-2"); + }); + + it("should update resourceId from string to array", () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + core.setResourceId(["user-1", "workspace-1"]); + + expect(core.resourceId).toEqual(["user-1", "workspace-1"]); + }); + + it("should update resourceId from array to string", () => { + const core = new CopilotKitCore({ + resourceId: ["user-1", "workspace-1"], + }); + + core.setResourceId("user-2"); + + expect(core.resourceId).toBe("user-2"); + }); + + it("should update resourceId to undefined", () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + core.setResourceId(undefined); + + expect(core.resourceId).toBeUndefined(); + }); + + it("should update resourceId from undefined to string", () => { + const core = new CopilotKitCore({}); + + core.setResourceId("user-1"); + + expect(core.resourceId).toBe("user-1"); + }); + + it("should handle multiple rapid updates", () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + core.setResourceId("user-2"); + core.setResourceId("user-3"); + core.setResourceId("user-4"); + + expect(core.resourceId).toBe("user-4"); + }); + + it("should handle transition to empty array", () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + core.setResourceId([]); + + expect(core.resourceId).toEqual([]); + }); + }); + + describe("subscriber notifications", () => { + it("should notify subscribers when resourceId changes", async () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + const subscriber = { + onResourceIdChanged: vi.fn(), + }; + + core.subscribe(subscriber); + core.setResourceId("user-2"); + + // Wait for notification + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(subscriber.onResourceIdChanged).toHaveBeenCalledWith({ + copilotkit: core, + resourceId: "user-2", + }); + }); + + it("should notify subscribers when resourceId set to array", async () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + const subscriber = { + onResourceIdChanged: vi.fn(), + }; + + core.subscribe(subscriber); + core.setResourceId(["user-1", "workspace-1"]); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(subscriber.onResourceIdChanged).toHaveBeenCalledWith({ + copilotkit: core, + resourceId: ["user-1", "workspace-1"], + }); + }); + + it("should notify subscribers when resourceId set to undefined", async () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + const subscriber = { + onResourceIdChanged: vi.fn(), + }; + + core.subscribe(subscriber); + core.setResourceId(undefined); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(subscriber.onResourceIdChanged).toHaveBeenCalledWith({ + copilotkit: core, + resourceId: undefined, + }); + }); + + it("should notify multiple subscribers", async () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + const subscriber1 = { onResourceIdChanged: vi.fn() }; + const subscriber2 = { onResourceIdChanged: vi.fn() }; + const subscriber3 = { onResourceIdChanged: vi.fn() }; + + core.subscribe(subscriber1); + core.subscribe(subscriber2); + core.subscribe(subscriber3); + + core.setResourceId("user-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(subscriber1.onResourceIdChanged).toHaveBeenCalledOnce(); + expect(subscriber2.onResourceIdChanged).toHaveBeenCalledOnce(); + expect(subscriber3.onResourceIdChanged).toHaveBeenCalledOnce(); + }); + + it("should not notify unsubscribed subscribers", async () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + const subscriber = { + onResourceIdChanged: vi.fn(), + }; + + const unsubscribe = core.subscribe(subscriber); + unsubscribe(); + + core.setResourceId("user-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(subscriber.onResourceIdChanged).not.toHaveBeenCalled(); + }); + + it("should handle subscriber errors gracefully", async () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const subscriber = { + onResourceIdChanged: vi.fn(() => { + throw new Error("Subscriber error"); + }), + }; + + core.subscribe(subscriber); + core.setResourceId("user-2"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errorSpy).toHaveBeenCalledWith( + "Subscriber onResourceIdChanged error:", + expect.any(Error) + ); + + errorSpy.mockRestore(); + }); + }); + + describe("getHeadersWithResourceId", () => { + it("should include resourceId header with single string", () => { + const core = new CopilotKitCore({ + resourceId: "user-123", + headers: { + Authorization: "Bearer token", + }, + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers).toEqual({ + Authorization: "Bearer token", + "X-CopilotKit-Resource-ID": "user-123", + }); + }); + + it("should include resourceId header with array (comma-separated)", () => { + const core = new CopilotKitCore({ + resourceId: ["user-123", "workspace-456"], + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user-123,workspace-456"); + }); + + it("should not include resourceId header when undefined", () => { + const core = new CopilotKitCore({ + headers: { + Authorization: "Bearer token", + }, + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers).toEqual({ + Authorization: "Bearer token", + }); + expect(headers["X-CopilotKit-Resource-ID"]).toBeUndefined(); + }); + + it("should URI encode special characters", () => { + const core = new CopilotKitCore({ + resourceId: "user@example.com/workspace#123", + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe( + "user%40example.com%2Fworkspace%23123" + ); + }); + + it("should URI encode each value in array", () => { + const core = new CopilotKitCore({ + resourceId: ["user@example.com", "workspace/123"], + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe( + "user%40example.com,workspace%2F123" + ); + }); + + it("should handle Unicode characters", () => { + const core = new CopilotKitCore({ + resourceId: "用户-123", + }); + + const headers = core.getHeadersWithResourceId(); + + // encodeURIComponent encodes Unicode + expect(headers["X-CopilotKit-Resource-ID"]).toBe( + encodeURIComponent("用户-123") + ); + }); + + it("should handle empty array (no header)", () => { + const core = new CopilotKitCore({ + resourceId: [], + }); + + const headers = core.getHeadersWithResourceId(); + + // Empty array produces empty joined string, which is falsy + expect(headers["X-CopilotKit-Resource-ID"]).toBeUndefined(); + }); + + it("should update header when resourceId changes", () => { + const core = new CopilotKitCore({ + resourceId: "user-1", + }); + + let headers = core.getHeadersWithResourceId(); + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user-1"); + + core.setResourceId("user-2"); + + headers = core.getHeadersWithResourceId(); + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user-2"); + }); + + it("should merge with other headers", () => { + const core = new CopilotKitCore({ + resourceId: "user-123", + headers: { + Authorization: "Bearer token", + "X-Custom": "value", + }, + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers).toEqual({ + Authorization: "Bearer token", + "X-Custom": "value", + "X-CopilotKit-Resource-ID": "user-123", + }); + }); + + it("should handle very long resourceId", () => { + const longId = "user-" + "a".repeat(1000); + const core = new CopilotKitCore({ + resourceId: longId, + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe(encodeURIComponent(longId)); + }); + + it("should handle many resourceIds in array", () => { + const manyIds = Array.from({ length: 100 }, (_, i) => `id-${i}`); + const core = new CopilotKitCore({ + resourceId: manyIds, + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe(manyIds.join(",")); + }); + + it("should handle whitespace in resourceId", () => { + const core = new CopilotKitCore({ + resourceId: "user with spaces", + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user%20with%20spaces"); + }); + + it("should handle duplicate IDs in array", () => { + const core = new CopilotKitCore({ + resourceId: ["user-1", "user-1", "user-2"], + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user-1,user-1,user-2"); + }); + }); + + describe("HttpAgent integration", () => { + it("should apply resourceId header to HttpAgent on runAgent", async () => { + const recorded: Array> = []; + + class RecordingHttpAgent extends HttpAgent { + constructor() { + super({ url: "https://runtime.example" }); + } + + async runAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + } + + const agent = new RecordingHttpAgent(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + resourceId: "user-123", + headers: { Authorization: "Bearer token" }, + agents__unsafe_dev_only: { default: agent }, + }); + + await core.runAgent({ agent }); + + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + Authorization: "Bearer token", + "X-CopilotKit-Resource-ID": "user-123", + }); + }); + + it("should update HttpAgent headers when resourceId changes", async () => { + const recorded: Array> = []; + + class RecordingHttpAgent extends HttpAgent { + constructor() { + super({ url: "https://runtime.example" }); + } + + async runAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + } + + const agent = new RecordingHttpAgent(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + resourceId: "user-1", + agents__unsafe_dev_only: { default: agent }, + }); + + await core.runAgent({ agent }); + + core.setResourceId("user-2"); + + await core.runAgent({ agent }); + + expect(recorded).toHaveLength(2); + expect(recorded[0]!["X-CopilotKit-Resource-ID"]).toBe("user-1"); + expect(recorded[1]!["X-CopilotKit-Resource-ID"]).toBe("user-2"); + }); + + it("should handle resourceId with array in HttpAgent", async () => { + const recorded: Array> = []; + + class RecordingHttpAgent extends HttpAgent { + constructor() { + super({ url: "https://runtime.example" }); + } + + async connectAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + } + + const agent = new RecordingHttpAgent(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + resourceId: ["user-123", "workspace-456"], + agents__unsafe_dev_only: { default: agent }, + }); + + await core.connectAgent({ agent }); + + expect(recorded).toHaveLength(1); + expect(recorded[0]!["X-CopilotKit-Resource-ID"]).toBe("user-123,workspace-456"); + }); + + it("should not include resourceId header when undefined", async () => { + const recorded: Array> = []; + + class RecordingHttpAgent extends HttpAgent { + constructor() { + super({ url: "https://runtime.example" }); + } + + async runAgent(...args: Parameters) { + recorded.push({ ...this.headers }); + return Promise.resolve({ newMessages: [] }) as ReturnType; + } + } + + const agent = new RecordingHttpAgent(); + + const core = new CopilotKitCore({ + runtimeUrl: undefined, + agents__unsafe_dev_only: { default: agent }, + }); + + await core.runAgent({ agent }); + + expect(recorded).toHaveLength(1); + expect(recorded[0]!["X-CopilotKit-Resource-ID"]).toBeUndefined(); + }); + }); + + describe("edge cases", () => { + it("should handle resourceId with control characters", () => { + const core = new CopilotKitCore({ + resourceId: "user\n123\ttab", + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe( + encodeURIComponent("user\n123\ttab") + ); + }); + + it("should handle resourceId with only whitespace", () => { + const core = new CopilotKitCore({ + resourceId: " ", + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe("%20%20%20"); + }); + + it("should handle array with empty strings", () => { + const core = new CopilotKitCore({ + resourceId: ["user-1", "", "workspace-1"], + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user-1,,workspace-1"); + }); + + it("should handle resourceId with percent signs", () => { + const core = new CopilotKitCore({ + resourceId: "user%20with%20encoded", + }); + + const headers = core.getHeadersWithResourceId(); + + // Double encoding + expect(headers["X-CopilotKit-Resource-ID"]).toBe( + encodeURIComponent("user%20with%20encoded") + ); + }); + + it("should handle single-element array", () => { + const core = new CopilotKitCore({ + resourceId: ["user-123"], + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe("user-123"); + }); + + it("should handle resourceId with emoji", () => { + const core = new CopilotKitCore({ + resourceId: "user-😀-123", + }); + + const headers = core.getHeadersWithResourceId(); + + expect(headers["X-CopilotKit-Resource-ID"]).toBe( + encodeURIComponent("user-😀-123") + ); + }); + }); +}); diff --git a/packages/core/src/core/core.ts b/packages/core/src/core/core.ts index dad2bcc6..d65f9c0b 100644 --- a/packages/core/src/core/core.ts +++ b/packages/core/src/core/core.ts @@ -1,4 +1,4 @@ -import { AbstractAgent, Context, State } from "@ag-ui/client"; +import { AbstractAgent, Context, State, Message } from "@ag-ui/client"; import { FrontendTool, SuggestionsConfig, Suggestion } from "../types"; import { AgentRegistry, CopilotKitCoreAddAgentParams } from "./agent-registry"; import { ContextStore } from "./context-store"; @@ -8,6 +8,7 @@ import { CopilotKitCoreRunAgentParams, CopilotKitCoreConnectAgentParams, CopilotKitCoreGetToolParams, + CopilotKitCoreDisconnectAgentParams, } from "./run-handler"; import { StateManager } from "./state-manager"; @@ -21,6 +22,13 @@ export interface CopilotKitCoreConfig { headers?: Record; /** Properties sent as `forwardedProps` to the AG-UI agent. */ properties?: Record; + /** + * Resource ID(s) for thread access control. + * + * This value is sent to the server as a hint for thread scoping. + * The server's `resolveThreadsScope` validates and enforces access control. + */ + resourceId?: string | string[]; /** Ordered collection of frontend tools available to the core. */ tools?: FrontendTool[]; /** Suggestions config for the core. */ @@ -32,6 +40,7 @@ export type { CopilotKitCoreRunAgentParams, CopilotKitCoreConnectAgentParams, CopilotKitCoreGetToolParams, + CopilotKitCoreDisconnectAgentParams, }; export interface CopilotKitCoreStopAgentParams { @@ -100,6 +109,10 @@ export interface CopilotKitCoreSubscriber { copilotkit: CopilotKitCore; headers: Readonly>; }) => void | Promise; + onResourceIdChanged?: (event: { + copilotkit: CopilotKitCore; + resourceId: string | string[] | undefined; + }) => void | Promise; onError?: (event: { copilotkit: CopilotKitCore; error: Error; @@ -126,11 +139,7 @@ export interface CopilotKitCoreFriendsAccess { errorMessage: string, ): Promise; - emitError(params: { - error: Error; - code: CopilotKitCoreErrorCode; - context?: Record; - }): Promise; + emitError(params: { error: Error; code: CopilotKitCoreErrorCode; context?: Record }): Promise; // Getters for internal state readonly headers: Readonly>; @@ -151,6 +160,7 @@ export interface CopilotKitCoreFriendsAccess { export class CopilotKitCore { private _headers: Record; private _properties: Record; + private _resourceId: string | string[] | undefined; private subscribers: Set = new Set(); @@ -165,12 +175,14 @@ export class CopilotKitCore { runtimeUrl, headers = {}, properties = {}, + resourceId, agents__unsafe_dev_only = {}, tools = [], suggestionsConfig = [], }: CopilotKitCoreConfig) { this._headers = headers; this._properties = properties; + this._resourceId = resourceId; // Initialize delegate classes this.agentRegistry = new AgentRegistry(this); @@ -276,6 +288,10 @@ export class CopilotKitCore { return this._properties; } + get resourceId(): string | string[] | undefined { + return this._resourceId; + } + get runtimeConnectionStatus(): CopilotKitCoreRuntimeConnectionStatus { return this.agentRegistry.runtimeConnectionStatus; } @@ -308,6 +324,18 @@ export class CopilotKitCore { ); } + setResourceId(resourceId: string | string[] | undefined): void { + this._resourceId = resourceId; + void this.notifySubscribers( + (subscriber) => + subscriber.onResourceIdChanged?.({ + copilotkit: this, + resourceId: this.resourceId, + }), + "Subscriber onResourceIdChanged error:", + ); + } + /** * Agent management (delegated to AgentRegistry) */ @@ -407,6 +435,10 @@ export class CopilotKitCore { params.agent.abortRun(); } + async disconnectAgent(params: CopilotKitCoreDisconnectAgentParams): Promise { + await this.runHandler.disconnectAgent(params); + } + async runAgent(params: CopilotKitCoreRunAgentParams): Promise { return this.runHandler.runAgent(params); } @@ -426,6 +458,146 @@ export class CopilotKitCore { return this.stateManager.getRunIdsForThread(agentId, threadId); } + /** + * Helper to format resourceId for HTTP header transport. + * Encodes each value with encodeURIComponent and joins with comma. + */ + private formatResourceIdHeader(): string | undefined { + if (!this._resourceId) { + return undefined; + } + + const ids = Array.isArray(this._resourceId) ? this._resourceId : [this._resourceId]; + return ids.map((id) => encodeURIComponent(id)).join(","); + } + + /** + * Get headers with resourceId header included (if resourceId is set). + * Used internally by RunHandler for HttpAgent requests. + */ + getHeadersWithResourceId(): Record { + const resourceIdHeader = this.formatResourceIdHeader(); + return { + ...this.headers, + ...(resourceIdHeader && { "X-CopilotKit-Resource-ID": resourceIdHeader }), + }; + } + + /** + * Thread management + */ + async listThreads(params?: { limit?: number; offset?: number }): Promise<{ + threads: Array<{ + threadId: string; + createdAt: number; + lastActivityAt: number; + isRunning: boolean; + messageCount: number; + firstMessage?: string; + }>; + total: number; + }> { + const runtimeUrl = this.runtimeUrl; + if (!runtimeUrl) { + throw new Error("Runtime URL is required to list threads"); + } + + // Ensure URL is properly formatted + const baseUrl = runtimeUrl.endsWith("/") ? runtimeUrl.slice(0, -1) : runtimeUrl; + const urlString = `${baseUrl}/threads`; + + // Build query params + const queryParams = new URLSearchParams(); + if (params?.limit !== undefined) { + queryParams.set("limit", params.limit.toString()); + } + if (params?.offset !== undefined) { + queryParams.set("offset", params.offset.toString()); + } + + const queryString = queryParams.toString(); + const fullUrl = queryString ? `${urlString}?${queryString}` : urlString; + + const resourceIdHeader = this.formatResourceIdHeader(); + const response = await fetch(fullUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...this.headers, + ...(resourceIdHeader && { "X-CopilotKit-Resource-ID": resourceIdHeader }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to list threads: ${response.statusText}`); + } + + return await response.json(); + } + + async getThreadMetadata(threadId: string): Promise<{ + threadId: string; + createdAt: number; + lastActivityAt: number; + isRunning: boolean; + messageCount: number; + firstMessage?: string; + } | null> { + const runtimeUrl = this.runtimeUrl; + if (!runtimeUrl) { + throw new Error("Runtime URL is required to get thread metadata"); + } + + // Ensure URL is properly formatted + const baseUrl = runtimeUrl.endsWith("/") ? runtimeUrl.slice(0, -1) : runtimeUrl; + const fullUrl = `${baseUrl}/threads/${threadId}`; + + const resourceIdHeader = this.formatResourceIdHeader(); + const response = await fetch(fullUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...this.headers, + ...(resourceIdHeader && { "X-CopilotKit-Resource-ID": resourceIdHeader }), + }, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to get thread metadata: ${response.statusText}`); + } + + return await response.json(); + } + + async deleteThread(threadId: string): Promise { + const runtimeUrl = this.runtimeUrl; + if (!runtimeUrl) { + throw new Error("Runtime URL is required to delete a thread"); + } + + // Ensure URL is properly formatted + const baseUrl = runtimeUrl.endsWith("/") ? runtimeUrl.slice(0, -1) : runtimeUrl; + const fullUrl = `${baseUrl}/threads/${threadId}`; + + const resourceIdHeader = this.formatResourceIdHeader(); + const response = await fetch(fullUrl, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + ...this.headers, + ...(resourceIdHeader && { "X-CopilotKit-Resource-ID": resourceIdHeader }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to delete thread: ${response.statusText}`); + } + } + /** * Internal method used by RunHandler to build frontend tools */ diff --git a/packages/core/src/core/run-handler.ts b/packages/core/src/core/run-handler.ts index 45949c2b..16a310ea 100644 --- a/packages/core/src/core/run-handler.ts +++ b/packages/core/src/core/run-handler.ts @@ -12,6 +12,11 @@ export interface CopilotKitCoreRunAgentParams { export interface CopilotKitCoreConnectAgentParams { agent: AbstractAgent; + threadId?: string; +} + +export interface CopilotKitCoreDisconnectAgentParams { + agent: AbstractAgent; } export interface CopilotKitCoreGetToolParams { @@ -102,10 +107,57 @@ export class RunHandler { /** * Connect an agent (establish initial connection) */ - async connectAgent({ agent }: CopilotKitCoreConnectAgentParams): Promise { + async connectAgent({ agent, threadId }: CopilotKitCoreConnectAgentParams): Promise { try { if (agent instanceof HttpAgent) { - agent.headers = { ...(this.core as unknown as CopilotKitCoreFriendsAccess).headers }; + agent.headers = this.core.getHeadersWithResourceId(); + } + + if (threadId) { + agent.threadId = threadId; + } + + const expectedThreadIdKey = "__copilotkitExpectedThreadId"; + const guardKey = "__copilotkitThreadGuard"; + + (agent as any)[expectedThreadIdKey] = agent.threadId ?? threadId ?? null; + + if (!(agent as any)[guardKey]) { + const guardSubscription = agent.subscribe({ + onEvent: ({ event, input }) => { + const expectedThreadId = (agent as any)[expectedThreadIdKey]; + const eventThreadId = input?.threadId; + + // If no expected thread (during disconnect), drop ALL events + if (!expectedThreadId) { + console.debug( + "CopilotKitCore: dropping event - no expected thread (disconnected state)", + "eventThread:", + eventThreadId, + "event:", + event.type, + ); + return { stopPropagation: true }; + } + + // If event has a thread ID and it doesn't match expected, drop it + if (eventThreadId && eventThreadId !== expectedThreadId) { + console.debug( + "CopilotKitCore: dropping event from stale thread", + eventThreadId, + "expected", + expectedThreadId, + "event", + event.type, + ); + return { stopPropagation: true }; + } + + return; + }, + }); + + (agent as any)[guardKey] = guardSubscription; } const runAgentResult = await agent.connectAgent( @@ -116,6 +168,8 @@ export class RunHandler { this.createAgentErrorSubscriber(agent), ); + (agent as any).__copilotkitExpectedThreadId = agent.threadId ?? threadId ?? null; + return this.processAgentResult({ runAgentResult, agent }); } catch (error) { const connectError = error instanceof Error ? error : new Error(String(error)); @@ -132,6 +186,32 @@ export class RunHandler { } } + async disconnectAgent({ agent }: CopilotKitCoreDisconnectAgentParams): Promise { + const disconnectableAgent = agent as AbstractAgent & { + disconnectAgent?: () => Promise | void; + stop?: () => Promise | void; + }; + + // Clear the expected thread ID - this stops UI updates for this thread + // The agent continues running in background, we just stop listening to its events + (agent as any).__copilotkitExpectedThreadId = null; + + // Note: We DO NOT abort or stop the agent here! + // Reason: The backend thread continues generating. When user returns to this thread, + // connectAgent() will sync all the new messages that were generated while they were away. + // This gives the ChatGPT-like behavior where you can switch tabs and come back to see + // the full conversation that continued in your absence. + + // Optional: Call agent's disconnectAgent for cleanup (close streams, etc) + if (typeof disconnectableAgent.disconnectAgent === "function") { + try { + await disconnectableAgent.disconnectAgent(); + } catch (error) { + console.debug("Error during agent disconnection (non-critical):", error); + } + } + } + /** * Run an agent */ @@ -142,7 +222,7 @@ export class RunHandler { } if (agent instanceof HttpAgent) { - agent.headers = { ...(this.core as unknown as CopilotKitCoreFriendsAccess).headers }; + agent.headers = this.core.getHeadersWithResourceId(); } if (withMessages) { @@ -543,6 +623,21 @@ export class RunHandler { } } +async function waitForAgentToStop(agent: AbstractAgent, timeoutMs = 2000): Promise { + if (!agent.isRunning) { + return; + } + + const start = Date.now(); + while (agent.isRunning && Date.now() - start < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + if (agent.isRunning) { + console.warn("Agent did not stop within timeout after abortRun."); + } +} + /** * Empty tool schema constant */ diff --git a/packages/core/src/core/suggestion-engine.ts b/packages/core/src/core/suggestion-engine.ts index 47e11919..21c58243 100644 --- a/packages/core/src/core/suggestion-engine.ts +++ b/packages/core/src/core/suggestion-engine.ts @@ -136,7 +136,8 @@ export class SuggestionEngine { const clonedAgent: AbstractAgent = suggestionsProviderAgent.clone(); agent = clonedAgent; agent.agentId = suggestionId; - agent.threadId = suggestionId; + // Suggestions shouldn't persist as threads; run threadless + agent.threadId = ""; agent.messages = JSON.parse(JSON.stringify(suggestionsConsumerAgent.messages)); agent.state = JSON.parse(JSON.stringify(suggestionsConsumerAgent.state)); diff --git a/packages/react/src/__tests__/utils/test-helpers.tsx b/packages/react/src/__tests__/utils/test-helpers.tsx index 9572d980..0d235d9d 100644 --- a/packages/react/src/__tests__/utils/test-helpers.tsx +++ b/packages/react/src/__tests__/utils/test-helpers.tsx @@ -4,13 +4,8 @@ import { CopilotKitProvider } from "@/providers/CopilotKitProvider"; import { CopilotChat } from "@/components/chat/CopilotChat"; import { CopilotChatConfigurationProvider } from "@/providers/CopilotChatConfigurationProvider"; import { DEFAULT_AGENT_ID } from "@copilotkitnext/shared"; -import { - AbstractAgent, - EventType, - type BaseEvent, - type RunAgentInput, -} from "@ag-ui/client"; -import { Observable, Subject } from "rxjs"; +import { AbstractAgent, EventType, type BaseEvent, type RunAgentInput } from "@ag-ui/client"; +import { Observable, Subject, EMPTY } from "rxjs"; import { ReactToolCallRenderer } from "@/types"; import { ReactCustomMessageRenderer } from "@/types/react-custom-message-renderer"; @@ -27,10 +22,7 @@ export class MockStepwiseAgent extends AbstractAgent { emit(event: BaseEvent) { if (event.type === EventType.RUN_STARTED) { this.isRunning = true; - } else if ( - event.type === EventType.RUN_FINISHED || - event.type === EventType.RUN_ERROR - ) { + } else if (event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR) { this.isRunning = false; } act(() => { @@ -56,6 +48,17 @@ export class MockStepwiseAgent extends AbstractAgent { protected run(_input: RunAgentInput): Observable { return this.subject.asObservable(); } + + protected connect(_input: RunAgentInput): Observable { + // Return EMPTY observable - tests will emit events manually via emit() + // This prevents the "Connect not implemented" error when switching threads + return EMPTY; + } + + async connectAgent(): Promise<{ newMessages: any[]; result?: any }> { + // Return empty array - tests will emit events manually via emit() + return { newMessages: [], result: undefined }; + } } /** @@ -94,17 +97,14 @@ export function renderWithCopilotKit({ frontendTools={frontendTools} humanInTheLoop={humanInTheLoop} > - + {children || (
)}
-
+
, ); } @@ -241,28 +241,32 @@ export function emitSuggestionToolCall( toolCallId: string; parentMessageId: string; suggestions: Array<{ title: string; message: string }>; - } + }, ) { // Convert suggestions to JSON string const suggestionsJson = JSON.stringify({ suggestions }); // Emit the tool call name first - agent.emit(toolCallChunkEvent({ - toolCallId, - toolCallName: "copilotkitSuggest", - parentMessageId, - delta: "", - })); + agent.emit( + toolCallChunkEvent({ + toolCallId, + toolCallName: "copilotkitSuggest", + parentMessageId, + delta: "", + }), + ); // Stream the JSON in chunks to simulate streaming const chunkSize = 10; // Characters per chunk for (let i = 0; i < suggestionsJson.length; i += chunkSize) { const chunk = suggestionsJson.substring(i, i + chunkSize); - agent.emit(toolCallChunkEvent({ - toolCallId, - parentMessageId, - delta: chunk, - })); + agent.emit( + toolCallChunkEvent({ + toolCallId, + parentMessageId, + delta: chunk, + }), + ); } } diff --git a/packages/react/src/components/CopilotKitInspector.tsx b/packages/react/src/components/CopilotKitInspector.tsx index 5596e139..46e9de24 100644 --- a/packages/react/src/components/CopilotKitInspector.tsx +++ b/packages/react/src/components/CopilotKitInspector.tsx @@ -1,17 +1,19 @@ import * as React from "react"; import { createComponent } from "@lit-labs/react"; -import { - WEB_INSPECTOR_TAG, - WebInspectorElement, - defineWebInspector, -} from "@copilotkitnext/web-inspector"; +import * as WebInspectorModule from "@copilotkitnext/web-inspector"; import type { CopilotKitCore } from "@copilotkitnext/core"; +const WEB_INSPECTOR_TAG = (WebInspectorModule as { WEB_INSPECTOR_TAG: string }).WEB_INSPECTOR_TAG; +const defineWebInspector = (WebInspectorModule as { defineWebInspector: () => void }).defineWebInspector; +const WebInspectorElementClass = (WebInspectorModule as { WebInspectorElement: typeof HTMLElement }).WebInspectorElement; + +type WebInspectorElementInstance = HTMLElement & { core: CopilotKitCore | null }; + defineWebInspector(); const CopilotKitInspectorBase = createComponent({ tagName: WEB_INSPECTOR_TAG, - elementClass: WebInspectorElement, + elementClass: WebInspectorElementClass, react: React, }); @@ -21,14 +23,11 @@ export interface CopilotKitInspectorProps extends Omit( +export const CopilotKitInspector = React.forwardRef( ({ core, ...rest }, ref) => { - const innerRef = React.useRef(null); + const innerRef = React.useRef(null); - React.useImperativeHandle(ref, () => innerRef.current as WebInspectorElement, []); + React.useImperativeHandle(ref, () => innerRef.current as WebInspectorElementInstance, []); React.useEffect(() => { if (innerRef.current) { diff --git a/packages/react/src/components/chat/CopilotChat.tsx b/packages/react/src/components/chat/CopilotChat.tsx index b91eae74..4f41c049 100644 --- a/packages/react/src/components/chat/CopilotChat.tsx +++ b/packages/react/src/components/chat/CopilotChat.tsx @@ -9,10 +9,9 @@ import { } from "@/providers/CopilotChatConfigurationProvider"; import { DEFAULT_AGENT_ID, randomUUID } from "@copilotkitnext/shared"; import { Suggestion } from "@copilotkitnext/core"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { merge } from "ts-deepmerge"; import { useCopilotKit } from "@/providers/CopilotKitProvider"; -import { AbstractAgent, AGUIConnectNotImplementedError } from "@ag-ui/client"; import { renderSlot, SlotValue } from "@/lib/slots"; export type CopilotChatProps = Omit< @@ -29,15 +28,139 @@ export function CopilotChat({ agentId, threadId, labels, chatView, isModalDefaul // Check for existing configuration provider const existingConfig = useCopilotChatConfiguration(); + // Wrap in provider if no config exists OR if we have override props + const hasOverrideProps = + agentId !== undefined || threadId !== undefined || labels !== undefined || isModalDefaultOpen !== undefined; + + if (!existingConfig || hasOverrideProps) { + return ( + + + + ); + } + // Apply priority: props > existing config > defaults const resolvedAgentId = agentId ?? existingConfig?.agentId ?? DEFAULT_AGENT_ID; - const resolvedThreadId = useMemo( - () => threadId ?? existingConfig?.threadId ?? randomUUID(), - [threadId, existingConfig?.threadId], - ); + const resolvedThreadId = threadId ?? existingConfig?.threadId; + const { agent } = useAgent({ agentId: resolvedAgentId }); const { copilotkit } = useCopilotKit(); + // Track thread switching state + const [isSwitchingThread, setIsSwitchingThread] = useState(false); + const [threadSwitchError, setThreadSwitchError] = useState(null); + const previousThreadIdRef = useRef(undefined); + const abortControllerRef = useRef(null); + + // Handle thread switching when threadId changes + useEffect(() => { + if (!agent || !resolvedThreadId) return; + + // Skip if thread hasn't changed + if (previousThreadIdRef.current === resolvedThreadId) return; + + // Cancel any in-flight thread switch to prevent race conditions + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller for this switch operation + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + // Switch threads immediately (no debounce) + const switchThread = async () => { + setIsSwitchingThread(true); + setThreadSwitchError(null); + + try { + // Check if aborted before starting + if (abortController.signal.aborted) return; + + // Disconnect from previous thread if exists + if (previousThreadIdRef.current) { + try { + await copilotkit.disconnectAgent({ agent }); + } catch (disconnectErr) { + // Log disconnect error but continue with the switch + // The disconnect already clears the thread guard, so stale events will be dropped + console.warn("Error during disconnect, continuing with thread switch:", disconnectErr); + } + } + + // Check if aborted after disconnect + if (abortController.signal.aborted) { + // Reset previousThreadIdRef to allow reconnection when returning to the old thread + // Without this, the guard on line 66 would short-circuit and prevent reconnection + previousThreadIdRef.current = undefined; + return; + } + + // Clear messages to prepare for the new thread's messages + // connectAgent() will sync messages from the backend for the new thread + agent.messages = []; + + // Manually trigger subscribers since direct assignment doesn't notify them + // This ensures React components re-render with the empty messages + const subscribers = (agent as any).subscribers || []; + subscribers.forEach((subscriber: any) => { + if (subscriber.onMessagesChanged) { + subscriber.onMessagesChanged({ + messages: agent.messages, + state: agent.state, + agent: agent, + }); + } + }); + + // Check if aborted before connecting + if (abortController.signal.aborted) { + // Reset previousThreadIdRef to allow reconnection when returning to the old thread + previousThreadIdRef.current = undefined; + return; + } + + // Connect to new thread - this syncs messages from backend for this thread + // (including any that were generated while we were on other threads) + await copilotkit.connectAgent({ agent, threadId: resolvedThreadId }); + + // Only update previousThreadIdRef if not aborted (successful switch) + if (!abortController.signal.aborted) { + previousThreadIdRef.current = resolvedThreadId; + } + } catch (err) { + // Ignore aborted errors + if (abortController.signal.aborted) { + // Reset previousThreadIdRef to allow reconnection when returning to the old thread + previousThreadIdRef.current = undefined; + return; + } + + const error = err instanceof Error ? err : new Error(String(err)); + setThreadSwitchError(error); + console.error("Failed to switch thread:", error); + } finally { + // Only clear loading state if this operation wasn't aborted + if (!abortController.signal.aborted) { + setIsSwitchingThread(false); + } + } + }; + + void switchThread(); + + // Cleanup: abort on unmount or when deps change + return () => { + abortController.abort(); + }; + }, [agent, resolvedThreadId, copilotkit]); + const { suggestions: autoSuggestions } = useSuggestions({ agentId: resolvedAgentId }); const { @@ -47,25 +170,6 @@ export function CopilotChat({ agentId, threadId, labels, chatView, isModalDefaul ...restProps } = props; - useEffect(() => { - const connect = async (agent: AbstractAgent) => { - try { - await copilotkit.connectAgent({ agent }); - } catch (error) { - if (error instanceof AGUIConnectNotImplementedError) { - // connect not implemented, ignore - } else { - throw error; - } - } - }; - if (agent) { - agent.threadId = resolvedThreadId; - connect(agent); - } - return () => {}; - }, [resolvedThreadId, agent, copilotkit, resolvedAgentId]); - const onSubmitInput = useCallback( async (value: string) => { agent?.addMessage({ @@ -142,7 +246,7 @@ export function CopilotChat({ agentId, threadId, labels, chatView, isModalDefaul const providedStopHandler = providedInputProps?.onStop; const hasMessages = (agent?.messages?.length ?? 0) > 0; const shouldAllowStop = (agent?.isRunning ?? false) && hasMessages; - const effectiveStopHandler = shouldAllowStop ? providedStopHandler ?? stopCurrentRun : providedStopHandler; + const effectiveStopHandler = shouldAllowStop ? (providedStopHandler ?? stopCurrentRun) : providedStopHandler; const finalInputProps = { ...providedInputProps, @@ -151,27 +255,32 @@ export function CopilotChat({ agentId, threadId, labels, chatView, isModalDefaul isRunning: agent?.isRunning ?? false, } as Partial & { onSubmitMessage: (value: string) => void }; - finalInputProps.mode = agent?.isRunning ? "processing" : finalInputProps.mode ?? "input"; + finalInputProps.mode = agent?.isRunning ? "processing" : (finalInputProps.mode ?? "input"); const finalProps = merge(mergedProps, { messages: agent?.messages ?? [], inputProps: finalInputProps, }) as CopilotChatViewProps; - // Always create a provider with merged values - // This ensures priority: props > existing config > defaults - const RenderedChatView = renderSlot(chatView, CopilotChatView, finalProps); - - return ( - - {RenderedChatView} - - ); + const inputPropsWithThreadState = { + ...(finalProps.inputProps ?? {}), + mode: isSwitchingThread ? "processing" : finalProps.inputProps?.mode, + }; + + const finalPropsWithThreadState: CopilotChatViewProps & { + "data-thread-switching"?: string; + "data-thread-switch-error"?: string; + } = { + ...finalProps, + isRunning: (finalProps.isRunning ?? false) || isSwitchingThread, + inputProps: inputPropsWithThreadState, + // Pass thread switching state to control scroll behavior + isSwitchingThread, + "data-thread-switching": isSwitchingThread ? "true" : undefined, + "data-thread-switch-error": threadSwitchError ? threadSwitchError.message : undefined, + }; + + return renderSlot(chatView, CopilotChatView, finalPropsWithThreadState); } // eslint-disable-next-line @typescript-eslint/no-namespace diff --git a/packages/react/src/components/chat/CopilotChatInput.tsx b/packages/react/src/components/chat/CopilotChatInput.tsx index 065316aa..741e1dc6 100644 --- a/packages/react/src/components/chat/CopilotChatInput.tsx +++ b/packages/react/src/components/chat/CopilotChatInput.tsx @@ -237,11 +237,7 @@ export function CopilotChatInput({ const previousCommandQueryRef = useRef(null); useEffect(() => { - if ( - commandQuery !== null && - commandQuery !== previousCommandQueryRef.current && - filteredCommands.length > 0 - ) { + if (commandQuery !== null && commandQuery !== previousCommandQueryRef.current && filteredCommands.length > 0) { setSlashHighlightIndex(0); } @@ -429,10 +425,7 @@ export function CopilotChatInput({ onChange: handleChange, onKeyDown: handleKeyDown, autoFocus: autoFocus, - className: twMerge( - "w-full py-3", - isExpanded ? "px-5" : "pr-5", - ), + className: twMerge("w-full py-3", isExpanded ? "px-5" : "pr-5"), }); const isProcessing = mode !== "transcribe" && isRunning; @@ -729,10 +722,10 @@ export function CopilotChatInput({ return; } - const active = slashMenuRef.current?.querySelector( - `[data-slash-index="${slashHighlightIndex}"]`, - ); - active?.scrollIntoView({ block: "nearest" }); + const active = slashMenuRef.current?.querySelector(`[data-slash-index="${slashHighlightIndex}"]`); + if (active && typeof active.scrollIntoView === "function") { + active.scrollIntoView({ block: "nearest" }); + } }, [slashMenuVisible, slashHighlightIndex]); const slashMenu = slashMenuVisible ? ( @@ -807,11 +800,7 @@ export function CopilotChatInput({ >
{BoundAddMenuButton}
@@ -1017,7 +1006,9 @@ export namespace CopilotChatInput {

Add files and more - / + + / +

@@ -1050,7 +1041,9 @@ export namespace CopilotChatInput { const handleFocus = () => { // Small delay to let the keyboard start appearing setTimeout(() => { - textarea.scrollIntoView({ behavior: "smooth", block: "nearest" }); + if (typeof textarea.scrollIntoView === "function") { + textarea.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } }, 300); }; diff --git a/packages/react/src/components/chat/CopilotChatToolCallsView.tsx b/packages/react/src/components/chat/CopilotChatToolCallsView.tsx index c8c879cb..0c91402a 100644 --- a/packages/react/src/components/chat/CopilotChatToolCallsView.tsx +++ b/packages/react/src/components/chat/CopilotChatToolCallsView.tsx @@ -19,13 +19,13 @@ export function CopilotChatToolCallsView({ return ( <> - {message.toolCalls.map((toolCall) => { + {message.toolCalls.map((toolCall, index) => { const toolMessage = messages.find( (m) => m.role === "tool" && m.toolCallId === toolCall.id ) as ToolMessage | undefined; return ( - + {renderToolCall({ toolCall, toolMessage, diff --git a/packages/react/src/components/chat/CopilotChatView.tsx b/packages/react/src/components/chat/CopilotChatView.tsx index fc9c76b0..d19ae428 100644 --- a/packages/react/src/components/chat/CopilotChatView.tsx +++ b/packages/react/src/components/chat/CopilotChatView.tsx @@ -27,8 +27,16 @@ export type CopilotChatViewProps = WithSlots< { messages?: Message[]; autoScroll?: boolean; + /** + * Controls scroll behavior: + * - "smooth": Always smooth scroll + * - "instant": Always instant scroll + * - "auto": Instant scroll on mount and thread switches, smooth scroll during generation (default) + */ + scrollBehavior?: "smooth" | "instant" | "auto"; inputProps?: Partial>; isRunning?: boolean; + isSwitchingThread?: boolean; suggestions?: Suggestion[]; suggestionLoadingIndexes?: ReadonlyArray; onSelectSuggestion?: (suggestion: Suggestion, index: number) => void; @@ -46,8 +54,10 @@ export function CopilotChatView({ suggestionView, messages = [], autoScroll = true, + scrollBehavior = "auto", inputProps, isRunning = false, + isSwitchingThread = false, suggestions, suggestionLoadingIndexes, onSelectSuggestion, @@ -129,6 +139,8 @@ export function CopilotChatView({ const BoundFeather = renderSlot(feather, CopilotChatView.Feather, {}); const BoundScrollView = renderSlot(scrollView, CopilotChatView.ScrollView, { autoScroll, + scrollBehavior, + isSwitchingThread, scrollToBottomButton, inputContainerHeight, isResizing, @@ -219,6 +231,8 @@ export namespace CopilotChatView { export const ScrollView: React.FC< React.HTMLAttributes & { autoScroll?: boolean; + scrollBehavior?: "smooth" | "instant" | "auto"; + isSwitchingThread?: boolean; scrollToBottomButton?: React.FC>; inputContainerHeight?: number; isResizing?: boolean; @@ -226,6 +240,8 @@ export namespace CopilotChatView { > = ({ children, autoScroll = true, + scrollBehavior = "auto", + isSwitchingThread = false, scrollToBottomButton, inputContainerHeight = 0, isResizing = false, @@ -305,11 +321,21 @@ export namespace CopilotChatView { ); } + // Calculate initial and resize behavior based on scrollBehavior prop + // When switching threads in "auto" mode, force instant scroll + const initial = scrollBehavior === "auto" ? "instant" : scrollBehavior; + const resize = + scrollBehavior === "instant" + ? "instant" + : (scrollBehavior === "auto" && isSwitchingThread) + ? "instant" + : "smooth"; + return ( void; + showThreadListButton?: boolean; + onNewThreadClick?: () => void; + showNewThreadButton?: boolean; } & Omit, "children">; export type CopilotModalHeaderProps = WithSlots; @@ -20,6 +25,11 @@ export function CopilotModalHeader({ title, titleContent, closeButton, + leftContent, + onThreadListClick, + showThreadListButton = false, + onNewThreadClick, + showNewThreadButton = false, children, className, ...rest @@ -41,10 +51,18 @@ export function CopilotModalHeader({ onClick: handleClose, }); + const BoundLeftContent = renderSlot(leftContent, CopilotModalHeader.LeftContent, { + onThreadListClick, + showThreadListButton, + onNewThreadClick, + showNewThreadButton, + }); + if (children) { return children({ titleContent: BoundTitle, closeButton: BoundCloseButton, + leftContent: BoundLeftContent, title: resolvedTitle, ...rest, }); @@ -61,7 +79,7 @@ export function CopilotModalHeader({ {...rest} >
- @@ -101,9 +119,71 @@ export namespace CopilotModalHeader {