diff --git a/apps/docs/content/docs/chat.mdx b/apps/docs/content/docs/chat.mdx index a546a57..d244973 100644 --- a/apps/docs/content/docs/chat.mdx +++ b/apps/docs/content/docs/chat.mdx @@ -85,6 +85,89 @@ Tool renderers receive the tool result `data` as props. Return any React compone --- +## Compound Components + +For full layout control, use the compound component pattern: + +### Basic Structure + +```tsx +import { CopilotChat } from '@yourgpt/copilot-sdk/ui'; + + + {/* Custom home screen */} + +

Welcome!

+ + +
+ + {/* Chat view uses default UI */} + +
+``` + +### Available Components + +| Component | Description | +|-----------|-------------| +| `CopilotChat.Root` | Root container (alias for CopilotChat) | +| `CopilotChat.HomeView` | Shows when no messages (home screen) | +| `CopilotChat.ChatView` | Shows when there are messages | +| `CopilotChat.Header` | Header slot (view-specific when nested) | +| `CopilotChat.Footer` | Footer slot | +| `CopilotChat.Input` | Auto-connected input | +| `CopilotChat.Suggestions` | Suggestion buttons | +| `CopilotChat.BackButton` | Starts new chat (requires persistence) | +| `CopilotChat.ThreadPicker` | Thread switcher (requires persistence) | + +### View-Specific Headers + +Place `Header` inside `ChatView` to show it only in chat view: + +```tsx + + {/* Home - no header */} + +

Welcome!

