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.
How can I help you today?
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 { ToolFallback } from "@/components/assistant-ui/tool-fallback";import { ToolGroupContent, ToolGroupRoot, ToolGroupTrigger,} from "@/components/assistant-ui/tool-group";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { ActionBarMorePrimitive, ActionBarPrimitive, AuiIf, type AssistantState, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, groupPartByType, MessagePrimitive, SuggestionPrimitive, ThreadPrimitive, type ToolCallMessagePartComponent, useAuiState,} from "@assistant-ui/react";import { ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, DownloadIcon, MicIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon, SquareIcon,} from "lucide-react";import { createContext, useContext, type ComponentType, type FC, type PropsWithChildren,} from "react";export type ThreadGroupPart = MessagePrimitive.GroupedParts.GroupPart;/** * Optional component overrides for the thread. `AssistantMessage` and * `Welcome` replace whole sections; the remaining slots override how the * assistant message renders tool calls and part groups. Tool UIs registered * by name (toolkit `render`, `useAssistantDataUI`) take precedence over * `ToolFallback`. */export type ThreadComponents = { AssistantMessage?: ComponentType | undefined; Welcome?: ComponentType | undefined; ToolFallback?: ToolCallMessagePartComponent | undefined; ToolGroup?: | ComponentType<PropsWithChildren<{ group: ThreadGroupPart }>> | undefined; ReasoningGroup?: | ComponentType<PropsWithChildren<{ group: ThreadGroupPart }>> | undefined;};export type ThreadProps = { components?: ThreadComponents | undefined;};const EMPTY_COMPONENTS: ThreadComponents = {};const ThreadComponentsContext = createContext<ThreadComponents>(EMPTY_COMPONENTS);// Startup exposes a loading placeholder thread; treat it as a new chat so// the composer mounts centered. Loads after startup keep the docked layout.const isNewChatView = (s: AssistantState) => s.thread.messages.length === 0 && (!s.thread.isLoading || s.threads.isLoading);export const Thread: FC<ThreadProps> = ({ components = EMPTY_COMPONENTS }) => { const isEmpty = useAuiState(isNewChatView); return ( <ThreadComponentsContext.Provider value={components}> <ThreadRoot isEmpty={isEmpty} /> </ThreadComponentsContext.Provider> );};const ThreadRoot: FC<{ isEmpty: boolean }> = ({ isEmpty }) => { const { Welcome = ThreadWelcome } = useContext(ThreadComponentsContext); 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-padding" as string]: "8px", }} > <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={cn( "mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4", isEmpty && "justify-center", )} > <AuiIf condition={isNewChatView}> <Welcome /> </AuiIf> <div data-slot="aui_message-group" className="mb-14 flex flex-col gap-y-6 empty:hidden" > <ThreadPrimitive.Messages> {() => <ThreadMessage />} </ThreadPrimitive.Messages> </div> <ThreadPrimitive.ViewportFooter className={cn( "aui-thread-viewport-footer bg-background flex flex-col gap-4 overflow-visible pb-4 md:pb-6", !isEmpty && "sticky bottom-0 mt-auto rounded-t-xl", )} > <ThreadScrollToBottom /> <Composer /> <AuiIf condition={(s) => isNewChatView(s) && s.composer.isEmpty}> <ThreadSuggestions /> </AuiIf> </ThreadPrimitive.ViewportFooter> </div> </ThreadPrimitive.Viewport> </ThreadPrimitive.Root> );};const ThreadMessage: FC = () => { const { AssistantMessage: AssistantMessageComponent = AssistantMessage } = useContext(ThreadComponentsContext); 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 <AssistantMessageComponent />;};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 mb-6 flex flex-col items-center px-4 text-center"> <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"> How can I help you today? </h1> </div> );};const ThreadSuggestions: FC = () => { return ( <div className="aui-thread-welcome-suggestions flex w-full flex-wrap items-center justify-center gap-2 px-4"> <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"> <SuggestionPrimitive.Trigger send asChild> <Button variant="ghost" className="aui-thread-welcome-suggestion text-foreground hover:bg-muted border-border/60 h-auto gap-1.5 rounded-full border px-3.5 py-1.5 text-sm font-normal whitespace-nowrap transition-colors" > <SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1" /> <SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 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 border-border/60 data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50 focus-within:border-border dark:border-muted-foreground/15 dark:bg-muted/30 dark:focus-within:border-muted-foreground/30 flex w-full flex-col gap-2 rounded-3xl border p-(--composer-padding) shadow-[0_4px_16px_-8px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.04)] transition-[border-color,box-shadow] focus-within:shadow-[0_6px_24px_-8px_rgba(0,0,0,0.12),0_1px_2px_rgba(0,0,0,0.05)] data-[dragging=true]:border-dashed dark:shadow-none" > <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-2.5 py-1 text-base 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 /> <div className="flex items-center gap-1.5"> <AuiIf condition={(s) => s.thread.capabilities.dictation}> <AuiIf condition={(s) => s.composer.dictation == null}> <ComposerPrimitive.Dictate asChild> <TooltipIconButton tooltip="Voice input" side="bottom" type="button" variant="ghost" size="icon" className="aui-composer-dictate size-7 rounded-full" aria-label="Start voice input" > <MicIcon className="aui-composer-dictate-icon size-4" /> </TooltipIconButton> </ComposerPrimitive.Dictate> </AuiIf> <AuiIf condition={(s) => s.composer.dictation != null}> <ComposerPrimitive.StopDictation asChild> <TooltipIconButton tooltip="Stop dictation" side="bottom" type="button" variant="ghost" size="icon" className="aui-composer-stop-dictation text-destructive size-7 rounded-full" aria-label="Stop voice input" > <SquareIcon className="aui-composer-stop-dictation-icon size-3.5 animate-pulse fill-current" /> </TooltipIconButton> </ComposerPrimitive.StopDictation> </AuiIf> </AuiIf> <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-7 rounded-full" aria-label="Send message" > <ArrowUpIcon className="aui-composer-send-icon size-4.5" /> </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-7 rounded-full" aria-label="Stop generating" > <SquareIcon className="aui-composer-cancel-icon size-3.5 fill-current" /> </Button> </ComposerPrimitive.Cancel> </AuiIf> </div> </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 = () => { const { ToolFallback: ToolFallbackComponent = ToolFallback, ToolGroup, ReasoningGroup, } = useContext(ThreadComponentsContext); // 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-tool": if (ToolGroup) { return <ToolGroup group={part}>{children}</ToolGroup>; } return ( <ToolGroupRoot variant="ghost"> <ToolGroupTrigger count={part.indices.length} active={part.status.type === "running"} /> <ToolGroupContent>{children}</ToolGroupContent> </ToolGroupRoot> ); case "group-reasoning": { if (ReasoningGroup) { return ( <ReasoningGroup group={part}>{children}</ReasoningGroup> ); } const running = part.status.type === "running"; return ( <ReasoningRoot streaming={running}> <ReasoningTrigger active={running} /> <ReasoningContent aria-busy={running}> <ReasoningText>{children}</ReasoningText> </ReasoningContent> </ReasoningRoot> ); } case "text": return <MarkdownText />; case "reasoning": return <Reasoning {...part} />; case "tool-call": return part.toolUI ?? <ToolFallbackComponent {...part} />; case "data": return part.dataRendererUI; 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 animate-in fade-in col-start-3 row-start-2 -ms-1 flex gap-1 duration-200" > <ActionBarPrimitive.Copy asChild> <TooltipIconButton tooltip="Copy"> <AuiIf condition={(s) => s.message.isCopied}> <CheckIcon className="animate-in zoom-in-50 fade-in duration-200 ease-out" /> </AuiIf> <AuiIf condition={(s) => !s.message.isCopied}> <CopyIcon className="animate-in zoom-in-75 fade-in duration-150" /> </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" sideOffset={6} className="aui-action-bar-more-content bg-popover/95 text-popover-foreground data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-xl border p-1.5 shadow-lg backdrop-blur-sm" > <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-lg px-2.5 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-xl px-4 py-2 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"> <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-background border-border/60 dark:border-muted-foreground/15 dark:bg-muted/30 ms-auto flex w-full max-w-[85%] flex-col rounded-3xl border shadow-[0_4px_16px_-8px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-none"> <ComposerPrimitive.Input className="aui-edit-composer-input text-foreground min-h-14 w-full resize-none bg-transparent px-4 pt-3 pb-1 text-base outline-none" autoFocus /> <div className="aui-edit-composer-footer mx-2.5 mb-2.5 flex items-center gap-1.5 self-end"> <ComposerPrimitive.Cancel asChild> <Button variant="ghost" size="sm" className="h-8 rounded-full px-3.5" > Cancel </Button> </ComposerPrimitive.Cancel> <ComposerPrimitive.Send asChild> <Button size="sm" className="h-8 rounded-full px-3.5"> 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 && !isComposer && "aui-attachment-root-message only:*:first:size-24", )} > <AttachmentPreviewDialog> <TooltipTrigger asChild> <div className="aui-attachment-tile bg-muted size-14 cursor-pointer overflow-hidden rounded-md 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-7 rounded-full p-1 text-xs font-semibold" aria-label="Add Attachment" > <PlusIcon className="aui-attachment-add-icon size-4.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 active:scale-90", 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 { SyntaxHighlighter } from "@/components/assistant-ui/shiki-highlighter";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} defer /> );};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-3 flex items-center justify-between rounded-t-xl border border-b-0 px-3.5 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 className="animate-in zoom-in-75 fade-in duration-150" /> )} {isCopied && ( <CheckIcon className="animate-in zoom-in-50 fade-in duration-200 ease-out" /> )} </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({ SyntaxHighlighter, h1: ({ className, ...props }) => ( <h1 className={cn( "aui-md-h1 mt-5 mb-2 scroll-m-20 text-xl font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h2: ({ className, ...props }) => ( <h2 className={cn( "aui-md-h2 mt-5 mb-2 scroll-m-20 text-lg font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h3: ({ className, ...props }) => ( <h3 className={cn( "aui-md-h3 mt-4 mb-1.5 scroll-m-20 text-base font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h4: ({ className, ...props }) => ( <h4 className={cn( "aui-md-h4 mt-3.5 mb-1 scroll-m-20 text-base font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), h5: ({ className, ...props }) => ( <h5 className={cn( "aui-md-h5 mt-3 mb-1 text-sm font-semibold first:mt-0 last:mb-0", className, )} {...props} /> ), h6: ({ className, ...props }) => ( <h6 className={cn( "aui-md-h6 mt-3 mb-1 text-sm font-medium first:mt-0 last:mb-0", className, )} {...props} /> ), p: ({ className, ...props }) => ( <p className={cn( "aui-md-p my-3 leading-relaxed 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-3 border-s-2 ps-4", className, )} {...props} /> ), ul: ({ className, ...props }) => ( <ul className={cn( "aui-md-ul marker:text-muted-foreground my-3 ms-5 list-disc [&>li]:mt-1", className, )} {...props} /> ), ol: ({ className, ...props }) => ( <ol className={cn( "aui-md-ol marker:text-muted-foreground my-3 ms-5 list-decimal [&>li]:mt-1", className, )} {...props} /> ), hr: ({ className, ...props }) => ( <hr className={cn("aui-md-hr border-muted-foreground/20 my-3", className)} {...props} /> ), table: ({ className, ...props }) => ( <table className={cn( "aui-md-table my-3 w-full border-separate border-spacing-0 overflow-y-auto", className, )} {...props} /> ), th: ({ className, ...props }) => ( <th className={cn( "aui-md-th bg-muted px-3 py-1.5 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-3 py-1.5 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-relaxed", className)} {...props} /> ), strong: ({ className, ...props }) => ( <strong className={cn("aui-md-strong font-semibold", 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-xl border border-t-0 p-3.5 text-[13px] leading-relaxed", className, )} {...props} /> ), code: function Code({ className, ...props }) { const isCodeBlock = useIsMarkdownCodeBlock(); return ( <code className={cn( !isCodeBlock && "aui-md-inline-code bg-muted rounded-md px-1.5 py-0.5 font-mono text-[0.85em]", className, )} {...props} /> ); }, CodeHeader,});"use client";import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, 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 ReasoningPreviewContext = createContext(false);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; /** * Whether the reasoning is currently streaming. When provided, it * supersedes `defaultOpen`: the disclosure auto-opens while streaming * with a bottom-pinned live preview, auto-collapses when streaming * ends, and the first manual toggle takes over permanently. */ streaming?: boolean; };function ReasoningRoot({ className, variant, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, streaming, children, ...props}: ReasoningRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const initialOpenRef = useRef(defaultOpen); const [userOpen, setUserOpen] = useState<boolean | null>(null); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : (userOpen ?? streaming ?? initialOpenRef.current); const isAutoMode = isControlled || userOpen === null; const isPreview = streaming === true && isOpen && isAutoMode; const prevStreamingRef = useRef(streaming); useLayoutEffect(() => { if (prevStreamingRef.current === streaming) return; prevStreamingRef.current = streaming; if (!isControlled && userOpen === null) lockScroll(); }, [streaming, isControlled, userOpen, lockScroll]); const handleOpenChange = useCallback( (open: boolean) => { lockScroll(); if (!isControlled) { setUserOpen(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} > <ReasoningPreviewContext.Provider value={isPreview}> {children} </ReasoningPreviewContext.Provider> </Collapsible> );}function ReasoningFade({ side = "bottom", className, ...props}: React.ComponentProps<"div"> & { side?: "top" | "bottom" }) { if (side === "top") { return ( <div data-slot="reasoning-fade" className={cn( "aui-reasoning-fade pointer-events-none absolute inset-x-0 top-0 z-10 h-8", "bg-[linear-gradient(to_bottom,var(--color-background),transparent)]", "group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_bottom,hsl(var(--muted)/0.5),transparent)]", "fade-in-0 animate-in", "duration-(--animation-duration)", className, )} {...props} /> ); } 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>) { const isPreview = useContext(ReasoningPreviewContext); 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} > {isPreview ? <ReasoningFade side="top" /> : null} {children} <ReasoningFade /> </CollapsibleContent> );}function ReasoningText({ className, children, ...props}: React.ComponentProps<"div">) { const isPreview = useContext(ReasoningPreviewContext); const scrollRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!isPreview) return; const scrollEl = scrollRef.current; const contentEl = contentRef.current; if (!scrollEl || !contentEl) return; const pin = () => { scrollEl.scrollTop = scrollEl.scrollHeight; }; pin(); const observer = new ResizeObserver(pin); observer.observe(contentEl); return () => observer.disconnect(); }, [isPreview]); return ( <div ref={scrollRef} data-slot="reasoning-text" className={cn( "aui-reasoning-text relative z-0 max-h-64 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} > <div ref={contentRef} className="aui-reasoning-text-content space-y-4"> {children} </div> </div> );}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 streaming={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, useToolCallElapsed, type ToolApprovalOption, type ToolCallMessagePart, type ToolCallMessagePartProps, type ToolCallMessagePartStatus, type ToolCallMessagePartComponent,} from "@assistant-ui/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";import { Button } from "@/components/ui/button";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) => { 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", 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,};const formatToolDuration = (ms: number) => { if (ms < 1000) return "<1s"; const seconds = ms / 1000; if (seconds < 10) return `${(Math.floor(seconds * 10) / 10).toFixed(1)}s`; if (seconds < 60) return `${Math.floor(seconds)}s`; return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;};function ToolFallbackDuration({ className, ...props}: React.ComponentProps<"span">) { const elapsedMs = useToolCallElapsed(); if (elapsedMs === undefined) return null; return ( <span data-slot="tool-fallback-duration" className={cn( "aui-tool-fallback-duration text-muted-foreground text-xs tabular-nums", className, )} {...props} > {formatToolDuration(elapsedMs)} </span> );}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 text-muted-foreground hover:text-foreground flex w-fit items-center gap-2 py-1 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 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> <ToolFallbackDuration /> <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="flex flex-col gap-2 ps-6 pt-1 pb-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", className)} {...props} > <pre className="aui-tool-fallback-args-value bg-muted/50 text-muted-foreground rounded-md p-2.5 text-xs 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", className)} {...props} > <p className="aui-tool-fallback-result-header text-muted-foreground text-xs font-medium"> Result: </p> <pre className="aui-tool-fallback-result-content bg-muted/50 text-muted-foreground mt-1 rounded-md p-2.5 text-xs 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", 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 APPROVED_RESULT = "Approved by user";const DENIED_RESULT = "User denied tool execution";const APPROVAL_OPTION_DEFAULT_LABELS: Record<string, string> = { "allow-once": "Allow", "allow-always": "Always allow", "reject-once": "Deny", "reject-always": "Always deny",};const isAllowKind = (kind: string) => kind === "allow-once" || kind === "allow-always";const approvalOptionLabel = (option: ToolApprovalOption) => option.label ?? (Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, option.kind) ? APPROVAL_OPTION_DEFAULT_LABELS[option.kind] : undefined) ?? option.id;function ToolFallbackApproval({ className, addResult, resume, interrupt, approval, respondToApproval, ...props}: React.ComponentProps<"div"> & Partial< Pick<ToolCallMessagePartProps, "addResult" | "resume" | "respondToApproval"> > & { interrupt?: ToolCallMessagePart["interrupt"]; approval?: ToolCallMessagePart["approval"]; }) { const [submitted, setSubmitted] = useState(false); const [confirmingId, setConfirmingId] = useState<string | null>(null); if ( approval != null && (approval.approved !== undefined || approval.resolution !== undefined) ) return null; // Custom (`_`-prefixed) kinds cannot be resolved to a boolean by the kit; // hosts using custom kinds render their own bar. A declared option list is // a host constraint: the kit never adds an approval path beyond it, but // always preserves a refusal path. const declaredOptions = respondToApproval ? approval?.options : undefined; const options = declaredOptions?.filter((o) => Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, o.kind), ); const respond = (approved: boolean) => { if (submitted) return; if ( approval != null && approval.approved === undefined && respondToApproval ) { respondToApproval({ approved }); } else if (interrupt) { resume?.({ approved }); } else { addResult?.(approved ? APPROVED_RESULT : DENIED_RESULT); } setSubmitted(true); }; const respondWithOption = (option: ToolApprovalOption) => { if (submitted) return; respondToApproval?.({ optionId: option.id }); setSubmitted(true); setConfirmingId(null); }; const handleOption = (option: ToolApprovalOption) => { if (option.confirm) { setConfirmingId(option.id); } else { respondWithOption(option); } }; const confirming = confirmingId != null ? options?.find((o) => o.id === confirmingId) : undefined; if (confirming) { const confirmMeta = typeof confirming.confirm === "object" ? confirming.confirm : undefined; const confirmDescription = confirmMeta?.description ?? confirming.description; return ( <div data-slot="tool-fallback-approval-confirm" className={cn( "aui-tool-fallback-approval-confirm flex flex-col gap-2 pt-1", className, )} {...props} > <p className="aui-tool-fallback-approval-confirm-title font-semibold"> {confirmMeta?.title ?? `${approvalOptionLabel(confirming)}?`} </p> {confirmDescription && ( <p className="aui-tool-fallback-approval-confirm-description text-muted-foreground"> {confirmDescription} </p> )} {confirming.grants && confirming.grants.length > 0 && ( <ul className="aui-tool-fallback-approval-confirm-grants flex flex-col gap-1"> {confirming.grants.map((grant) => ( <li key={grant}> <code className="aui-tool-fallback-approval-confirm-grant bg-muted rounded px-1.5 py-0.5 text-xs"> {grant} </code> </li> ))} </ul> )} <div className="flex items-center gap-2"> <Button size="sm" onClick={() => respondWithOption(confirming)} disabled={submitted} > Confirm </Button> <Button size="sm" variant="outline" onClick={() => setConfirmingId(null)} disabled={submitted} > Back </Button> </div> </div> ); } if (declaredOptions && declaredOptions.length > 0) { const allowOptions = options?.filter((o) => isAllowKind(o.kind)) ?? []; const rejectOptions = options?.filter((o) => !isAllowKind(o.kind)) ?? []; return ( <div data-slot="tool-fallback-approval" className={cn( "aui-tool-fallback-approval flex flex-wrap items-center gap-2 pt-1", className, )} {...props} > {[...allowOptions, ...rejectOptions].map((option) => ( <Button key={option.id} size="sm" variant={option === allowOptions[0] ? "default" : "outline"} onClick={() => handleOption(option)} disabled={submitted} > {approvalOptionLabel(option)} </Button> ))} {rejectOptions.length === 0 && ( <Button size="sm" variant="outline" onClick={() => respond(false)} disabled={submitted} > Deny </Button> )} </div> ); } return ( <div data-slot="tool-fallback-approval" className={cn( "aui-tool-fallback-approval flex items-center gap-2 pt-1", className, )} {...props} > <Button size="sm" onClick={() => respond(true)} disabled={submitted}> Allow </Button> <Button size="sm" variant="outline" onClick={() => respond(false)} disabled={submitted} > Deny </Button> </div> );}const ToolFallbackImpl: ToolCallMessagePartComponent = ({ toolName, argsText, result, status, addResult, resume, interrupt, approval, respondToApproval,}) => { const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const isRequiresAction = status?.type === "requires-action"; const [open, setOpen] = useState(isRequiresAction); const [prevRequiresAction, setPrevRequiresAction] = useState(isRequiresAction); if (isRequiresAction !== prevRequiresAction) { setPrevRequiresAction(isRequiresAction); if (isRequiresAction) setOpen(true); } return ( <ToolFallbackRoot open={open} onOpenChange={setOpen}> <ToolFallbackTrigger toolName={toolName} status={status} /> <ToolFallbackContent> <ToolFallbackError status={status} /> <ToolFallbackArgs argsText={argsText} className={cn(isCancelled && "opacity-60")} /> {isRequiresAction && ( <ToolFallbackApproval addResult={addResult} resume={resume} interrupt={interrupt} approval={approval} respondToApproval={respondToApproval} /> )} {!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; Approval: typeof ToolFallbackApproval;};ToolFallback.displayName = "ToolFallback";ToolFallback.Root = ToolFallbackRoot;ToolFallback.Trigger = ToolFallbackTrigger;ToolFallback.Content = ToolFallbackContent;ToolFallback.Args = ToolFallbackArgs;ToolFallback.Result = ToolFallbackResult;ToolFallback.Error = ToolFallbackError;ToolFallback.Approval = ToolFallbackApproval;export { ToolFallback, ToolFallbackRoot, ToolFallbackTrigger, ToolFallbackContent, ToolFallbackArgs, ToolFallbackResult, ToolFallbackError, ToolFallbackApproval,};"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) => { 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=ghost]/tool-group-root:text-muted-foreground group-data-[variant=ghost]/tool-group-root:hover:text-foreground group-data-[variant=ghost]/tool-group-root:py-1", "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=ghost]/tool-group-root:font-normal", "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=ghost]/tool-group-root:mt-1 group-data-[variant=ghost]/tool-group-root:gap-1", "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>
);Component Overrides
Thread accepts an optional components prop that swaps parts of the rendering without editing the copied file. All slots are optional; omitted slots keep the built-in rendering.
import { Thread, type ThreadComponents } from "@/components/assistant-ui/thread";
const THREAD_COMPONENTS: ThreadComponents = {
ToolFallback: MyToolFallback,
ToolGroup: MyToolGroup,
};
export default function Chat() {
return <Thread components={THREAD_COMPONENTS} />;
}ThreadComponentsAssistantMessage?: ComponentTypeReplaces the entire assistant message, including the action bar and branch picker.
Welcome?: ComponentTypeReplaces the welcome screen shown for a new chat.
ToolFallback?: ToolCallMessagePartComponentRenders tool calls that have no tool UI registered by name. Registered tool UIs take precedence over this slot.
ToolGroup?: ComponentType<PropsWithChildren<{ group: ThreadGroupPart }>>Wraps runs of consecutive tool calls. Receives the group part (`indices`, `status`) and the rendered children.
ReasoningGroup?: ComponentType<PropsWithChildren<{ group: ThreadGroupPart }>>Wraps runs of consecutive reasoning parts. Receives the group part and the rendered children.
Define the components object once at module scope (or memoize it) so message subtrees do not re-render whenever the parent re-renders.
For per-tool UI, prefer registering a renderer by tool name over overriding ToolFallback: put render on the matching toolkit entry (see Tool UI). data message parts render through renderers registered with useAssistantDataUI; parts without a registered renderer are not displayed.
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