--- title: "Tutorial: Build a Multi-Conversation Chat App" description: "Build a chat application with persistent conversation threads using useThreads and CopilotChat — create, switch, rename, and archive conversations with realtime sync." icon: "lucide/MessageSquareMore" doc_type: tutorial --- ## What you'll build A chat application with a thread sidebar — similar to ChatGPT or Claude's conversation list. Users can create new conversations, switch between them, rename them, and archive old ones. All thread metadata syncs in realtime across tabs. ## What you'll learn - How to list and manage threads with `useThreads` - How to wire thread selection into `CopilotChat` via `threadId` - How to create new threads by clearing the active thread - How to add rename and archive actions to each thread - How pagination works for users with many conversations ## Prerequisites - Node.js 20+ - A CopilotKit project with the Enterprise Intelligence Platform configured (via [Copilot Cloud](https://dashboard.operations.copilotkit.ai) or [self-hosted](/premium/self-hosting)) - `@copilotkit/react-core` v1.50+ ## Steps ### Scaffold the layout Create a two-panel layout: a sidebar for the thread list on the left, and the chat area on the right. We'll use a simple flexbox layout. ```tsx title="App.tsx" import { CopilotKit } from "@copilotkit/react-core/v2"; export default function App() { return (
); } ```
### Build the thread sidebar Use `useThreads` to fetch the thread list and render it. Each thread shows its name (or "New conversation" if unnamed) and the time it was last updated. ```tsx title="ThreadSidebar.tsx" import { useThreads } from "@copilotkit/react-core/v2"; // [!code highlight] import { useState } from "react"; export function ThreadSidebar() { const { // [!code highlight:5] threads, isLoading, renameThread, archiveThread, } = useThreads({ agentId: "my-agent" }); if (isLoading) { return
Loading...
; } return (
{threads.map((thread) => ( renameThread(thread.id, name)} onArchive={() => archiveThread(thread.id)} /> ))}
); } ```
### Add thread row with actions Each row needs a click handler to select the thread, plus rename and archive actions. We'll use a simple inline editing pattern for rename. ```tsx title="ThreadRow.tsx" import { useState } from "react"; import type { Thread } from "@copilotkit/react-core/v2"; interface ThreadRowProps { thread: Thread; onRename: (name: string) => void; onArchive: () => void; } export function ThreadRow({ thread, onRename, onArchive }: ThreadRowProps) { const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(thread.name ?? ""); const displayName = thread.name || "New conversation"; const timeAgo = new Date(thread.updatedAt).toLocaleDateString(); return (
{ window.dispatchEvent( new CustomEvent("select-thread", { detail: thread.id }) ); }} >
{isEditing ? ( setEditName(e.target.value)} onBlur={() => { onRename(editName); setIsEditing(false); }} onKeyDown={(e) => { if (e.key === "Enter") { onRename(editName); setIsEditing(false); } }} autoFocus onClick={(e) => e.stopPropagation()} /> ) : ( <>
{displayName}
{timeAgo}
)}
); } ```
### Wire up the chat panel The chat panel listens for thread selection events and passes the active `threadId` to `CopilotChat`. When no thread is selected, starting a conversation creates a new thread automatically. ```tsx title="ChatPanel.tsx" import { CopilotChat } from "@copilotkit/react-core/v2"; // [!code highlight] import { useState, useEffect } from "react"; export function ChatPanel() { const [activeThreadId, setActiveThreadId] = useState(); useEffect(() => { const handleSelect = (e: CustomEvent) => setActiveThreadId(e.detail); const handleNew = () => setActiveThreadId(undefined); window.addEventListener("select-thread", handleSelect as EventListener); window.addEventListener("new-thread", handleNew); return () => { window.removeEventListener("select-thread", handleSelect as EventListener); window.removeEventListener("new-thread", handleNew); }; }, []); return ( ); } ``` When `threadId` is `undefined`, the chat starts a fresh conversation. When set to an existing thread ID, it loads that thread's message history and reconnects to any active agent stream. ### Add pagination (optional) If your users accumulate many conversations, add a "Load more" button at the bottom of the sidebar using the `limit` parameter. ```tsx title="ThreadSidebar.tsx" const { threads, isLoading, hasMoreThreads, // [!code highlight] isFetchingMoreThreads, // [!code highlight] fetchMoreThreads, // [!code highlight] renameThread, archiveThread, } = useThreads({ agentId: "my-agent", limit: 25, // [!code highlight] }); // At the bottom of the thread list: {hasMoreThreads && ( )} ```
## What's next You now have a working multi-conversation chat app with persistent threads. Thread names are auto-generated by the LLM after the first message — you'll see them appear in the sidebar automatically. Here are some ideas for extending further: - **Search** — add a search input that filters threads by name - **Unread indicators** — track which threads have new messages since the user last viewed them - **Drag to reorder** — let users pin important threads to the top - **Archive view** — add a toggle to show archived threads using `includeArchived: true` ## Next steps - **Step-by-step guide:** [Threads](/threads) — the concise how-to for thread management - **Understand how it works:** [How Threads & Persistence Work](/premium/threads-explained) — architecture, event replay model, and WebSocket sync - **API reference:** [useThreads](/reference/v2/hooks/useThreads) — parameters, return values, types