Conversation Thread
Compound component family for AI chat UIs that orchestrates auto-scroll, streaming indicators, empty states, and message rendering. Install Conversation Thread from the VLLNT UI registry with the shadcn CLI.
Compound component family for AI chat UIs that orchestrates auto-scroll, streaming indicators, empty states, and message rendering. Part of the ai family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Conversation Thread to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/conversation-thread.jsonSource
"use client";
import {
createContext,
type ReactNode,
type RefObject,
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowDown, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "../../lib/utils";
import { ThinkingBlock } from "../thinking-block/thinking-block";
/** A single tool call made by the assistant. */
export type ToolCall = {
id: string;
input?: Record<string, unknown>;
name: string;
result?: string;
};
/** A single message in the conversation. */
export type ConversationMessage = {
content: string;
id: string;
/** Whether the assistant still streams this individual message. */
isStreaming?: boolean;
role: "assistant" | "user";
/** AI reasoning/thinking content. The component renders this via ThinkingBlock. */
thinking?: string;
toolCalls?: ToolCall[];
};
export type ConversationThreadProps = {
children?: ReactNode;
className?: string;
/** Whether the assistant generates a response. */
isStreaming?: boolean;
messages: ConversationMessage[];
onFeedback?: (messageId: string, feedback: "negative" | "positive") => void;
onRetry?: (messageId: string) => void;
/** Calls onSend with the suggestion text after the user clicks a ConversationSuggestions chip. */
onSend?: (message: string) => void;
};
export type ConversationHeaderProps = {
children?: ReactNode;
className?: string;
};
export type ConversationTitleProps = {
children?: ReactNode;
className?: string;
};
export type ConversationMessagesProps = {
/** Overlay children: ConversationEmpty, ConversationScrollButton, ConversationLoading. */
children?: ReactNode;
className?: string;
};
export type ConversationEmptyProps = {
children?: ReactNode;
className?: string;
};
export type ConversationSuggestionsProps = {
className?: string;
suggestions?: string[];
};
export type ConversationScrollButtonProps = {
className?: string;
};
export type ConversationLoadingProps = {
className?: string;
};
// ---- Context ----
type ConversationThreadContextValue = {
handleScroll: () => void;
isAtBottom: boolean;
isStreaming: boolean;
messages: ConversationMessage[];
messagesEndRef: RefObject<HTMLDivElement | null>;
onFeedback?: (messageId: string, feedback: "negative" | "positive") => void;
onRetry?: (messageId: string) => void;
onSend?: (message: string) => void;
scrollContainerRef: RefObject<HTMLDivElement | null>;
scrollToBottom: () => void;
};
const ConversationThreadContext =
createContext<ConversationThreadContextValue | null>(null);
function useConversationThreadContext(): ConversationThreadContextValue {
const ctx = use(ConversationThreadContext);
if (!ctx) {
throw new Error(
"ConversationThread compound components must be used within <ConversationThread>",
);
}
return ctx;
}
// ---- Internal message item ----
type MessageActionsProps = {
messageId: string;
};
function MessageActions({ messageId }: MessageActionsProps) {
const { onFeedback, onRetry } = useConversationThreadContext();
return (
<div className="mt-2 flex items-center gap-1">
{onRetry ? (
<button
aria-label="Retry message"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
onRetry(messageId);
}}
type="button"
>
<RefreshCw className="size-3" />
</button>
) : null}
{onFeedback ? (
<>
<button
aria-label="Positive feedback"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
onFeedback(messageId, "positive");
}}
type="button"
>
<ThumbsUp className="size-3" />
</button>
<button
aria-label="Negative feedback"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
onFeedback(messageId, "negative");
}}
type="button"
>
<ThumbsDown className="size-3" />
</button>
</>
) : null}
</div>
);
}
type MessageItemProps = {
message: ConversationMessage;
};
function MessageItem({ message }: MessageItemProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"mb-4 flex gap-3",
isUser ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-4 py-3 text-sm",
isUser
? "rounded-br-sm bg-primary text-primary-foreground"
: "rounded-bl-sm bg-muted text-foreground",
)}
>
{!isUser && message.thinking ? (
<ThinkingBlock
isStreaming={message.isStreaming}
thinking={message.thinking}
/>
) : null}
{message.toolCalls && message.toolCalls.length > 0 ? (
<ul
aria-label="Tool calls"
className="mb-2 flex flex-col gap-1 text-xs text-muted-foreground"
>
{message.toolCalls.map((toolCall) => (
<li className="font-mono" key={toolCall.id}>
{toolCall.name}
</li>
))}
</ul>
) : null}
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
{isUser ? null : <MessageActions messageId={message.id} />}
</div>
</div>
);
}
// ---- Scroll hook ----
function useConversationScroll(
messages: ConversationMessage[],
isStreaming: boolean,
) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const [isAtBottom, setIsAtBottom] = useState(true);
const scrollToBottom = useCallback(() => {
const element = messagesEndRef.current;
if (element && typeof element.scrollIntoView === "function") {
element.scrollIntoView({ behavior: "smooth" });
}
}, []);
const scrollToBottomInstant = useCallback(() => {
const element = messagesEndRef.current;
if (element && typeof element.scrollIntoView === "function") {
element.scrollIntoView({ behavior: "instant" });
}
}, []);
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { clientHeight, scrollHeight, scrollTop } = container;
const nearBottom = scrollHeight - scrollTop - clientHeight <= 100;
isAtBottomRef.current = nearBottom;
setIsAtBottom(nearBottom);
}, []);
useEffect(() => {
if (!isAtBottomRef.current) return;
scrollToBottomInstant();
}, [messages, scrollToBottomInstant]);
useEffect(() => {
if (!isStreaming || !isAtBottomRef.current) return;
scrollToBottomInstant();
}, [isStreaming, scrollToBottomInstant]);
return {
handleScroll,
isAtBottom,
messagesEndRef,
scrollContainerRef,
scrollToBottom,
};
}
// ---- Root ----
/**
* Root provider for the ConversationThread compound component family.
*
* @example
* ```tsx
* <ConversationThread messages={messages} isStreaming={isStreaming} onSend={handleSend}>
* <ConversationHeader><ConversationTitle>Chat</ConversationTitle></ConversationHeader>
* <ConversationMessages>
* <ConversationEmpty>
* <ConversationSuggestions suggestions={["Hello!", "Help me with..."]} />
* </ConversationEmpty>
* <ConversationScrollButton />
* <ConversationLoading />
* </ConversationMessages>
* </ConversationThread>
* ```
*/
export const ConversationThread = ({
children,
className,
isStreaming = false,
messages,
onFeedback,
onRetry,
onSend,
ref: reference,
}: ConversationThreadProps & { ref?: React.Ref<HTMLDivElement> }) => {
const {
handleScroll,
isAtBottom,
messagesEndRef,
scrollContainerRef,
scrollToBottom,
} = useConversationScroll(messages, isStreaming);
const contextValue = useMemo<ConversationThreadContextValue>(
() => ({
handleScroll,
isAtBottom,
isStreaming,
messages,
messagesEndRef,
onFeedback,
onRetry,
onSend,
scrollContainerRef,
scrollToBottom,
}),
[
handleScroll,
isAtBottom,
isStreaming,
messages,
messagesEndRef,
onFeedback,
onRetry,
onSend,
scrollContainerRef,
scrollToBottom,
],
);
return (
<ConversationThreadContext.Provider value={contextValue}>
<div
className={cn("flex h-full flex-col overflow-hidden", className)}
ref={reference}
>
{children}
</div>
</ConversationThreadContext.Provider>
);
};
ConversationThread.displayName = "ConversationThread";
// ---- Compound components ----
/** Optional header slot, rendered above the message list. */
export const ConversationHeader = ({
children,
className,
ref: reference,
}: ConversationHeaderProps & { ref?: React.Ref<HTMLDivElement> }) => {
return (
<div
className={cn("flex shrink-0 items-center border-b px-4 py-3", className)}
ref={reference}
>
{children}
</div>
);
};
ConversationHeader.displayName = "ConversationHeader";
/** Title text for use inside ConversationHeader. */
export const ConversationTitle = ({
children,
className,
ref: reference,
}: ConversationTitleProps & { ref?: React.Ref<HTMLHeadingElement> }) => {
return (
<h2
className={cn("text-sm font-semibold leading-none", className)}
ref={reference}
>
{children}
</h2>
);
};
ConversationTitle.displayName = "ConversationTitle";
/**
* Scrollable message list container. Renders messages from context.
* Pass ConversationEmpty, ConversationScrollButton, and ConversationLoading as children —
* the component renders these as absolute overlays that read state from context.
*/
export const ConversationMessages = ({
children,
className,
ref: reference,
}: ConversationMessagesProps & { ref?: React.Ref<HTMLDivElement> }) => {
const { handleScroll, messages, messagesEndRef, scrollContainerRef } =
useConversationThreadContext();
return (
<div className={cn("relative min-h-0 flex-1", className)} ref={reference}>
<div
aria-label="Conversation messages"
aria-live="polite"
className="absolute inset-0 overflow-y-auto"
onScroll={handleScroll}
ref={scrollContainerRef}
role="log"
>
<div className="flex flex-col p-4">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
<div aria-hidden="true" ref={messagesEndRef} />
</div>
</div>
{children}
</div>
);
};
ConversationMessages.displayName = "ConversationMessages";
/**
* Shown when the message list is empty. Hides automatically once messages exist.
* Renders as a centered overlay — pass ConversationSuggestions or custom content as children.
*/
export const ConversationEmpty = ({
children,
className,
ref: reference,
}: ConversationEmptyProps & { ref?: React.Ref<HTMLDivElement> }) => {
const { messages } = useConversationThreadContext();
if (messages.length > 0) return null;
return (
<div
className={cn(
"pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 p-8",
className,
)}
ref={reference}
>
<div className="pointer-events-auto flex flex-col items-center gap-4">
{children}
</div>
</div>
);
};
ConversationEmpty.displayName = "ConversationEmpty";
/** Suggested prompt chips displayed in the empty state. Calls onSend when clicked. */
export const ConversationSuggestions = ({
className,
ref: reference,
suggestions = [],
}: ConversationSuggestionsProps & { ref?: React.Ref<HTMLDivElement> }) => {
const { onSend } = useConversationThreadContext();
return (
<div
className={cn("flex flex-wrap justify-center gap-2", className)}
ref={reference}
>
{suggestions.map((suggestion) => (
<button
className="rounded-full border bg-background px-4 py-2 text-sm transition-colors hover:bg-muted"
key={suggestion}
onClick={() => onSend?.(suggestion)}
type="button"
>
{suggestion}
</button>
))}
</div>
);
};
ConversationSuggestions.displayName = "ConversationSuggestions";
/** Floating button that appears when the user scrolls up, to jump back to the bottom. */
export const ConversationScrollButton = ({
className,
ref: reference,
}: ConversationScrollButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => {
const { isAtBottom, scrollToBottom } = useConversationThreadContext();
if (isAtBottom) return null;
return (
<button
aria-label="Scroll to bottom"
className={cn(
"absolute bottom-4 right-4 flex size-8 items-center justify-center rounded-full border bg-background shadow-md transition-colors hover:bg-muted",
className,
)}
onClick={scrollToBottom}
ref={reference}
type="button"
>
<ArrowDown className="size-4" />
</button>
);
};
ConversationScrollButton.displayName = "ConversationScrollButton";
/**
* Typing indicator shown while the assistant is streaming a response.
* Visible when isStreaming is true and the last message role is "assistant".
*/
export const ConversationLoading = ({
className,
ref: reference,
}: ConversationLoadingProps & { ref?: React.Ref<HTMLDivElement> }) => {
const { isStreaming, messages } = useConversationThreadContext();
const lastMessage = messages.at(-1);
if (!isStreaming || lastMessage?.role !== "assistant") return null;
return (
<div
aria-label="Assistant is typing"
className={cn(
"absolute bottom-4 left-4 flex items-center gap-1",
className,
)}
ref={reference}
role="status"
>
<span
className="size-2 animate-pulse rounded-full bg-muted-foreground"
style={{ animationDelay: "-0.3s" }}
/>
<span
className="size-2 animate-pulse rounded-full bg-muted-foreground"
style={{ animationDelay: "-0.15s" }}
/>
<span className="size-2 animate-pulse rounded-full bg-muted-foreground" />
</div>
);
};
ConversationLoading.displayName = "ConversationLoading";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.