Conversation Thread
Famille de composants composee pour les interfaces de chat IA qui orchestre le defilement automatique, les indicateurs de streaming, les etats vides et le rendu des messages. Installez Conversation Thread depuis le registre VLLNT UI avec la CLI shadcn.
Famille de composants composee pour les interfaces de chat IA qui orchestre le defilement automatique, les indicateurs de streaming, les etats vides et le rendu des messages. Fait partie de la famille ai dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Conversation Thread a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
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
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.