showcase/shell-docs/src/content/docs/tutorials/multi-conversation-chat.mdx
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.
useThreadsCopilotChat via threadId@copilotkit/react-core v1.50+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 (
<CopilotKit runtimeUrl="/api/copilotkit">
<div className="flex h-screen">
<aside className="w-72 border-r overflow-y-auto">
<ThreadSidebar />
</aside>
<main className="flex-1">
<ChatPanel />
</main>
</div>
</CopilotKit>
);
}
```
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 <div className="p-4 text-sm text-gray-500">Loading...</div>;
}
return (
<div className="flex flex-col">
<button
className="m-3 p-2 rounded bg-blue-600 text-white text-sm"
onClick={() => {
// Clear active thread to start a new conversation
window.dispatchEvent(new CustomEvent("new-thread"));
}}
>
New conversation
</button>
{threads.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
onRename={(name) => renameThread(thread.id, name)}
onArchive={() => archiveThread(thread.id)}
/>
))}
</div>
);
}
```
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 (
<div
className="flex items-center justify-between px-3 py-2 hover:bg-gray-100 cursor-pointer group"
onClick={() => {
window.dispatchEvent(
new CustomEvent("select-thread", { detail: thread.id })
);
}}
>
<div className="flex-1 min-w-0">
{isEditing ? (
<input
className="w-full text-sm border rounded px-1"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => {
onRename(editName);
setIsEditing(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRename(editName);
setIsEditing(false);
}
}}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<div className="text-sm truncate">{displayName}</div>
<div className="text-xs text-gray-400">{timeAgo}</div>
</>
)}
</div>
<div className="hidden group-hover:flex gap-1 ml-2">
<button
className="text-xs text-gray-500 hover:text-gray-700"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
Rename
</button>
<button
className="text-xs text-gray-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
>
Archive
</button>
</div>
</div>
);
}
```
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<string | undefined>();
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 (
<CopilotChat
threadId={activeThreadId}
className="h-full"
/>
);
}
```
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.
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 && (
<button
className="m-3 p-2 text-sm text-gray-500 hover:text-gray-700"
onClick={fetchMoreThreads}
disabled={isFetchingMoreThreads}
>
{isFetchingMoreThreads ? "Loading..." : "Load older conversations"}
</button>
)}
```
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:
includeArchived: true