AI
ChatWithThreads
Wraps Chat with a thread-list sidebar for multi-conversation apps — a persistent column on wide viewports, a Drawer overlay on narrow ones, both driven by a CSS breakpoint rather than JS viewport tracking.
ChatWithThreadsPreview
Loading demo
Usage
tsx
import { useState } from "react";
import { ChatWithThreads } from "@vesture/react";
import type { ChatThread } from "@vesture/react";
function Example() {
const [threads, setThreads] = useState<ChatThread[]>([
{ id: "t1", title: "Getting started", messages: [], updatedAt: new Date() },
]);
const [activeThreadId, setActiveThreadId] = useState("t1");
return (
<ChatWithThreads
threads={threads}
activeThreadId={activeThreadId}
onThreadChange={setActiveThreadId}
onSendMessage={(content) =>
setThreads((prev) =>
prev.map((t) =>
t.id === activeThreadId
? { ...t, messages: [...t.messages, { id: crypto.randomUUID(), role: "user", content }] }
: t
)
)
}
onNewThread={() => {
const id = crypto.randomUUID();
setThreads((prev) => [{ id, title: "New conversation", messages: [], updatedAt: new Date() }, ...prev]);
setActiveThreadId(id);
}}
onDeleteThread={(id) => setThreads((prev) => prev.filter((t) => t.id !== id))}
onRenameThread={(id, title) =>
setThreads((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t)))
}
/>
);
}Behavior
- Fully controlled, same philosophy as Chat and every other component here: threads, activeThreadId, plus every ChatProps field except messages pass straight through. ChatWithThreads never persists anything itself — no localStorage, no backend.
- The active thread's messages are derived internally from threads.find((t) => t.id === activeThreadId) — you never pass messages directly, only the full threads array.
- New/Delete/Rename affordances only render when the matching onNewThread/onDeleteThread/onRenameThread callback is supplied — omit one to make that action unavailable rather than a no-op button.
- Rename is inline: click the pencil icon next to a thread, edit its title in place, Enter commits via onRenameThread, Escape cancels without calling it.
- The sidebar's thread list renders identically whether it's showing in the persistent column or inside the mobile Drawer — the same list content both times, with visibility toggled purely by a 768px CSS media query, so there's nothing that can drift out of sync between the two.
Props
ChatThread
| Prop | Type | Default | Description |
|---|---|---|---|
| id / title | string | — | Required. |
| messages | ChatMessage[] | — | This thread's own message history. |
| updatedAt | Date | — | Not read by ChatWithThreads itself — yours to use for sorting the list, if desired. |
ChatWithThreadsProps
| Prop | Type | Default | Description |
|---|---|---|---|
| threads | ChatThread[] | — | Required. |
| activeThreadId | string | — | Required. Which thread's messages are currently shown. |
| onThreadChange | (id: string) => void | — | Required. Fires when a thread in the sidebar is clicked. |
| onNewThread / onDeleteThread / onRenameThread | () => void / (id) => void / (id, title) => void | — | Gate the matching sidebar affordance's visibility (see behavior above). |
| sidebarTitle | string | "Threads" | Sidebar heading. |
| ...chatProps | Omit<ChatProps, "messages"> | — | Every other Chat prop — onSendMessage, renderToolCall, focusShortcut, etc. — passed straight through to the inner Chat. |