+ +
+ + {/* Chat - header with navigation */} + + + + Conversation + + + {/* Default messages + input render automatically */} + +
+``` + +### Custom BackButton + +```tsx + + ← Back to Home + +``` + +### Context Hook + +Access chat context in custom components: + +```tsx +import { useCopilotChatContext } from '@yourgpt/copilot-sdk/ui'; + +function CustomComponent() { + const { view, send, isLoading } = useCopilotChatContext(); + return ; +} +``` + +--- + ## Build Your Own Chat Use hooks for full control: diff --git a/apps/docs/content/docs/customizations.mdx b/apps/docs/content/docs/customizations.mdx index 7d1438a..ec6847e 100644 --- a/apps/docs/content/docs/customizations.mdx +++ b/apps/docs/content/docs/customizations.mdx @@ -144,6 +144,13 @@ The SDK exposes semantic CSS classes for advanced theme customization: | `csdk-button-attach` | Attachment button | | `csdk-followup` | Follow-up container | | `csdk-followup-button` | Follow-up buttons | +| `csdk-chat-header` | Compound Header slot | +| `csdk-chat-footer` | Compound Footer slot | +| `csdk-chat-home-view` | Home view container | +| `csdk-chat-view` | Chat view container | +| `csdk-back-button` | Back/New chat button | +| `csdk-compound-input` | Compound Input wrapper | +| `csdk-compound-suggestions` | Compound Suggestions wrapper | ### Example: Custom Theme with Component Styles diff --git a/apps/docs/content/docs/server.mdx b/apps/docs/content/docs/server.mdx index 5d12720..a8fd2dc 100644 --- a/apps/docs/content/docs/server.mdx +++ b/apps/docs/content/docs/server.mdx @@ -39,9 +39,9 @@ The Copilot SDK frontend connects to your backend API endpoint. Your server: ### Response -The SDK supports two response formats: +The SDK supports three response formats: - + Simple text streaming for basic chat (no tools). @@ -67,11 +67,29 @@ The SDK supports two response formats: Use `result.toDataStreamResponse()` to return this format. + + Complete response in a single JSON object. Use for batch processing, logging, or simpler integrations. + + **Content-Type:** `application/json` + + ```json + { + "text": "Hello! How can I help you today?", + "usage": { + "promptTokens": 10, + "completionTokens": 8, + "totalTokens": 18 + } + } + ``` + + Use `generateText()` or `runtime.chat()` to return this format. + --- -## Framework Examples +## Framework Examples (Streaming) @@ -161,57 +179,347 @@ The SDK supports two response formats: --- -## With Tools +## Framework Examples (Non-Streaming) -Add tools to let the AI call functions on your server: +For use cases where you need the complete response before returning (batch processing, logging, simpler integration), use the non-streaming approach. -```ts title="app/api/chat/route.ts" -import { streamText, tool } from '@yourgpt/llm-sdk'; -import { openai } from '@yourgpt/llm-sdk/openai'; -import { z } from 'zod'; +### Response Format -export async function POST(req: Request) { - const { messages } = await req.json(); - - const result = await streamText({ - model: openai('gpt-4o'), - system: 'You are a helpful assistant.', - messages, - tools: { - getWeather: tool({ - description: 'Get current weather for a city', - parameters: z.object({ - city: z.string().describe('City name'), - }), - execute: async ({ city }) => { - const data = await fetchWeatherAPI(city); - return { temperature: data.temp, condition: data.condition }; - }, - }), - searchProducts: tool({ - description: 'Search the product database', - parameters: z.object({ - query: z.string(), - limit: z.number().optional().default(10), - }), - execute: async ({ query, limit }) => { - return await db.products.search(query, limit); - }, - }), - }, - maxSteps: 5, - }); +**Content-Type:** `application/json` - return result.toDataStreamResponse(); +```json +{ + "text": "Hello! How can I help you today?", + "usage": { + "promptTokens": 10, + "completionTokens": 8, + "totalTokens": 18 + } } ``` +### Using generateText + + + + ```ts title="app/api/chat/route.ts" + import { generateText } from '@yourgpt/llm-sdk'; + import { openai } from '@yourgpt/llm-sdk/openai'; + + export async function POST(req: Request) { + const { messages } = await req.json(); + + const result = await generateText({ + model: openai('gpt-4o'), + system: 'You are a helpful assistant.', + messages, + }); + + return Response.json({ + text: result.text, + usage: result.usage, + }); + } + ``` + + + ```ts title="server.ts" + import express from 'express'; + import cors from 'cors'; + import { generateText } from '@yourgpt/llm-sdk'; + import { openai } from '@yourgpt/llm-sdk/openai'; + + const app = express(); + app.use(cors()); + app.use(express.json()); + + app.post('/api/chat', async (req, res) => { + const { messages } = req.body; + + const result = await generateText({ + model: openai('gpt-4o'), + system: 'You are a helpful assistant.', + messages, + }); + + res.json({ + text: result.text, + usage: result.usage, + }); + }); + + app.listen(3001, () => console.log('Server on http://localhost:3001')); + ``` + + + ```ts title="server.ts" + import { createServer } from 'http'; + import { generateText } from '@yourgpt/llm-sdk'; + import { openai } from '@yourgpt/llm-sdk/openai'; + + createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/api/chat') { + const body = await getBody(req); + const { messages } = JSON.parse(body); + + const result = await generateText({ + model: openai('gpt-4o'), + system: 'You are a helpful assistant.', + messages, + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + text: result.text, + usage: result.usage, + })); + } + }).listen(3001); + + function getBody(req: any): Promise { + return new Promise((resolve) => { + let data = ''; + req.on('data', (chunk: any) => data += chunk); + req.on('end', () => resolve(data)); + }); + } + ``` + + + +### Using Runtime chat() + +The runtime also provides a `chat()` method for non-streaming: + + + + ```ts title="app/api/chat/route.ts" + import { createRuntime } from '@yourgpt/llm-sdk'; + import { createOpenAI } from '@yourgpt/llm-sdk/openai'; + + const runtime = createRuntime({ + provider: createOpenAI({ apiKey: process.env.OPENAI_API_KEY }), + model: 'gpt-4o', + systemPrompt: 'You are a helpful assistant.', + }); + + export async function POST(req: Request) { + const body = await req.json(); + + const { text, messages, toolCalls } = await runtime.chat(body); + + return Response.json({ + text, + messages, + toolCalls, + }); + } + ``` + + + ```ts title="server.ts" + import express from 'express'; + import cors from 'cors'; + import { createRuntime } from '@yourgpt/llm-sdk'; + import { createOpenAI } from '@yourgpt/llm-sdk/openai'; + + const app = express(); + app.use(cors()); + app.use(express.json()); + + const runtime = createRuntime({ + provider: createOpenAI({ apiKey: process.env.OPENAI_API_KEY }), + model: 'gpt-4o', + systemPrompt: 'You are a helpful assistant.', + }); + + app.post('/api/chat', async (req, res) => { + const { text, messages, toolCalls } = await runtime.chat(req.body); + + res.json({ + text, + messages, + toolCalls, + }); + }); + + app.listen(3001, () => console.log('Server on http://localhost:3001')); + ``` + + + ```ts title="server.ts" + import { createServer } from 'http'; + import { createRuntime } from '@yourgpt/llm-sdk'; + import { createOpenAI } from '@yourgpt/llm-sdk/openai'; + + const runtime = createRuntime({ + provider: createOpenAI({ apiKey: process.env.OPENAI_API_KEY }), + model: 'gpt-4o', + systemPrompt: 'You are a helpful assistant.', + }); + + createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/api/chat') { + const body = await getBody(req); + const chatRequest = JSON.parse(body); + + const { text, messages, toolCalls } = await runtime.chat(chatRequest); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ text, messages, toolCalls })); + } + }).listen(3001); + + function getBody(req: any): Promise { + return new Promise((resolve) => { + let data = ''; + req.on('data', (chunk: any) => data += chunk); + req.on('end', () => resolve(data)); + }); + } + ``` + + + +### Using stream().collect() + +You can also collect a stream into a single response: + +```ts +app.post('/api/chat', async (req, res) => { + const { text, messages, toolCalls } = await runtime.stream(req.body).collect(); + + res.json({ text, messages, toolCalls }); +}); +``` + -Use `toDataStreamResponse()` when using tools to stream structured events including tool calls and results. +**When to use non-streaming:** +- Background processing or batch operations +- When you need the full response before taking action +- Simpler integration without SSE handling +- Logging or analytics that need complete responses --- +## With Tools + +Add tools to let the AI call functions on your server: + + + + ```ts title="app/api/chat/route.ts" + import { streamText, tool } from '@yourgpt/llm-sdk'; + import { openai } from '@yourgpt/llm-sdk/openai'; + import { z } from 'zod'; + + export async function POST(req: Request) { + const { messages } = await req.json(); + + const result = await streamText({ + model: openai('gpt-4o'), + system: 'You are a helpful assistant.', + messages, + tools: { + getWeather: tool({ + description: 'Get current weather for a city', + parameters: z.object({ + city: z.string().describe('City name'), + }), + execute: async ({ city }) => { + const data = await fetchWeatherAPI(city); + return { temperature: data.temp, condition: data.condition }; + }, + }), + searchProducts: tool({ + description: 'Search the product database', + parameters: z.object({ + query: z.string(), + limit: z.number().optional().default(10), + }), + execute: async ({ query, limit }) => { + return await db.products.search(query, limit); + }, + }), + }, + maxSteps: 5, + }); + + return result.toDataStreamResponse(); + } + ``` + + + Use `toDataStreamResponse()` when using tools to stream structured events including tool calls and results. + + + + ```ts title="app/api/chat/route.ts" + import { generateText, tool } from '@yourgpt/llm-sdk'; + import { openai } from '@yourgpt/llm-sdk/openai'; + import { z } from 'zod'; + + export async function POST(req: Request) { + const { messages } = await req.json(); + + const result = await generateText({ + model: openai('gpt-4o'), + system: 'You are a helpful assistant.', + messages, + tools: { + getWeather: tool({ + description: 'Get current weather for a city', + parameters: z.object({ + city: z.string().describe('City name'), + }), + execute: async ({ city }) => { + const data = await fetchWeatherAPI(city); + return { temperature: data.temp, condition: data.condition }; + }, + }), + searchProducts: tool({ + description: 'Search the product database', + parameters: z.object({ + query: z.string(), + limit: z.number().optional().default(10), + }), + execute: async ({ query, limit }) => { + return await db.products.search(query, limit); + }, + }), + }, + maxSteps: 5, + }); + + return Response.json({ + text: result.text, + toolCalls: result.toolCalls, + toolResults: result.toolResults, + usage: result.usage, + }); + } + ``` + + The response includes all tool calls and results: + + ```json + { + "text": "The weather in Tokyo is 22°C and sunny.", + "toolCalls": [ + { "id": "call_123", "name": "getWeather", "args": { "city": "Tokyo" } } + ], + "toolResults": [ + { "toolCallId": "call_123", "result": { "temperature": 22, "condition": "sunny" } } + ], + "usage": { "promptTokens": 50, "completionTokens": 25, "totalTokens": 75 } + } + ``` + + + +--- + ## Runtime API (Advanced) For more control over the server, use `createRuntime()` instead of `streamText()`: diff --git a/examples/nextjs-demo/app/compound-test/page.tsx b/examples/nextjs-demo/app/compound-test/page.tsx new file mode 100644 index 0000000..6641594 --- /dev/null +++ b/examples/nextjs-demo/app/compound-test/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { CopilotProvider } from "@yourgpt/copilot-sdk/react"; +import { CopilotChat, useCopilotChatContext } from "@yourgpt/copilot-sdk/ui"; +import { + Sparkles, + BarChart3, + PenLine, + Lightbulb, + Code2, + Bot, + MessageSquare, + ChevronLeft, +} from "lucide-react"; + +// Suggestion card component +function SuggestionCard({ + icon, + title, + description, + message, +}: { + icon: React.ReactNode; + title: string; + description: string; + message: string; +}) { + const { send } = useCopilotChatContext(); + + return ( + + ); +} + +// Wrapper for suggestion cards (needs context) +function SuggestionCards() { + return ( +
+ } + title="Analyze my data" + description="Get insights from your files" + message="Help me analyze my data and get insights" + /> + } + title="Help me write" + description="Draft emails, documents, and more" + message="Help me write a professional email" + /> + } + title="Brainstorm ideas" + description="Creative solutions for any challenge" + message="Help me brainstorm ideas for my project" + /> + } + title="Write some code" + description="Build features, fix bugs, explain code" + message="Help me write code for a new feature" + /> +
+ ); +} + +// Custom header component +function CustomHeader({ + title, + icon, +}: { + title: string; + icon: React.ReactNode; +}) { + return ( +
+ {icon} + {title} +
+ ); +} + +// Custom footer component +function CustomFooter() { + return ( +
+ Powered by AI • Built with Copilot SDK +
+ ); +} + +export default function CompoundTestPage() { + return ( +
+
+

Compound Components Test

+

+ Layout Composition Patterns +

+
+ +
+ {/* Default CopilotChat (backward compatible) */} +
+
+ Default +
+ + + +
+ + {/* Custom Home with Compound Components (Legacy Pattern) */} +
+
+ Custom Home (Legacy) +
+ + + + {/* Brand Logo */} +
+ +
+ + {/* Heading */} +
+

+ How can I help you today? +

+

+ Your AI assistant for everything +

+
+ + {/* Input */} + + + {/* Suggestion Cards */} + +
+
+
+
+ + {/* View-Specific Header with Navigation (New Pattern) */} +
+
+ View-Specific Header +
+ + + {/* Home View - no header, clean welcome */} + +
+ +
+
+

Welcome!

+

+ Start a conversation with AI +

+
+ + +
+ + {/* Chat View - header ONLY shows here (by composition!) */} + + + + + +
+ Chats + +
+
+ {/* Default messages + input render automatically */} +
+ + {/* Shared Footer - shows in both views */} + + + +
+
+
+
+ +
+ Click a suggestion or type a message to see the chat view transition +
+
+ ); +} diff --git a/examples/nextjs-demo/app/page.tsx b/examples/nextjs-demo/app/page.tsx index ce58135..14645a6 100644 --- a/examples/nextjs-demo/app/page.tsx +++ b/examples/nextjs-demo/app/page.tsx @@ -11,6 +11,11 @@ const demos = [ href: "/providers", description: "OpenAI, Anthropic, Google side-by-side", }, + { + name: "Compound Components", + href: "/compound-test", + description: "Custom home screen with Chat.Home, Chat.Input", + }, { name: "Ticketing Demo", href: "/ticketing-demo", diff --git a/packages/copilot-sdk/package.json b/packages/copilot-sdk/package.json index d0e1227..5e7f285 100644 --- a/packages/copilot-sdk/package.json +++ b/packages/copilot-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@yourgpt/copilot-sdk", - "version": "1.3.0", + "version": "1.4.1", "description": "Build AI copilots with app context awareness", "type": "module", "exports": { diff --git a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx index 5725775..2e1b306 100644 --- a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx +++ b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx @@ -62,6 +62,8 @@ export interface CopilotProviderProps { onError?: (error: Error) => void; /** Enable/disable streaming (default: true) */ streaming?: boolean; + /** Custom headers to send with each request */ + headers?: Record; /** Enable debug logging */ debug?: boolean; /** Max tool execution iterations (default: 20) */ @@ -150,6 +152,7 @@ export function CopilotProvider({ onMessagesChange, onError, streaming, + headers, debug = false, maxIterations, maxIterationsMessage, @@ -207,6 +210,7 @@ export function CopilotProvider({ threadId, initialMessages: uiInitialMessages, streaming, + headers, debug, maxIterations, maxIterationsMessage, diff --git a/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx b/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx index 1710762..0ce0c1f 100644 --- a/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx +++ b/packages/copilot-sdk/src/ui/components/composed/chat/chat.tsx @@ -1,6 +1,13 @@ "use client"; -import React, { useState, useCallback, useRef, useId } from "react"; +import React, { + useState, + useCallback, + useRef, + useId, + createContext, + useContext, +} from "react"; import { cn } from "../../../lib/utils"; import { ChatContainerRoot, @@ -17,14 +24,420 @@ import { } from "../../ui/prompt-input"; import { Loader } from "../../ui/loader"; import { Button } from "../../ui/button"; -import { StopIcon, PlusIcon, ArrowUpIcon, XIcon } from "../../icons"; +import { + StopIcon, + PlusIcon, + ArrowUpIcon, + XIcon, + ChevronLeftIcon, +} from "../../icons"; import CopilotSDKLogo from "../../icons/copilot-sdk-logo"; import { ChatHeader } from "./chat-header"; import { Suggestions } from "./suggestions"; import { DefaultMessage } from "./default-message"; import { ChatWelcome } from "./chat-welcome"; -import type { ChatProps, PendingAttachment } from "./types"; +import type { ChatProps, PendingAttachment, MessageAttachment } from "./types"; import type { ToolExecutionData } from "../tools"; +import type { Thread } from "../../../../core/types/thread"; +import { ThreadPicker, type ThreadPickerProps } from "../../ui/thread-picker"; + +// ============================================================================ +// Internal Context for Compound Components +// ============================================================================ + +interface CopilotChatInternalContext { + view: "home" | "chat"; + send: (message: string, attachments?: MessageAttachment[]) => void; + isLoading: boolean; + onStop?: () => void; + attachmentsEnabled: boolean; + placeholder: string; + // Thread management + onNewChat?: () => void; + threads?: Thread[]; + currentThreadId?: string | null; + onSwitchThread?: (id: string) => void; + onDeleteThread?: (id: string) => void; + isThreadBusy?: boolean; +} + +const CopilotChatContext = createContext( + null, +); + +/** + * Hook to access CopilotChat internal context. + * Must be used within CopilotChat compound components. + */ +export const useCopilotChatContext = () => { + const ctx = useContext(CopilotChatContext); + if (!ctx) { + throw new Error( + "useCopilotChatContext must be used within CopilotChat. " + + "Make sure you're using CopilotChat.Home, CopilotChat.Input, etc. inside ", + ); + } + return ctx; +}; + +// ============================================================================ +// Compound Components +// ============================================================================ + +/** + * HomeView slot - renders only when there are no messages (home view). + * Use this to create a custom welcome/home screen. + */ +export interface HomeViewProps { + children: React.ReactNode; + className?: string; +} + +function HomeView({ children, className }: HomeViewProps) { + const { view } = useCopilotChatContext(); + if (view !== "home") return null; + return ( +
+
+ {children} +
+
+ ); +} + +// Alias for backward compatibility +export type { HomeViewProps as HomeProps }; +const Home = HomeView; + +/** + * ChatView slot - renders only when there are messages (chat view). + * Use this for custom chat UI layouts. If no children, renders default chat UI. + * + * When Header/Footer are placed inside ChatView (instead of at root level), + * they only show in chat view - view-specific by composition! + * + * @example View-specific header + * ```tsx + * + * Only shows in chat view! + * + * ``` + */ +export interface ChatViewProps { + children?: React.ReactNode; + className?: string; +} + +function ChatView({ children, className }: ChatViewProps) { + const { view } = useCopilotChatContext(); + if (view !== "chat") return null; + + // If children provided, render them in a minimal wrapper (no flex-1, user controls layout) + if (children) { + return ( +
+ {children} +
+ ); + } + + // Marker for parent to render default chat content + return null; +} + +// Internal marker to identify ChatView without children +ChatView.displayName = "ChatView"; + +/** + * Check if ChatView children consist only of Header/Footer components. + * If so, we should still render default chat content alongside them. + */ +function chatViewHasOnlyLayoutChildren( + chatViewElement: React.ReactElement | undefined, +): boolean { + if (!chatViewElement?.props?.children) return false; + + const childArray = React.Children.toArray(chatViewElement.props.children); + if (childArray.length === 0) return false; + + // Check if ALL children are Header or Footer + return childArray.every( + (child) => + React.isValidElement(child) && + (child.type === Header || child.type === Footer), + ); +} + +/** + * Header slot - renders header content. + * Can be placed at root level (shows in both views) or inside HomeView/ChatView (view-specific). + */ +export interface HeaderProps { + children: React.ReactNode; + className?: string; +} + +function Header({ children, className }: HeaderProps) { + return
{children}
; +} + +/** + * Footer slot - renders footer content. + * Can be placed at root level (shows in both views) or inside HomeView/ChatView (view-specific). + */ +export interface FooterProps { + children: React.ReactNode; + className?: string; +} + +function Footer({ children, className }: FooterProps) { + return
{children}
; +} + +/** + * Input component that auto-connects to CopilotChat context. + * Handles sending messages without manual wiring. + */ +export interface InputProps { + placeholder?: string; + className?: string; +} + +function Input({ placeholder: placeholderProp, className }: InputProps) { + const { + send, + isLoading, + onStop, + placeholder: defaultPlaceholder, + } = useCopilotChatContext(); + const [value, setValue] = useState(""); + + const handleSubmit = useCallback(() => { + if (value.trim() && !isLoading) { + send(value.trim()); + setValue(""); + } + }, [value, isLoading, send]); + + return ( + + + + + {isLoading ? ( + + ) : ( + + )} + + + + ); +} + +/** + * Suggestions component that auto-connects to CopilotChat context. + * Clicking a suggestion sends it as a message. + */ +export interface SuggestionsCompoundProps { + items: string[]; + label?: string; + className?: string; + buttonClassName?: string; +} + +function SuggestionsCompound({ + items, + label, + className, + buttonClassName, +}: SuggestionsCompoundProps) { + const { send } = useCopilotChatContext(); + + if (items.length === 0) return null; + + return ( +
+ {label && ( + + {label} + + )} +
+ {items.map((item, i) => ( + + ))} +
+
+ ); +} + +/** + * BackButton component that starts a new chat and returns to home view. + * Auto-connects to CopilotChat context for thread management. + */ +export interface BackButtonProps { + className?: string; + children?: React.ReactNode; + /** Override disabled state (combines with isThreadBusy from context) */ + disabled?: boolean; + /** Accessible label for screen readers */ + "aria-label"?: string; +} + +function BackButton({ + className, + children, + disabled, + "aria-label": ariaLabel = "Start new chat", +}: BackButtonProps) { + const { onNewChat, isThreadBusy } = useCopilotChatContext(); + + if (!onNewChat) return null; + + return ( + + ); +} + +/** + * ThreadPicker compound wrapper that auto-connects to CopilotChat context. + * Only renders when persistence is enabled (thread functions available). + */ +export type ThreadPickerCompoundProps = Omit< + ThreadPickerProps, + | "value" + | "threads" + | "onSelect" + | "onNewThread" + | "onDeleteThread" + | "disabled" +>; + +function ThreadPickerCompound(props: ThreadPickerCompoundProps) { + const { + threads, + currentThreadId, + onSwitchThread, + onNewChat, + onDeleteThread, + isThreadBusy, + } = useCopilotChatContext(); + + // Only render if persistence is enabled (thread functions available) + if (!threads || !onSwitchThread) return null; + + return ( + + ); +} + +// ============================================================================ +// Helper to detect compound children +// ============================================================================ + +function hasCompoundChild( + children: React.ReactNode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...components: React.ComponentType[] +): boolean { + return React.Children.toArray(children).some( + (child) => + React.isValidElement(child) && + components.includes(child.type as React.ComponentType), + ); +} + +/** + * Find a specific compound child by type + */ +function findCompoundChild( + children: React.ReactNode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: React.ComponentType, +): React.ReactElement | undefined { + return React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === component, + ) as React.ReactElement | undefined; +} + +/** + * Filter compound children by types + */ +function filterCompoundChildren( + children: React.ReactNode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...components: React.ComponentType[] +): React.ReactElement[] { + return React.Children.toArray(children).filter( + (child) => + React.isValidElement(child) && + components.includes(child.type as React.ComponentType), + ) as React.ReactElement[]; +} // Constants const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB @@ -67,12 +480,14 @@ function generateAttachmentId(): string { return `att_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } -export function Chat({ +function ChatComponent({ // Core messages = [], onSendMessage, onStop, isLoading = false, + // Compound children + children, // Labels placeholder = "Type a message...", welcomeMessage, @@ -124,6 +539,12 @@ export function Chat({ // Styling className, classNames = {}, + // Thread management for compound components + onNewChat, + threads, + currentThreadId, + onSwitchThread, + isThreadBusy, }: ChatProps) { const [input, setInput] = useState(""); const [pendingAttachments, setPendingAttachments] = useState< @@ -324,387 +745,548 @@ export function Chat({ // Compute accept string from allowed types const acceptString = allowedFileTypes.join(","); - // Determine if we should show the welcome screen - const showWelcome = messages.length === 0 && welcome !== false; + // Determine view state + const view = messages.length === 0 ? "home" : "chat"; + + // Check if user provided custom compound components + const hasCustomHome = hasCompoundChild(children, Home, HomeView); + const hasCustomChatView = hasCompoundChild(children, ChatView); + const hasCustomLayout = hasCustomHome || hasCustomChatView; + + // Extract root-level Header/Footer (shown in both views) + const rootHeader = findCompoundChild(children, Header); + const rootFooter = findCompoundChild(children, Footer); + + // Get view-specific children + const viewChildren = filterCompoundChildren( + children, + HomeView, + Home, + ChatView, + ); + + // Check if ChatView has no children or only Header/Footer children (should render default) + const chatViewElement = findCompoundChild(children, ChatView); + const chatViewNeedsDefault = + chatViewElement && + (!chatViewElement.props.children || + chatViewHasOnlyLayoutChildren(chatViewElement)); + + // Determine if we should show the default welcome screen + const showDefaultWelcome = + view === "home" && !hasCustomHome && welcome !== false; // Get welcome config (could be object or undefined/true) const welcomeConfig = typeof welcome === "object" ? welcome : undefined; + // Stable send function reference + const send = useCallback( + (message: string, attachments?: MessageAttachment[]) => { + onSendMessage?.(message, attachments); + }, + [onSendMessage], + ); + + // Context value for compound components (memoized to prevent unnecessary re-renders) + const contextValue: CopilotChatInternalContext = React.useMemo( + () => ({ + view, + send, + isLoading, + onStop, + attachmentsEnabled, + placeholder, + // Thread management - passed from connected-chat + onNewChat, + threads, + currentThreadId, + onSwitchThread, + onDeleteThread, + isThreadBusy, + }), + [ + view, + send, + isLoading, + onStop, + attachmentsEnabled, + placeholder, + onNewChat, + threads, + currentThreadId, + onSwitchThread, + onDeleteThread, + isThreadBusy, + ], + ); + return ( -
- {/* Drag overlay */} - {isDragging && ( -
-
- Drop files here + +
+ {/* Drag overlay */} + {isDragging && ( +
+
+ Drop files here +
-
- )} - {/* Header */} - {showHeader && - (renderHeader ? ( - renderHeader() - ) : ( - + ))} + + {/* Root-level custom Header (shows in both views) */} + {rootHeader} + + {/* Custom compound children - view components self-filter based on current view */} + {hasCustomLayout && viewChildren} + + {showDefaultWelcome ? ( + /* Default Welcome Screen (centered input) */ + + onSendMessage?.(msg, attachments) + } + onSelectThread={onSelectThread} + onDeleteThread={onDeleteThread} + onViewMoreThreads={onViewMoreThreads} + isLoading={isLoading} + onStop={onStop} + placeholder={placeholder} + attachmentsEnabled={attachmentsEnabled} + attachmentsDisabledTooltip={attachmentsDisabledTooltip} + maxFileSize={maxFileSize} + allowedFileTypes={allowedFileTypes} + processAttachment={processAttachmentProp} /> - ))} + ) : null} - {showWelcome ? ( - /* Welcome Screen (centered input) */ - - onSendMessage?.(msg, attachments) - } - onSelectThread={onSelectThread} - onDeleteThread={onDeleteThread} - onViewMoreThreads={onViewMoreThreads} - isLoading={isLoading} - onStop={onStop} - placeholder={placeholder} - attachmentsEnabled={attachmentsEnabled} - attachmentsDisabledTooltip={attachmentsDisabledTooltip} - maxFileSize={maxFileSize} - allowedFileTypes={allowedFileTypes} - processAttachment={processAttachmentProp} - /> - ) : ( - /* Normal Chat UI (messages + input at bottom) */ - <> - {/* Messages */} - - + {/* Messages */} + - {/* Welcome message */} - {messages.length === 0 && ( -
- {welcomeMessage || "Send a message to start the conversation"} -
- )} - - {/* Messages */} - {messages.map((message, index) => { - const isLastMessage = index === messages.length - 1; - const isEmptyAssistant = - message.role === "assistant" && !message.content?.trim(); - - // Check if message has tool_calls or toolExecutions - const hasToolCalls = - message.tool_calls && message.tool_calls.length > 0; - const hasToolExecutions = - message.toolExecutions && message.toolExecutions.length > 0; - - // Check if this message has pending tool approvals - const hasPendingApprovals = message.toolExecutions?.some( - (exec) => exec.approvalStatus === "required", - ); - - if (isEmptyAssistant) { - if (hasToolCalls || hasToolExecutions) { - // Has tools - continue to render - } else if (isLastMessage && hasPendingApprovals) { - // Has pending approvals - continue to render - } else if (isLastMessage && isLoading && !isProcessing) { - // Show streaming loader - return ( - - - ) : undefined - } - className="bg-background" - /> -
- -
-
- ); - } else { - // Hide empty assistant messages - return null; - } - } - - // Check for saved executions in metadata (historical) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const savedExecutions = (message as any).metadata - ?.toolExecutions as ToolExecutionData[] | undefined; - const messageToolExecutions = - message.toolExecutions || savedExecutions; - - const messageWithExecutions = messageToolExecutions - ? { ...message, toolExecutions: messageToolExecutions } - : message; - - // Handle follow-up click - use onSendMessage if available - const handleFollowUpClick = (question: string) => { - if (onSuggestionClick) { - onSuggestionClick(question); - } else { - onSendMessage?.(question); - } - }; - - return renderMessage ? ( - - {renderMessage(messageWithExecutions, index)} - - ) : ( - - ); - })} - - {/* "Continuing..." loader - shown after tool completion while waiting for server */} - {isProcessing && ( - - - ) : undefined - } - className="bg-background" - /> -
- - - Continuing... - + + {/* Welcome message */} + {messages.length === 0 && ( +
+ {welcomeMessage || + "Send a message to start the conversation"}
- - )} - - {/* Loading indicator for non-streaming - when last message is user and waiting for response */} - {isLoading && - !isProcessing && - (() => { - const lastMessage = messages[messages.length - 1]; - // Show loader if last message is from user (non-streaming doesn't create empty assistant message) - if (lastMessage?.role === "user") { - return ( - - - ) : undefined - } - className="bg-background" - /> -
- -
-
- ); + )} + + {/* Messages */} + {messages.map((message, index) => { + const isLastMessage = index === messages.length - 1; + const isEmptyAssistant = + message.role === "assistant" && !message.content?.trim(); + + // Check if message has tool_calls or toolExecutions + const hasToolCalls = + message.tool_calls && message.tool_calls.length > 0; + const hasToolExecutions = + message.toolExecutions && message.toolExecutions.length > 0; + + // Check if this message has pending tool approvals + const hasPendingApprovals = message.toolExecutions?.some( + (exec) => exec.approvalStatus === "required", + ); + + if (isEmptyAssistant) { + if (hasToolCalls || hasToolExecutions) { + // Has tools - continue to render + } else if (isLastMessage && hasPendingApprovals) { + // Has pending approvals - continue to render + } else if (isLastMessage && isLoading && !isProcessing) { + // Show streaming loader + return ( + + + ) : undefined + } + className="bg-background" + /> +
+ +
+
+ ); + } else { + // Hide empty assistant messages + return null; + } } - return null; - })()} - -
+ // Check for saved executions in metadata (historical) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const savedExecutions = (message as any).metadata + ?.toolExecutions as ToolExecutionData[] | undefined; + const messageToolExecutions = + message.toolExecutions || savedExecutions; - {/* Scroll to bottom button */} -
- -
- - - {/* Suggestions */} - {suggestions.length > 0 && !isLoading && ( - - )} + const messageWithExecutions = messageToolExecutions + ? { ...message, toolExecutions: messageToolExecutions } + : message; - {/* Input */} - {renderInput ? ( - renderInput() - ) : ( -
- {/* Pending Attachments Preview */} - {pendingAttachments.length > 0 && ( -
- {pendingAttachments.map((att) => ( -
- {att.attachment.type === "image" ? ( - {att.file.name} - ) : ( -
- - - - - {att.file.name.length > 10 - ? att.file.name.slice(0, 8) + "..." - : att.file.name} - -
- )} - {/* Loading overlay */} - {att.status === "processing" && ( -
- -
- )} - {/* Error overlay */} - {att.status === "error" && ( -
- - Error - -
- )} - {/* Remove button */} - -
- ))} -
- )} - - - - -
- { + if (onSuggestionClick) { + onSuggestionClick(question); + } else { + onSendMessage?.(question); + } + }; + + return renderMessage ? ( + + {renderMessage(messageWithExecutions, index)} + + ) : ( + + ); + })} + + {/* "Continuing..." loader - shown after tool completion while waiting for server */} + {isProcessing && ( + + + ) : undefined } - > - + )} + + {/* Loading indicator for non-streaming - when last message is user and waiting for response */} + {isLoading && + !isProcessing && + (() => { + const lastMessage = messages[messages.length - 1]; + // Show loader if last message is from user (non-streaming doesn't create empty assistant message) + if (lastMessage?.role === "user") { + return ( + + + ) : undefined + } + className="bg-background" + /> +
+ +
+
+ ); + } + return null; + })()} + + + + + {/* Scroll to bottom button */} +
+ +
+ + + {/* Suggestions */} + {suggestions.length > 0 && !isLoading && ( + + )} + + {/* Input */} + {renderInput ? ( + renderInput() + ) : ( +
+ {/* Pending Attachments Preview */} + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((att) => ( +
+ {att.attachment.type === "image" ? ( + {att.file.name} + ) : ( +
+ + + + + {att.file.name.length > 10 + ? att.file.name.slice(0, 8) + "..." + : att.file.name} + +
)} - > - - - - + {/* Loading overlay */} + {att.status === "processing" && ( +
+ +
+ )} + {/* Error overlay */} + {att.status === "error" && ( +
+ + Error + +
+ )} + {/* Remove button */} + +
+ ))}
- - {isLoading ? ( - - ) : ( - - )} - - - -
- )} - - )} -
+ + +
+ + {isLoading ? ( + + ) : ( + + )} + + + +
+ )} + + )} + + {/* Root-level custom Footer (shows in both views) */} + {rootFooter} +
+ ); } + +// ============================================================================ +// Attach Compound Components & Export +// ============================================================================ + +/** + * Chat component with compound component pattern. + * + * @example Default usage (backward compatible) + * ```tsx + * + * ``` + * + * @example Custom home screen with compound components + * ```tsx + * + * + *

