Vesture

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.

ChatWithThreads

Preview

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

PropTypeDefaultDescription
id / titlestringRequired.
messagesChatMessage[]This thread's own message history.
updatedAtDateNot read by ChatWithThreads itself — yours to use for sorting the list, if desired.

ChatWithThreadsProps

PropTypeDefaultDescription
threadsChatThread[]Required.
activeThreadIdstringRequired. Which thread's messages are currently shown.
onThreadChange(id: string) => voidRequired. Fires when a thread in the sidebar is clicked.
onNewThread / onDeleteThread / onRenameThread() => void / (id) => void / (id, title) => voidGate the matching sidebar affordance's visibility (see behavior above).
sidebarTitlestring"Threads"Sidebar heading.
...chatPropsOmit<ChatProps, "messages">Every other Chat prop — onSendMessage, renderToolCall, focusShortcut, etc. — passed straight through to the inner Chat.