Stream-ready React chat container with message list, composer, auto-scroll, and accessibility built in. Drop into any AI chat UI built with assistant-ui.
A complete chat interface that combines message rendering, auto-scrolling, composer input, attachments, and conditional UI states. Fully customizable and composable.
Anatomy
The Thread component is built with the following primitives:
import { ThreadPrimitive, AuiIf } from "@assistant-ui/react";
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport>
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
{/* ThreadWelcome includes ThreadPrimitive.Suggestions */}
</AuiIf>
<ThreadPrimitive.Messages>
{({ message }) => {
if (message.role === "user") return <UserMessage />;
return <AssistantMessage />;
}}
</ThreadPrimitive.Messages>
<ThreadPrimitive.ViewportFooter>
<ThreadPrimitive.ScrollToBottom />
<Composer />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>Getting Started
Add the component
npx shadcn@latest add https://r.assistant-ui.com/thread.jsonMain Component
npm install @assistant-ui/reactimport { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments,} from "@/components/assistant-ui/attachment";import { MarkdownText } from "@/components/assistant-ui/markdown-text";import { Reasoning, ReasoningContent, ReasoningRoot, ReasoningText, ReasoningTrigger,} from "@/components/assistant-ui/reasoning";import { ToolGroupContent, ToolGroupRoot, ToolGroupTrigger,} from "@/components/assistant-ui/tool-group";import { ToolFallback } from "@/components/assistant-ui/tool-fallback";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { ActionBarMorePrimitive, ActionBarPrimitive, AuiIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, groupPartByType, MessagePrimitive, SuggestionPrimitive, ThreadPrimitive, useAuiState,} from "@assistant-ui/react";import { ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, DownloadIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon, SquareIcon,} from "lucide-react";import type { FC } from "react";export const Thread: FC = () => { return ( <ThreadPrimitive.Root className="aui-root aui-thread-root bg-background @container flex h-full flex-col" style={{ ["--thread-max-width" as string]: "44rem", ["--composer-radius" as string]: "24px", ["--composer-padding" as string]: "10px", }} > <ThreadPrimitive.Viewport turnAnchor="top" data-slot="aui_thread-viewport" className="relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth" > <div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4"> <AuiIf condition={(s) => s.thread.isEmpty}> <ThreadWelcome /> </AuiIf> <div data-slot="aui_message-group" className="mb-10 flex flex-col gap-y-8 empty:hidden" > <ThreadPrimitive.Messages> {() => <ThreadMessage />} </ThreadPrimitive.Messages> </div> <ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer bg-background sticky bottom-0 mt-auto flex flex-col gap-4 overflow-visible rounded-t-(--composer-radius) pb-4 md:pb-6"> <ThreadScrollToBottom /> <Composer /> </ThreadPrimitive.ViewportFooter> </div> </ThreadPrimitive.Viewport> </ThreadPrimitive.Root> );};const ThreadMessage: FC = () => { const role = useAuiState((s) => s.message.role); const isEditing = useAuiState((s) => s.message.composer.isEditing); if (isEditing) return <EditComposer />; if (role === "user") return <UserMessage />; return <AssistantMessage />;};const ThreadScrollToBottom: FC = () => { return ( <ThreadPrimitive.ScrollToBottom asChild> <TooltipIconButton tooltip="Scroll to bottom" variant="outline" className="aui-thread-scroll-to-bottom dark:border-border dark:bg-background dark:hover:bg-accent absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible" > <ArrowDownIcon /> </TooltipIconButton> </ThreadPrimitive.ScrollToBottom> );};const ThreadWelcome: FC = () => { return ( <div className="aui-thread-welcome-root my-auto flex grow flex-col"> <div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center"> <div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4"> <h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-2xl font-semibold duration-200"> Hello there! </h1> <p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200"> How can I help you today? </p> </div> </div> <ThreadSuggestions /> </div> );};const ThreadSuggestions: FC = () => { return ( <div className="aui-thread-welcome-suggestions grid w-full gap-2 pb-4 @md:grid-cols-2"> <ThreadPrimitive.Suggestions> {() => <ThreadSuggestionItem />} </ThreadPrimitive.Suggestions> </div> );};const ThreadSuggestionItem: FC = () => { return ( <div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 animate-in fill-mode-both duration-200 nth-[n+3]:hidden @md:nth-[n+3]:block"> <SuggestionPrimitive.Trigger send asChild> <Button variant="ghost" className="aui-thread-welcome-suggestion bg-background hover:bg-muted h-auto w-full flex-wrap items-start justify-start gap-1 rounded-3xl border px-4 py-3 text-start text-sm transition-colors @md:flex-col" > <SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" /> <SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" /> </Button> </SuggestionPrimitive.Trigger> </div> );};const Composer: FC = () => { return ( <ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col"> <ComposerPrimitive.AttachmentDropzone asChild> <div data-slot="aui_composer-shell" className="bg-background focus-within:border-ring/75 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50 flex w-full flex-col gap-2 rounded-(--composer-radius) border p-(--composer-padding) transition-shadow focus-within:ring-2 data-[dragging=true]:border-dashed" > <ComposerAttachments /> <ComposerPrimitive.Input placeholder="Send a message..." className="aui-composer-input placeholder:text-muted-foreground/80 max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none" rows={1} autoFocus aria-label="Message input" /> <ComposerAction /> </div> </ComposerPrimitive.AttachmentDropzone> </ComposerPrimitive.Root> );};const ComposerAction: FC = () => { return ( <div className="aui-composer-action-wrapper relative flex items-center justify-between"> <ComposerAddAttachment /> <AuiIf condition={(s) => !s.thread.isRunning}> <ComposerPrimitive.Send asChild> <TooltipIconButton tooltip="Send message" side="bottom" type="button" variant="default" size="icon" className="aui-composer-send size-8 rounded-full" aria-label="Send message" > <ArrowUpIcon className="aui-composer-send-icon size-4" /> </TooltipIconButton> </ComposerPrimitive.Send> </AuiIf> <AuiIf condition={(s) => s.thread.isRunning}> <ComposerPrimitive.Cancel asChild> <Button type="button" variant="default" size="icon" className="aui-composer-cancel size-8 rounded-full" aria-label="Stop generating" > <SquareIcon className="aui-composer-cancel-icon size-3 fill-current" /> </Button> </ComposerPrimitive.Cancel> </AuiIf> </div> );};const MessageError: FC = () => { return ( <MessagePrimitive.Error> <ErrorPrimitive.Root className="aui-message-error-root border-destructive bg-destructive/10 text-destructive dark:bg-destructive/5 mt-2 rounded-md border p-3 text-sm dark:text-red-200"> <ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" /> </ErrorPrimitive.Root> </MessagePrimitive.Error> );};const AssistantMessage: FC = () => { // reserves space for action bar and compensates with `-mb` for consistent msg spacing // keeps hovered action bar from shifting layout (autohide doesn't support absolute positioning well) // for pt-[n] use -mb-[n + 6] & min-h-[n + 6] to preserve compensation const ACTION_BAR_PT = "pt-1.5"; const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`; return ( <MessagePrimitive.Root data-slot="aui_assistant-message-root" data-role="assistant" className="fade-in slide-in-from-bottom-1 animate-in relative duration-150" > <div data-slot="aui_assistant-message-content" // [contain-intrinsic-size:auto_24px] fixes issue #4104, don't change without checking for regressions className="text-foreground px-2 leading-relaxed wrap-break-word [contain-intrinsic-size:auto_24px] [content-visibility:auto]" > <MessagePrimitive.GroupedParts groupBy={groupPartByType({ reasoning: ["group-chainOfThought", "group-reasoning"], "tool-call": ["group-chainOfThought", "group-tool"], "standalone-tool-call": [], })} > {({ part, children }) => { switch (part.type) { case "group-chainOfThought": return <div data-slot="aui_chain-of-thought">{children}</div>; case "group-reasoning": { const running = part.status.type === "running"; return ( <ReasoningRoot defaultOpen={running}> <ReasoningTrigger active={running} /> <ReasoningContent aria-busy={running}> <ReasoningText>{children}</ReasoningText> </ReasoningContent> </ReasoningRoot> ); } case "group-tool": return ( <ToolGroupRoot> <ToolGroupTrigger count={part.indices.length} active={part.status.type === "running"} /> <ToolGroupContent>{children}</ToolGroupContent> </ToolGroupRoot> ); case "text": return <MarkdownText />; case "reasoning": return <Reasoning {...part} />; case "tool-call": return part.toolUI ?? <ToolFallback {...part} />; case "indicator": return ( <span data-slot="aui_assistant-message-indicator" className="animate-pulse font-sans" aria-label="Assistant is working" > {"●"} </span> ); default: return null; } }} </MessagePrimitive.GroupedParts> <MessageError /> </div> <div data-slot="aui_assistant-message-footer" className={cn("ms-2 flex items-center", ACTION_BAR_HEIGHT)} > <BranchPicker /> <AssistantActionBar /> </div> </MessagePrimitive.Root> );};const AssistantActionBar: FC = () => { return ( <ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className="aui-assistant-action-bar-root text-muted-foreground col-start-3 row-start-2 -ms-1 flex gap-1" > <ActionBarPrimitive.Copy asChild> <TooltipIconButton tooltip="Copy"> <AuiIf condition={(s) => s.message.isCopied}> <CheckIcon /> </AuiIf> <AuiIf condition={(s) => !s.message.isCopied}> <CopyIcon /> </AuiIf> </TooltipIconButton> </ActionBarPrimitive.Copy> <ActionBarPrimitive.Reload asChild> <TooltipIconButton tooltip="Refresh"> <RefreshCwIcon /> </TooltipIconButton> </ActionBarPrimitive.Reload> <ActionBarMorePrimitive.Root> <ActionBarMorePrimitive.Trigger asChild> <TooltipIconButton tooltip="More" className="data-[state=open]:bg-accent" > <MoreHorizontalIcon /> </TooltipIconButton> </ActionBarMorePrimitive.Trigger> <ActionBarMorePrimitive.Content side="bottom" align="start" className="aui-action-bar-more-content bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md" > <ActionBarPrimitive.ExportMarkdown asChild> <ActionBarMorePrimitive.Item className="aui-action-bar-more-item hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none"> <DownloadIcon className="size-4" /> Export as Markdown </ActionBarMorePrimitive.Item> </ActionBarPrimitive.ExportMarkdown> </ActionBarMorePrimitive.Content> </ActionBarMorePrimitive.Root> </ActionBarPrimitive.Root> );};const UserMessage: FC = () => { return ( <MessagePrimitive.Root data-slot="aui_user-message-root" className="fade-in slide-in-from-bottom-1 animate-in grid auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 duration-150 [contain-intrinsic-size:auto_60px] [content-visibility:auto] [&:where(>*)]:col-start-2" data-role="user" > <UserMessageAttachments /> <div className="aui-user-message-content-wrapper relative col-start-2 min-w-0"> <div className="aui-user-message-content peer bg-muted text-foreground rounded-2xl px-4 py-2.5 wrap-break-word empty:hidden"> <MessagePrimitive.Parts /> </div> <div className="aui-user-action-bar-wrapper absolute start-0 top-1/2 -translate-x-full -translate-y-1/2 pe-2 peer-empty:hidden rtl:translate-x-full"> <UserActionBar /> </div> </div> <BranchPicker data-slot="aui_user-branch-picker" className="col-span-full col-start-1 row-start-3 -me-1 justify-end" /> </MessagePrimitive.Root> );};const UserActionBar: FC = () => { return ( <ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className="aui-user-action-bar-root flex flex-col items-end" > <ActionBarPrimitive.Edit asChild> <TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4"> <PencilIcon /> </TooltipIconButton> </ActionBarPrimitive.Edit> </ActionBarPrimitive.Root> );};const EditComposer: FC = () => { return ( <MessagePrimitive.Root data-slot="aui_edit-composer-wrapper" className="flex flex-col px-2" > <ComposerPrimitive.Root className="aui-edit-composer-root bg-muted ms-auto flex w-full max-w-[85%] flex-col rounded-2xl"> <ComposerPrimitive.Input className="aui-edit-composer-input text-foreground min-h-14 w-full resize-none bg-transparent p-4 text-sm outline-none" autoFocus /> <div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end"> <ComposerPrimitive.Cancel asChild> <Button variant="ghost" size="sm"> Cancel </Button> </ComposerPrimitive.Cancel> <ComposerPrimitive.Send asChild> <Button size="sm">Update</Button> </ComposerPrimitive.Send> </div> </ComposerPrimitive.Root> </MessagePrimitive.Root> );};const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest}) => { return ( <BranchPickerPrimitive.Root hideWhenSingleBranch className={cn( "aui-branch-picker-root text-muted-foreground -ms-2 me-2 inline-flex items-center text-xs", className, )} {...rest} > <BranchPickerPrimitive.Previous asChild> <TooltipIconButton tooltip="Previous"> <ChevronLeftIcon /> </TooltipIconButton> </BranchPickerPrimitive.Previous> <span className="aui-branch-picker-state font-medium"> <BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count /> </span> <BranchPickerPrimitive.Next asChild> <TooltipIconButton tooltip="Next"> <ChevronRightIcon /> </TooltipIconButton> </BranchPickerPrimitive.Next> </BranchPickerPrimitive.Root> );};assistant-ui dependencies
npm install @assistant-ui/react @assistant-ui/react-markdown class-variance-authority radix-ui remark-gfm tw-shimmer zustand"use client";import { type PropsWithChildren, useEffect, useState, type FC } from "react";import { XIcon, PlusIcon, FileText } from "lucide-react";import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, useAuiState, useAui,} from "@assistant-ui/react";import { useShallow } from "zustand/shallow";import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip";import { Dialog, DialogTitle, DialogContent, DialogTrigger,} from "@/components/ui/dialog";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { cn } from "@/lib/utils";const useFileSrc = (file: File | undefined) => { const [src, setSrc] = useState<string | undefined>(undefined); useEffect(() => { if (!file) { setSrc(undefined); return; } const objectUrl = URL.createObjectURL(file); setSrc(objectUrl); return () => { URL.revokeObjectURL(objectUrl); }; }, [file]); return src;};const useAttachmentSrc = () => { const { file, src } = useAuiState( useShallow((s): { file?: File; src?: string } => { if (s.attachment.type !== "image") return {}; if (s.attachment.file) return { file: s.attachment.file }; const src = s.attachment.content?.filter((c) => c.type === "image")[0] ?.image; if (!src) return {}; return { src }; }), ); return useFileSrc(file) ?? src;};type AttachmentPreviewProps = { src: string;};const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => { const [isLoaded, setIsLoaded] = useState(false); return ( <img src={src} alt="Attachment preview" className={cn( "block h-auto max-h-[80vh] w-auto max-w-full object-contain", isLoaded ? "aui-attachment-preview-image-loaded" : "aui-attachment-preview-image-loading invisible", )} onLoad={() => setIsLoaded(true)} /> );};const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => { const src = useAttachmentSrc(); if (!src) return children; return ( <Dialog> <DialogTrigger className="aui-attachment-preview-trigger hover:bg-accent/50 cursor-pointer transition-colors" asChild > {children} </DialogTrigger> <DialogContent className="aui-attachment-preview-dialog-content [&>button]:bg-foreground/60 [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0!"> <DialogTitle className="aui-sr-only sr-only"> Image Attachment Preview </DialogTitle> <div className="aui-attachment-preview bg-background relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden"> <AttachmentPreview src={src} /> </div> </DialogContent> </Dialog> );};const AttachmentThumb: FC = () => { const src = useAttachmentSrc(); return ( <Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none"> <AvatarImage src={src} alt="Attachment preview" className="aui-attachment-tile-image object-cover" /> <AvatarFallback> <FileText className="aui-attachment-tile-fallback-icon text-muted-foreground size-8" /> </AvatarFallback> </Avatar> );};const AttachmentUI: FC = () => { const aui = useAui(); const isComposer = aui.attachment.source !== "message"; const isImage = useAuiState((s) => s.attachment.type === "image"); const typeLabel = useAuiState((s) => { const type = s.attachment.type; switch (type) { case "image": return "Image"; case "document": return "Document"; case "file": return "File"; default: return type; } }); return ( <Tooltip> <AttachmentPrimitive.Root className={cn( "aui-attachment-root relative", isImage && "aui-attachment-root-composer only:*:first:size-24", )} > <AttachmentPreviewDialog> <TooltipTrigger asChild> <div className="aui-attachment-tile bg-muted size-14 cursor-pointer overflow-hidden rounded-[calc(var(--composer-radius)-var(--composer-padding))] border transition-opacity hover:opacity-75" role="button" tabIndex={0} aria-label={`${typeLabel} attachment`} > <AttachmentThumb /> </div> </TooltipTrigger> </AttachmentPreviewDialog> {isComposer && <AttachmentRemove />} </AttachmentPrimitive.Root> <TooltipContent side="top"> <AttachmentPrimitive.Name /> </TooltipContent> </Tooltip> );};const AttachmentRemove: FC = () => { return ( <AttachmentPrimitive.Remove asChild> <TooltipIconButton tooltip="Remove file" className="aui-attachment-tile-remove text-muted-foreground hover:[&_svg]:text-destructive absolute end-1.5 top-1.5 size-3.5 rounded-full bg-white opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black" side="top" > <XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" /> </TooltipIconButton> </AttachmentPrimitive.Remove> );};export const UserMessageAttachments: FC = () => { return ( <div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2"> <MessagePrimitive.Attachments> {() => <AttachmentUI />} </MessagePrimitive.Attachments> </div> );};export const ComposerAttachments: FC = () => { return ( <div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden"> <ComposerPrimitive.Attachments> {() => <AttachmentUI />} </ComposerPrimitive.Attachments> </div> );};export const ComposerAddAttachment: FC = () => { return ( <ComposerPrimitive.AddAttachment asChild> <TooltipIconButton tooltip="Add Attachment" side="bottom" variant="ghost" size="icon" className="aui-composer-add-attachment hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30 size-8 rounded-full p-1 text-xs font-semibold" aria-label="Add Attachment" > <PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" /> </TooltipIconButton> </ComposerPrimitive.AddAttachment> );};"use client";import { type ComponentPropsWithRef, forwardRef } from "react";import { Slot } from "radix-ui";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & { tooltip: string; side?: "top" | "bottom" | "left" | "right";};export const TooltipIconButton = forwardRef< HTMLButtonElement, TooltipIconButtonProps>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { return ( <TooltipProvider delayDuration={0}> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon" {...rest} className={cn("aui-button-icon size-6 p-1", className)} ref={ref} > <Slot.Slottable>{children}</Slot.Slottable> <span className="aui-sr-only sr-only">{tooltip}</span> </Button> </TooltipTrigger> <TooltipContent side={side}>{tooltip}</TooltipContent> </Tooltip> </TooltipProvider> );});TooltipIconButton.displayName = "TooltipIconButton";"use client";import "@assistant-ui/react-markdown/styles/dot.css";import { type CodeHeaderProps, MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock,} from "@assistant-ui/react-markdown";import remarkGfm from "remark-gfm";import { type FC, memo, useState } from "react";import { CheckIcon, CopyIcon } from "lucide-react";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { cn } from "@/lib/utils";const MarkdownTextImpl = () => { return ( <MarkdownTextPrimitive remarkPlugins={[remarkGfm]} className="aui-md" components={defaultComponents} /> );};export const MarkdownText = memo(MarkdownTextImpl);const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => { const { isCopied, copyToClipboard } = useCopyToClipboard(); const onCopy = () => { if (!code || isCopied) return; copyToClipboard(code); }; return ( <div className="aui-code-header-root border-border/50 bg-muted/50 mt-2.5 flex items-center justify-between rounded-t-lg border border-b-0 px-3 py-1.5 text-xs"> <span className="aui-code-header-language text-muted-foreground font-medium lowercase"> {language} </span> <TooltipIconButton tooltip="Copy" onClick={onCopy}> {!isCopied && <CopyIcon />} {isCopied && <CheckIcon />} </TooltipIconButton> </div> );};const useCopyToClipboard = ({ copiedDuration = 3000,}: { copiedDuration?: number;} = {}) => { const [isCopied, setIsCopied] = useState<boolean>(false); const copyToClipboard = (value: string) => { if (!value || typeof navigator === "undefined" || !navigator.clipboard) { return; } navigator.clipboard.writeText(value).then( () => { setIsCopied(true); setTimeout(() => setIsCopied(false), copiedDuration); }, () => {}, ); }; return { isCopied, copyToClipboard };};const defaultComponents = memoizeMarkdownComponents({ h1: ({ className, ...props }) => ( <h1 className={cn( "aui-md-h1 mb-2 scroll-m-20 text-base font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h2: ({ className, ...props }) => ( <h2 className={cn( "aui-md-h2 mt-3 mb-1.5 scroll-m-20 text-sm font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h3: ({ className, ...props }) => ( <h3 className={cn( "aui-md-h3 mt-2.5 mb-1 scroll-m-20 text-sm font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h4: ({ className, ...props }) => ( <h4 className={cn( "aui-md-h4 mt-2 mb-1 scroll-m-20 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), h5: ({ className, ...props }) => ( <h5 className={cn( "aui-md-h5 mt-2 mb-1 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), h6: ({ className, ...props }) => ( <h6 className={cn( "aui-md-h6 mt-2 mb-1 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), p: ({ className, ...props }) => ( <p className={cn( "aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0", className, )} {...props} /> ), a: ({ className, ...props }) => ( <a className={cn( "aui-md-a text-primary hover:text-primary/80 underline underline-offset-2", className, )} {...props} /> ), blockquote: ({ className, ...props }) => ( <blockquote className={cn( "aui-md-blockquote border-muted-foreground/30 text-muted-foreground my-2.5 border-s-2 ps-3 italic", className, )} {...props} /> ), ul: ({ className, ...props }) => ( <ul className={cn( "aui-md-ul marker:text-muted-foreground my-2 ms-4 list-disc [&>li]:mt-1", className, )} {...props} /> ), ol: ({ className, ...props }) => ( <ol className={cn( "aui-md-ol marker:text-muted-foreground my-2 ms-4 list-decimal [&>li]:mt-1", className, )} {...props} /> ), hr: ({ className, ...props }) => ( <hr className={cn("aui-md-hr border-muted-foreground/20 my-2", className)} {...props} /> ), table: ({ className, ...props }) => ( <table className={cn( "aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto", className, )} {...props} /> ), th: ({ className, ...props }) => ( <th className={cn( "aui-md-th bg-muted px-2 py-1 text-start font-medium first:rounded-ss-lg last:rounded-se-lg [[align=center]]:text-center [[align=right]]:text-right", className, )} {...props} /> ), td: ({ className, ...props }) => ( <td className={cn( "aui-md-td border-muted-foreground/20 border-s border-b px-2 py-1 text-start last:border-e [[align=center]]:text-center [[align=right]]:text-right", className, )} {...props} /> ), tr: ({ className, ...props }) => ( <tr className={cn( "aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", className, )} {...props} /> ), li: ({ className, ...props }) => ( <li className={cn("aui-md-li leading-normal", className)} {...props} /> ), sup: ({ className, ...props }) => ( <sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} /> ), pre: ({ className, ...props }) => ( <pre className={cn( "aui-md-pre border-border/50 bg-muted/30 overflow-x-auto rounded-t-none rounded-b-lg border border-t-0 p-3 text-xs leading-relaxed", className, )} {...props} /> ), code: function Code({ className, ...props }) { const isCodeBlock = useIsMarkdownCodeBlock(); return ( <code className={cn( !isCodeBlock && "aui-md-inline-code border-border/50 bg-muted/50 rounded-md border px-1.5 py-0.5 font-mono text-[0.85em]", className, )} {...props} /> ); }, CodeHeader,});"use client";import { memo, useCallback, useRef, useState } from "react";import { cva, type VariantProps } from "class-variance-authority";import { BrainIcon, ChevronDownIcon } from "lucide-react";import { useScrollLock, useAuiState, type ReasoningMessagePartComponent, type ReasoningGroupComponent,} from "@assistant-ui/react";import { MarkdownText } from "@/components/assistant-ui/markdown-text";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";const ANIMATION_DURATION = 200;const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", { variants: { variant: { outline: "rounded-lg border px-3 py-2", ghost: "", muted: "bg-muted/50 rounded-lg px-3 py-2", }, }, defaultVariants: { variant: "outline", },});export type ReasoningRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & VariantProps<typeof reasoningVariants> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; };function ReasoningRoot({ className, variant, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ReasoningRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { if (!open) { lockScroll(); } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="reasoning-root" data-variant={variant} open={isOpen} onOpenChange={handleOpenChange} className={cn( "group/reasoning-root", reasoningVariants({ variant, className }), )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}function ReasoningFade({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="reasoning-fade" className={cn( "aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8", "bg-[linear-gradient(to_top,var(--color-background),transparent)]", "group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_top,hsl(var(--muted)/0.5),transparent)]", "fade-in-0 animate-in", "group-data-[state=open]/collapsible-content:animate-out", "group-data-[state=open]/collapsible-content:fade-out-0", "group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]", "group-data-[state=open]/collapsible-content:fill-mode-forwards", "duration-(--animation-duration)", "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", className, )} {...props} /> );}function ReasoningTrigger({ active, duration, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { active?: boolean; duration?: number;}) { const durationText = duration ? ` (${duration}s)` : ""; return ( <CollapsibleTrigger data-slot="reasoning-trigger" className={cn( "aui-reasoning-trigger group/trigger text-muted-foreground hover:text-foreground flex max-w-[75%] items-center gap-2 py-1 text-sm transition-colors", className, )} {...props} > <BrainIcon data-slot="reasoning-trigger-icon" className="aui-reasoning-trigger-icon size-4 shrink-0" /> <span data-slot="reasoning-trigger-label" className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none" > <span>Reasoning{durationText}</span> {active ? ( <span aria-hidden data-slot="reasoning-trigger-shimmer" className="aui-reasoning-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > Reasoning{durationText} </span> ) : null} </span> <ChevronDownIcon data-slot="reasoning-trigger-chevron" className={cn( "aui-reasoning-trigger-chevron mt-0.5 size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-out", "group-data-[state=closed]/trigger:-rotate-90", "group-data-[state=open]/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ReasoningContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="reasoning-content" className={cn( "aui-reasoning-content text-muted-foreground relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-out", "data-[state=closed]:animate-collapsible-up", "data-[state=open]:animate-collapsible-down", "data-[state=closed]:fill-mode-forwards", "data-[state=closed]:pointer-events-none", "data-[state=open]:duration-(--animation-duration)", "data-[state=closed]:duration-(--animation-duration)", className, )} {...props} > {children} <ReasoningFade /> </CollapsibleContent> );}function ReasoningText({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="reasoning-text" className={cn( "aui-reasoning-text relative z-0 max-h-64 space-y-4 overflow-y-auto ps-6 pt-2 pb-2 leading-relaxed", "transform-gpu transition-[transform,opacity]", "group-data-[state=open]/collapsible-content:animate-in", "group-data-[state=closed]/collapsible-content:animate-out", "group-data-[state=open]/collapsible-content:fade-in-0", "group-data-[state=closed]/collapsible-content:fade-out-0", "group-data-[state=open]/collapsible-content:slide-in-from-top-4", "group-data-[state=closed]/collapsible-content:slide-out-to-top-4", "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", "group-data-[state=closed]/collapsible-content:duration-(--animation-duration)", className, )} {...props} /> );}const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;const ReasoningGroupImpl: ReasoningGroupComponent = ({ children, startIndex, endIndex,}) => { const isReasoningStreaming = useAuiState((s) => { if (s.message.status?.type !== "running") return false; const lastIndex = s.message.parts.length - 1; if (lastIndex < 0) return false; const lastType = s.message.parts[lastIndex]?.type; if (lastType !== "reasoning") return false; return lastIndex >= startIndex && lastIndex <= endIndex; }); return ( <ReasoningRoot defaultOpen={isReasoningStreaming}> <ReasoningTrigger active={isReasoningStreaming} /> <ReasoningContent aria-busy={isReasoningStreaming}> <ReasoningText>{children}</ReasoningText> </ReasoningContent> </ReasoningRoot> );};const Reasoning = memo( ReasoningImpl,) as unknown as ReasoningMessagePartComponent & { Root: typeof ReasoningRoot; Trigger: typeof ReasoningTrigger; Content: typeof ReasoningContent; Text: typeof ReasoningText; Fade: typeof ReasoningFade;};Reasoning.displayName = "Reasoning";Reasoning.Root = ReasoningRoot;Reasoning.Trigger = ReasoningTrigger;Reasoning.Content = ReasoningContent;Reasoning.Text = ReasoningText;Reasoning.Fade = ReasoningFade;/** * @deprecated This wrapper targets the legacy `components.ReasoningGroup` * prop on `<MessagePrimitive.Parts>`. Use `<MessagePrimitive.GroupedParts>` * with a `groupBy` returning `"group-reasoning"` and compose `ReasoningRoot` * / `ReasoningTrigger` / `ReasoningContent` / `ReasoningText` directly. * See `thread.tsx` for an example. */const ReasoningGroup = memo(ReasoningGroupImpl);ReasoningGroup.displayName = "ReasoningGroup";export { Reasoning, ReasoningGroup, ReasoningRoot, ReasoningTrigger, ReasoningContent, ReasoningText, ReasoningFade, reasoningVariants,};"use client";import { memo, useCallback, useRef, useState } from "react";import { AlertCircleIcon, CheckIcon, ChevronDownIcon, LoaderIcon, XCircleIcon,} from "lucide-react";import { useScrollLock, type ToolCallMessagePartStatus, type ToolCallMessagePartComponent,} from "@assistant-ui/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";const ANIMATION_DURATION = 200;export type ToolFallbackRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};function ToolFallbackRoot({ className, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ToolFallbackRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { if (!open) { lockScroll(); } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="tool-fallback-root" open={isOpen} onOpenChange={handleOpenChange} className={cn( "aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3", className, )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}type ToolStatus = ToolCallMessagePartStatus["type"];const statusIconMap: Record<ToolStatus, React.ElementType> = { running: LoaderIcon, complete: CheckIcon, incomplete: XCircleIcon, "requires-action": AlertCircleIcon,};function ToolFallbackTrigger({ toolName, status, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { toolName: string; status?: ToolCallMessagePartStatus;}) { const statusType = status?.type ?? "complete"; const isRunning = statusType === "running"; const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const Icon = statusIconMap[statusType]; const label = isCancelled ? "Cancelled tool" : "Used tool"; return ( <CollapsibleTrigger data-slot="tool-fallback-trigger" className={cn( "aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors", className, )} {...props} > <Icon data-slot="tool-fallback-trigger-icon" className={cn( "aui-tool-fallback-trigger-icon size-4 shrink-0", isCancelled && "text-muted-foreground", isRunning && "animate-spin", )} /> <span data-slot="tool-fallback-trigger-label" className={cn( "aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-start leading-none", isCancelled && "text-muted-foreground line-through", )} > <span> {label}: <b>{toolName}</b> </span> {isRunning && ( <span aria-hidden data-slot="tool-fallback-trigger-shimmer" className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > {label}: <b>{toolName}</b> </span> )} </span> <ChevronDownIcon data-slot="tool-fallback-trigger-chevron" className={cn( "aui-tool-fallback-trigger-chevron size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-out", "group-data-[state=closed]/trigger:-rotate-90", "group-data-[state=open]/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ToolFallbackContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="tool-fallback-content" className={cn( "aui-tool-fallback-content relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-out", "data-[state=closed]:animate-collapsible-up", "data-[state=open]:animate-collapsible-down", "data-[state=closed]:fill-mode-forwards", "data-[state=closed]:pointer-events-none", "data-[state=open]:duration-(--animation-duration)", "data-[state=closed]:duration-(--animation-duration)", className, )} {...props} > <div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div> </CollapsibleContent> );}function ToolFallbackArgs({ argsText, className, ...props}: React.ComponentProps<"div"> & { argsText?: string;}) { if (!argsText) return null; return ( <div data-slot="tool-fallback-args" className={cn("aui-tool-fallback-args px-4", className)} {...props} > <pre className="aui-tool-fallback-args-value whitespace-pre-wrap"> {argsText} </pre> </div> );}function ToolFallbackResult({ result, className, ...props}: React.ComponentProps<"div"> & { result?: unknown;}) { if (result === undefined) return null; return ( <div data-slot="tool-fallback-result" className={cn( "aui-tool-fallback-result border-t border-dashed px-4 pt-2", className, )} {...props} > <p className="aui-tool-fallback-result-header font-semibold">Result:</p> <pre className="aui-tool-fallback-result-content whitespace-pre-wrap"> {typeof result === "string" ? result : JSON.stringify(result, null, 2)} </pre> </div> );}function ToolFallbackError({ status, className, ...props}: React.ComponentProps<"div"> & { status?: ToolCallMessagePartStatus;}) { if (status?.type !== "incomplete") return null; const error = status.error; const errorText = error ? typeof error === "string" ? error : JSON.stringify(error) : null; if (!errorText) return null; const isCancelled = status.reason === "cancelled"; const headerText = isCancelled ? "Cancelled reason:" : "Error:"; return ( <div data-slot="tool-fallback-error" className={cn("aui-tool-fallback-error px-4", className)} {...props} > <p className="aui-tool-fallback-error-header text-muted-foreground font-semibold"> {headerText} </p> <p className="aui-tool-fallback-error-reason text-muted-foreground"> {errorText} </p> </div> );}const ToolFallbackImpl: ToolCallMessagePartComponent = ({ toolName, argsText, result, status,}) => { const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; return ( <ToolFallbackRoot className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")} > <ToolFallbackTrigger toolName={toolName} status={status} /> <ToolFallbackContent> <ToolFallbackError status={status} /> <ToolFallbackArgs argsText={argsText} className={cn(isCancelled && "opacity-60")} /> {!isCancelled && <ToolFallbackResult result={result} />} </ToolFallbackContent> </ToolFallbackRoot> );};const ToolFallback = memo( ToolFallbackImpl,) as unknown as ToolCallMessagePartComponent & { Root: typeof ToolFallbackRoot; Trigger: typeof ToolFallbackTrigger; Content: typeof ToolFallbackContent; Args: typeof ToolFallbackArgs; Result: typeof ToolFallbackResult; Error: typeof ToolFallbackError;};ToolFallback.displayName = "ToolFallback";ToolFallback.Root = ToolFallbackRoot;ToolFallback.Trigger = ToolFallbackTrigger;ToolFallback.Content = ToolFallbackContent;ToolFallback.Args = ToolFallbackArgs;ToolFallback.Result = ToolFallbackResult;ToolFallback.Error = ToolFallbackError;export { ToolFallback, ToolFallbackRoot, ToolFallbackTrigger, ToolFallbackContent, ToolFallbackArgs, ToolFallbackResult, ToolFallbackError,};"use client";import { memo, useCallback, useRef, useState, type FC, type PropsWithChildren,} from "react";import { ChevronDownIcon, LoaderIcon } from "lucide-react";import { cva, type VariantProps } from "class-variance-authority";import { useScrollLock } from "@assistant-ui/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";const ANIMATION_DURATION = 200;const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", { variants: { variant: { outline: "rounded-lg border py-3", ghost: "", muted: "border-muted-foreground/30 bg-muted/30 rounded-lg border py-3", }, }, defaultVariants: { variant: "outline" },});export type ToolGroupRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & VariantProps<typeof toolGroupVariants> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; };function ToolGroupRoot({ className, variant, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ToolGroupRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { if (!open) { lockScroll(); } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="tool-group-root" data-variant={variant ?? "outline"} open={isOpen} onOpenChange={handleOpenChange} className={cn( toolGroupVariants({ variant }), "group/tool-group-root", className, )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}function ToolGroupTrigger({ count, active = false, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { count: number; active?: boolean;}) { const label = `${count} tool ${count === 1 ? "call" : "calls"}`; return ( <CollapsibleTrigger data-slot="tool-group-trigger" className={cn( "aui-tool-group-trigger group/trigger flex items-center gap-2 text-sm transition-colors", "group-data-[variant=outline]/tool-group-root:w-full group-data-[variant=outline]/tool-group-root:px-4", "group-data-[variant=muted]/tool-group-root:w-full group-data-[variant=muted]/tool-group-root:px-4", className, )} {...props} > {active && ( <LoaderIcon data-slot="tool-group-trigger-loader" className="aui-tool-group-trigger-loader size-4 shrink-0 animate-spin" /> )} <span data-slot="tool-group-trigger-label" className={cn( "aui-tool-group-trigger-label-wrapper relative inline-block text-start leading-none font-medium", "group-data-[variant=outline]/tool-group-root:grow", "group-data-[variant=muted]/tool-group-root:grow", )} > <span>{label}</span> {active && ( <span aria-hidden data-slot="tool-group-trigger-shimmer" className="aui-tool-group-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > {label} </span> )} </span> <ChevronDownIcon data-slot="tool-group-trigger-chevron" className={cn( "aui-tool-group-trigger-chevron size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-out", "group-data-[state=closed]/trigger:-rotate-90", "group-data-[state=open]/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ToolGroupContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="tool-group-content" className={cn( "aui-tool-group-content relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-out", "data-[state=closed]:animate-collapsible-up", "data-[state=open]:animate-collapsible-down", "data-[state=closed]:fill-mode-forwards", "data-[state=closed]:pointer-events-none", "data-[state=open]:duration-(--animation-duration)", "data-[state=closed]:duration-(--animation-duration)", className, )} {...props} > <div className={cn( "mt-2 flex flex-col gap-2", "group-data-[variant=outline]/tool-group-root:mt-3 group-data-[variant=outline]/tool-group-root:border-t group-data-[variant=outline]/tool-group-root:px-4 group-data-[variant=outline]/tool-group-root:pt-3", "group-data-[variant=muted]/tool-group-root:mt-3 group-data-[variant=muted]/tool-group-root:border-t group-data-[variant=muted]/tool-group-root:px-4 group-data-[variant=muted]/tool-group-root:pt-3", )} > {children} </div> </CollapsibleContent> );}type ToolGroupComponent = FC< PropsWithChildren<{ startIndex: number; endIndex: number }>> & { Root: typeof ToolGroupRoot; Trigger: typeof ToolGroupTrigger; Content: typeof ToolGroupContent;};const ToolGroupImpl: FC< PropsWithChildren<{ startIndex: number; endIndex: number }>> = ({ children, startIndex, endIndex }) => { const toolCount = endIndex - startIndex + 1; return ( <ToolGroupRoot> <ToolGroupTrigger count={toolCount} /> <ToolGroupContent>{children}</ToolGroupContent> </ToolGroupRoot> );};/** * @deprecated This wrapper targets the legacy `components.ToolGroup` prop * on `<MessagePrimitive.Parts>`. Use `<MessagePrimitive.GroupedParts>` with * a `groupBy` returning `"group-tool"` and compose `ToolGroupRoot` / * `ToolGroupTrigger` / `ToolGroupContent` directly. See `thread.tsx`. */const ToolGroup = memo(ToolGroupImpl) as unknown as ToolGroupComponent;ToolGroup.displayName = "ToolGroup";ToolGroup.Root = ToolGroupRoot;ToolGroup.Trigger = ToolGroupTrigger;ToolGroup.Content = ToolGroupContent;export { ToolGroup, ToolGroupRoot, ToolGroupTrigger, ToolGroupContent, toolGroupVariants,};This adds a /components/assistant-ui/thread.tsx file to your project, which you can adjust as needed.
Use in your application
import { Thread } from "@/components/assistant-ui/thread";
export default function Chat() {
return (
<div className="h-full">
<Thread />
</div>
);
}Examples
Welcome Screen
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>Viewport Spacer
<AuiIf condition={(s) => !s.thread.isEmpty}>
<div className="min-h-8 grow" />
</AuiIf>Conditional Send/Cancel Button
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send>
Send
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel>
Cancel
</ComposerPrimitive.Cancel>
</AuiIf>Suggestions
Display suggested prompts using the Suggestions API. See the Suggestions guide for detailed configuration.
import { SuggestionPrimitive, ThreadPrimitive } from "@assistant-ui/react";
// Configure suggestions in your runtime provider
const aui = useAui({
suggestions: Suggestions(["What's the weather?", "Tell me a joke"]),
});
// Display suggestions in your thread component
<ThreadPrimitive.Suggestions>
{() => <SuggestionItem />}
</ThreadPrimitive.Suggestions>
// Custom suggestion item
const SuggestionItem = () => (
<SuggestionPrimitive.Trigger send asChild>
<button>
<SuggestionPrimitive.Title />
</button>
</SuggestionPrimitive.Trigger>
);API Reference
The following primitives are used within the Thread component and can be customized in your /components/assistant-ui/thread.tsx file.
Root
Contains all parts of the thread.
ThreadPrimitiveRootPropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper div.
className?: stringCSS class name.
This primitive renders a <div> element unless asChild is set.
Viewport
The scrollable area containing all messages. Automatically scrolls to the bottom as new messages are added.
ThreadPrimitiveViewportPropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper div.
autoScroll: boolean= true (false when turnAnchor is "top")Whether to automatically scroll to the bottom when new messages are added while the viewport was previously scrolled to the bottom.
turnAnchor: "top" | "bottom"= "bottom"Controls scroll anchoring behavior for new messages. "top" anchors new user messages at the top of the viewport.
topAnchorMessageClamp: { tallerThan?: string; visibleHeight?: string }= { tallerThan: "10em", visibleHeight: "6em" }Clamps tall user messages when turnAnchor is "top". Messages up to `tallerThan` stay fully visible; taller messages show only `visibleHeight` of their bottom edge above the assistant response.
tallerThan: string= "10em"Clamp messages taller than this CSS length.
visibleHeight: string= "6em"Visible portion of a clamped message's bottom edge.
scrollToBottomOnRunStart: boolean= trueWhether to scroll to bottom when a new run starts.
scrollToBottomOnInitialize: boolean= trueWhether to scroll to bottom when thread history is first loaded.
scrollToBottomOnThreadSwitch: boolean= trueWhether to scroll to bottom when switching to a different thread.
className?: stringCSS class name.
This primitive renders a <div> element unless asChild is set.
Messages
Renders all messages in the thread. This primitive renders a separate component for each message.
<ThreadPrimitive.Messages>
{({ message }) => {
if (message.role === "user") return <UserMessage />;
return <AssistantMessage />;
}}
</ThreadPrimitive.Messages>ThreadPrimitiveMessagesPropscomponents: MessageComponentsComponents to render for different message types.
Message?: ComponentTypeDefault component for all messages.
UserMessage?: ComponentTypeComponent for user messages.
EditComposer?: ComponentTypeComponent for user messages being edited.
AssistantMessage?: ComponentTypeComponent for assistant messages.
SystemMessage?: ComponentTypeComponent for system messages.
MessageByIndex
Renders a single message at the specified index.
<ThreadPrimitive.MessageByIndex
index={0}
components={{
UserMessage: UserMessage,
AssistantMessage: AssistantMessage
}}
/>ThreadPrimitiveMessageByIndexPropsindex: numberThe index of the message to render.
components?: MessageComponentsComponents to render for different message types.
Empty
Renders children only when there are no messages in the thread.
ThreadPrimitiveEmptyPropschildren?: ReactNodeContent to display when the thread is empty.
ScrollToBottom
A button to scroll the viewport to the bottom. Disabled when the viewport is already at the bottom.
ThreadPrimitiveScrollToBottomPropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper button.
className?: stringCSS class name.
This primitive renders a <button> element unless asChild is set.
Suggestions
Renders all configured suggestions. Configure suggestions using the Suggestions() API in your runtime provider.
<ThreadPrimitive.Suggestions>
{() => <CustomSuggestionComponent />}
</ThreadPrimitive.Suggestions>ThreadPrimitiveSuggestionsPropscomponents?: { Suggestion: ComponentType }Custom component to render each suggestion.
See the Suggestions guide for detailed information on configuring and customizing suggestions.
AuiIf
Conditionally renders children based on assistant state. This is a generic component that can access thread, message, composer, and other state.
import { AuiIf } from "@assistant-ui/react";
<AuiIf condition={(s) => s.thread.isEmpty}>
<WelcomeScreen />
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<LoadingIndicator />
</AuiIf>
<AuiIf condition={(s) => s.message.role === "assistant"}>
<AssistantAvatar />
</AuiIf>AuiIfPropscondition: (state: AssistantState) => booleanA function that receives the assistant state and returns whether to render children.
The condition function receives an AssistantState object with access to thread, message, composer, part, and attachment state depending on context.
Related Components
- ThreadList - List of threads, with or without sidebar
- Quoting guide - Quote selected text from messages
- SelectionToolbarPrimitive - Floating toolbar API reference