Welcome!

+ * + * + *
+ *
+ * ``` + * + * @example Full layout composition (new pattern) + * ```tsx + * + * Custom Header + * + *

Welcome!

+ * + *
+ * + * Custom Footer + *
+ * ``` + * + * @example View-specific header with navigation (new pattern) + * ```tsx + * + * + *

Welcome!

+ *
+ * + * + * + * + * + * + *
+ * ``` + */ +export const Chat = Object.assign(ChatComponent, { + Root: ChatComponent, // Alias for layout composition pattern + Home, // Backward compat alias + HomeView, // New name + ChatView, + Header, + Footer, + Input, + Suggestions: SuggestionsCompound, + BackButton, // Navigation: start new chat + ThreadPicker: ThreadPickerCompound, // Thread switching +}); + +// Re-export compound components for direct access and TypeScript declarations +export { + HomeView, + Home, + ChatView, + Header, + Footer, + Input as ChatInput, + SuggestionsCompound as ChatSuggestions, + BackButton, + ThreadPickerCompound as ChatThreadPicker, +}; diff --git a/packages/copilot-sdk/src/ui/components/composed/chat/index.ts b/packages/copilot-sdk/src/ui/components/composed/chat/index.ts index a12a9b5..8287281 100644 --- a/packages/copilot-sdk/src/ui/components/composed/chat/index.ts +++ b/packages/copilot-sdk/src/ui/components/composed/chat/index.ts @@ -1,4 +1,13 @@ -export { Chat } from "./chat"; +export { Chat, useCopilotChatContext } from "./chat"; +export type { + HomeViewProps, + HomeProps, + ChatViewProps, + HeaderProps, + FooterProps, + BackButtonProps, + ThreadPickerCompoundProps, +} from "./chat"; export { ChatHeader } from "./chat-header"; export { ChatWelcome } from "./chat-welcome"; export { Suggestions } from "./suggestions"; diff --git a/packages/copilot-sdk/src/ui/components/composed/chat/types.ts b/packages/copilot-sdk/src/ui/components/composed/chat/types.ts index 811e344..eb52d65 100644 --- a/packages/copilot-sdk/src/ui/components/composed/chat/types.ts +++ b/packages/copilot-sdk/src/ui/components/composed/chat/types.ts @@ -214,6 +214,13 @@ export type ChatProps = { /** Whether AI is currently generating */ isLoading?: boolean; + // === Compound Components === + /** + * Children for compound component pattern. + * Use Chat.Home, Chat.Input, Chat.Suggestions as children for custom home screens. + */ + children?: React.ReactNode; + // === Labels/Text === /** Placeholder text for input */ placeholder?: string; @@ -382,4 +389,16 @@ export type ChatProps = { suggestions?: string; footer?: string; }; + + // === Thread Management (for compound components) === + /** Called when starting a new chat (clears messages, returns to home) */ + onNewChat?: () => void; + /** Available threads (passed from connected-chat when persistence enabled) */ + threads?: Thread[]; + /** Current thread ID */ + currentThreadId?: string | null; + /** Called when switching to a different thread */ + onSwitchThread?: (threadId: string) => void; + /** Whether a thread operation is in progress (disables controls) */ + isThreadBusy?: boolean; }; diff --git a/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx b/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx index ef1b375..76efc00 100644 --- a/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx +++ b/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx @@ -291,13 +291,16 @@ function parsePersistenceConfig( }; } -export function CopilotChat(props: CopilotChatProps) { +function CopilotChatBase( + props: CopilotChatProps & { children?: React.ReactNode }, +) { const { persistence, showThreadPicker = false, onThreadChange, classNames, header, + children, ...chatProps } = props; @@ -563,10 +566,33 @@ export function CopilotChat(props: CopilotChatProps) { recentThreads={isPersistenceEnabled ? threadManager.threads : undefined} onSelectThread={isPersistenceEnabled ? handleSwitchThread : undefined} onDeleteThread={isPersistenceEnabled ? handleDeleteThread : undefined} - /> + // Thread management for compound components + onNewChat={handleNewThread} + threads={isPersistenceEnabled ? threadManager.threads : undefined} + currentThreadId={threadManager.currentThreadId} + onSwitchThread={isPersistenceEnabled ? handleSwitchThread : undefined} + isThreadBusy={isBusy} + > + {children} +
); } +// Attach compound components from Chat to CopilotChat +// This allows: , , , etc. +export const CopilotChat = Object.assign(CopilotChatBase, { + Root: CopilotChatBase, // Alias for layout composition pattern + Home: Chat.Home, // Backward compat alias + HomeView: Chat.HomeView, // New name + ChatView: Chat.ChatView, + Header: Chat.Header, + Footer: Chat.Footer, + Input: Chat.Input, + Suggestions: Chat.Suggestions, + BackButton: Chat.BackButton, // Navigation: start new chat + ThreadPicker: Chat.ThreadPicker, // Thread switching +}); + // Alias for backwards compatibility export const ConnectedChat = CopilotChat; export type ConnectedChatProps = CopilotChatProps; diff --git a/packages/copilot-sdk/src/ui/components/composed/index.ts b/packages/copilot-sdk/src/ui/components/composed/index.ts index ad2b76b..8e317c9 100644 --- a/packages/copilot-sdk/src/ui/components/composed/index.ts +++ b/packages/copilot-sdk/src/ui/components/composed/index.ts @@ -5,6 +5,16 @@ export { Suggestions, DefaultMessage, ToolExecutionMessage, + // Compound component hook + useCopilotChatContext, + // Compound component types + type HomeViewProps, + type HomeProps, + type ChatViewProps, + type HeaderProps, + type FooterProps, + type BackButtonProps, + type ThreadPickerCompoundProps, type ChatProps, type ChatMessage, type ToolRendererProps, diff --git a/packages/copilot-sdk/src/ui/components/icons/index.tsx b/packages/copilot-sdk/src/ui/components/icons/index.tsx index 1f66437..d9b0f29 100644 --- a/packages/copilot-sdk/src/ui/components/icons/index.tsx +++ b/packages/copilot-sdk/src/ui/components/icons/index.tsx @@ -85,6 +85,23 @@ export function ChevronUpIcon({ className }: { className?: string }) { ); } +export function ChevronLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} + export function CopyIcon({ className }: { className?: string }) { return (