logoassistant-ui

Thread

A complete chat interface that combines message rendering, auto-scrolling, composer input, attachments, and conditional UI states. Fully customizable and composable.

Sample
Hello there!
How can I help you today?

Anatomy

The Thread component is built with the following primitives:

import { ThreadPrimitive, AssistantIf } from "@assistant-ui/react";

<ThreadPrimitive.Root>
  <ThreadPrimitive.Viewport>
    <ThreadPrimitive.Empty />
    <ThreadPrimitive.Messages
      components={{
        EditComposer,
        UserMessage,
        AssistantMessage,
      }}
    />
    <ThreadPrimitive.ScrollToBottom />
  </ThreadPrimitive.Viewport>
  <ThreadPrimitive.Suggestion />
  <AssistantIf condition={...} />
</ThreadPrimitive.Root>

Getting Started

Add the component

npx shadcn@latest add @assistant-ui/thread

Main Component

npm install @assistant-ui/react
yarn add @assistant-ui/react
pnpm add @assistant-ui/react
bun add @assistant-ui/react
xpm add @assistant-ui/react
import {  ArrowDownIcon,  ArrowUpIcon,  CheckIcon,  ChevronLeftIcon,  ChevronRightIcon,  CopyIcon,  PencilIcon,  RefreshCwIcon,  Square,} from "lucide-react";import {  ActionBarPrimitive,  AssistantIf,  BranchPickerPrimitive,  ComposerPrimitive,  ErrorPrimitive,  MessagePrimitive,  ThreadPrimitive,} from "@assistant-ui/react";import type { FC } from "react";import { Button } from "@/components/ui/button";import { MarkdownText } from "@/components/assistant-ui/markdown-text";import { ToolFallback } from "@/components/assistant-ui/tool-fallback";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import {  ComposerAddAttachment,  ComposerAttachments,  UserMessageAttachments,} from "@/components/assistant-ui/attachment";import { cn } from "@/lib/utils";export const Thread: FC = () => {  return (    <ThreadPrimitive.Root      className="aui-root aui-thread-root @container flex h-full flex-col bg-background"      style={{        ["--thread-max-width" as string]: "44rem",      }}    >      <ThreadPrimitive.Viewport        turnAnchor="top"        className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"      >        <AssistantIf condition={({ thread }) => thread.isEmpty}>          <ThreadWelcome />        </AssistantIf>        <ThreadPrimitive.Messages          components={{            UserMessage,            EditComposer,            AssistantMessage,          }}        />        <ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-4 flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">          <ThreadScrollToBottom />          <Composer />        </ThreadPrimitive.ViewportFooter>      </ThreadPrimitive.Viewport>    </ThreadPrimitive.Root>  );};const ThreadScrollToBottom: FC = () => {  return (    <ThreadPrimitive.ScrollToBottom asChild>      <TooltipIconButton        tooltip="Scroll to bottom"        variant="outline"        className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"      >        <ArrowDownIcon />      </TooltipIconButton>    </ThreadPrimitive.ScrollToBottom>  );};const ThreadWelcome: FC = () => {  return (    <div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) 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-8">          <div className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in font-semibold text-2xl duration-300 ease-out">            Hello there!          </div>          <div className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-2xl text-muted-foreground/65 delay-100 duration-300 ease-out">            How can I help you today?          </div>        </div>      </div>      <ThreadSuggestions />    </div>  );};const ThreadSuggestions: FC = () => {  return (    <div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">      {[        {          title: "What's the weather",          label: "in San Francisco?",          action: "What's the weather in San Francisco?",        },        {          title: "Explain React hooks",          label: "like useState and useEffect",          action: "Explain React hooks like useState and useEffect",        },        {          title: "Write a SQL query",          label: "to find top customers",          action: "Write a SQL query to find top customers",        },        {          title: "Create a meal plan",          label: "for healthy weight loss",          action: "Create a meal plan for healthy weight loss",        },      ].map((suggestedAction, index) => (        <div          key={`suggested-action-${suggestedAction.title}-${index}`}          className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-4 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-300 ease-out"          style={{ animationDelay: `${index * 50}ms` }}        >          <ThreadPrimitive.Suggestion            prompt={suggestedAction.action}            send            asChild          >            <Button              variant="ghost"              className="aui-thread-welcome-suggestion h-auto w-full flex-1 @md:flex-col flex-wrap items-start justify-start gap-1 rounded-3xl border px-5 py-4 text-left text-sm dark:hover:bg-accent/60"              aria-label={suggestedAction.action}            >              <span className="aui-thread-welcome-suggestion-text-1 font-medium">                {suggestedAction.title}              </span>              <span className="aui-thread-welcome-suggestion-text-2 text-muted-foreground">                {suggestedAction.label}              </span>            </Button>          </ThreadPrimitive.Suggestion>        </div>      ))}    </div>  );};const Composer: FC = () => {  return (    <ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">      <ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-3xl border border-input bg-background px-1 pt-2 shadow-xs outline-none transition-[color,box-shadow] has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-[3px] has-[textarea:focus-visible]:ring-ring/50 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50 dark:bg-background">        <ComposerAttachments />        <ComposerPrimitive.Input          placeholder="Send a message..."          className="aui-composer-input mb-1 max-h-32 min-h-16 w-full resize-none bg-transparent px-3.5 pt-1.5 pb-3 text-base outline-none placeholder:text-muted-foreground focus-visible:ring-0"          rows={1}          autoFocus          aria-label="Message input"        />        <ComposerAction />      </ComposerPrimitive.AttachmentDropzone>    </ComposerPrimitive.Root>  );};const ComposerAction: FC = () => {  return (    <div className="aui-composer-action-wrapper relative mx-1 mt-2 mb-2 flex items-center justify-between">      <ComposerAddAttachment />      <AssistantIf condition={({ thread }) => !thread.isRunning}>        <ComposerPrimitive.Send asChild>          <TooltipIconButton            tooltip="Send message"            side="bottom"            type="submit"            variant="default"            size="icon"            className="aui-composer-send size-[34px] rounded-full p-1"            aria-label="Send message"          >            <ArrowUpIcon className="aui-composer-send-icon size-5" />          </TooltipIconButton>        </ComposerPrimitive.Send>      </AssistantIf>      <AssistantIf condition={({ thread }) => thread.isRunning}>        <ComposerPrimitive.Cancel asChild>          <Button            type="button"            variant="default"            size="icon"            className="aui-composer-cancel size-[34px] rounded-full border border-muted-foreground/60 hover:bg-primary/75 dark:border-muted-foreground/90"            aria-label="Stop generating"          >            <Square className="aui-composer-cancel-icon size-3.5 fill-white dark:fill-black" />          </Button>        </ComposerPrimitive.Cancel>      </AssistantIf>    </div>  );};const MessageError: FC = () => {  return (    <MessagePrimitive.Error>      <ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">        <ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />      </ErrorPrimitive.Root>    </MessagePrimitive.Error>  );};const AssistantMessage: FC = () => {  return (    <MessagePrimitive.Root      className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-4 duration-150 ease-out"      data-role="assistant"    >      <div className="aui-assistant-message-content wrap-break-word mx-2 text-foreground leading-7">        <MessagePrimitive.Parts          components={{            Text: MarkdownText,            tools: { Fallback: ToolFallback },          }}        />        <MessageError />      </div>      <div className="aui-assistant-message-footer mt-2 ml-2 flex">        <BranchPicker />        <AssistantActionBar />      </div>    </MessagePrimitive.Root>  );};const AssistantActionBar: FC = () => {  return (    <ActionBarPrimitive.Root      hideWhenRunning      autohide="not-last"      autohideFloat="single-branch"      className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"    >      <ActionBarPrimitive.Copy asChild>        <TooltipIconButton tooltip="Copy">          <AssistantIf condition={({ message }) => message.isCopied}>            <CheckIcon />          </AssistantIf>          <AssistantIf condition={({ message }) => !message.isCopied}>            <CopyIcon />          </AssistantIf>        </TooltipIconButton>      </ActionBarPrimitive.Copy>      <ActionBarPrimitive.Reload asChild>        <TooltipIconButton tooltip="Refresh">          <RefreshCwIcon />        </TooltipIconButton>      </ActionBarPrimitive.Reload>    </ActionBarPrimitive.Root>  );};const UserMessage: FC = () => {  return (    <MessagePrimitive.Root      className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-4 duration-150 ease-out [&: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 wrap-break-word rounded-3xl bg-muted px-5 py-2.5 text-foreground">          <MessagePrimitive.Parts />        </div>        <div className="aui-user-action-bar-wrapper -translate-x-full -translate-y-1/2 absolute top-1/2 left-0 pr-2">          <UserActionBar />        </div>      </div>      <BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 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 className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 px-2">      <ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-7/8 flex-col rounded-xl bg-muted">        <ComposerPrimitive.Input          className="aui-edit-composer-input flex min-h-[60px] w-full resize-none bg-transparent p-4 text-foreground outline-none"          autoFocus        />        <div className="aui-edit-composer-footer mx-3 mb-3 flex items-center justify-center gap-2 self-end">          <ComposerPrimitive.Cancel asChild>            <Button variant="ghost" size="sm" aria-label="Cancel edit">              Cancel            </Button>          </ComposerPrimitive.Cancel>          <ComposerPrimitive.Send asChild>            <Button size="sm" aria-label="Update message">              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 -ml-2 mr-2 inline-flex items-center text-muted-foreground 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 remark-gfm zustand
yarn add @assistant-ui/react @assistant-ui/react-markdown remark-gfm zustand
pnpm add @assistant-ui/react @assistant-ui/react-markdown remark-gfm zustand
bun add @assistant-ui/react @assistant-ui/react-markdown remark-gfm zustand
xpm add @assistant-ui/react @assistant-ui/react-markdown remark-gfm zustand
"use client";import { PropsWithChildren, useEffect, useState, type FC } from "react";import Image from "next/image";import { XIcon, PlusIcon, FileText } from "lucide-react";import {  AttachmentPrimitive,  ComposerPrimitive,  MessagePrimitive,  useAssistantState,  useAssistantApi,} 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 } = useAssistantState(    useShallow(({ attachment }): { file?: File; src?: string } => {      if (attachment.type !== "image") return {};      if (attachment.file) return { file: attachment.file };      const src = 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 (    <Image      src={src}      alt="Image Preview"      width={1}      height={1}      className={        isLoaded          ? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"          : "aui-attachment-preview-image-loading hidden"      }      onLoadingComplete={() => setIsLoaded(true)}      priority={false}    />  );};const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {  const src = useAttachmentSrc();  if (!src) return children;  return (    <Dialog>      <DialogTrigger        className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"        asChild      >        {children}      </DialogTrigger>      <DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">        <DialogTitle className="aui-sr-only sr-only">          Image Attachment Preview        </DialogTitle>        <div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">          <AttachmentPreview src={src} />        </div>      </DialogContent>    </Dialog>  );};const AttachmentThumb: FC = () => {  const isImage = useAssistantState(    ({ attachment }) => attachment.type === "image",  );  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 delayMs={isImage ? 200 : 0}>        <FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />      </AvatarFallback>    </Avatar>  );};const AttachmentUI: FC = () => {  const api = useAssistantApi();  const isComposer = api.attachment.source === "composer";  const isImage = useAssistantState(    ({ attachment }) => attachment.type === "image",  );  const typeLabel = useAssistantState(({ attachment }) => {    const type = attachment.type;    switch (type) {      case "image":        return "Image";      case "document":        return "Document";      case "file":        return "File";      default:        const _exhaustiveCheck: never = type;        throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);    }  });  return (    <Tooltip>      <AttachmentPrimitive.Root        className={cn(          "aui-attachment-root relative",          isImage &&            "aui-attachment-root-composer only:[&>#attachment-tile]:size-24",        )}      >        <AttachmentPreviewDialog>          <TooltipTrigger asChild>            <div              className={cn(                "aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",                isComposer &&                  "aui-attachment-tile-composer border-foreground/20",              )}              role="button"              id="attachment-tile"              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 absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"        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 components={{ Attachment: AttachmentUI }} />    </div>  );};export const ComposerAttachments: FC = () => {  return (    <div className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">      <ComposerPrimitive.Attachments        components={{ Attachment: AttachmentUI }}      />    </div>  );};export const ComposerAddAttachment: FC = () => {  return (    <ComposerPrimitive.AddAttachment asChild>      <TooltipIconButton        tooltip="Add Attachment"        side="bottom"        variant="ghost"        size="icon"        className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"        aria-label="Add Attachment"      >        <PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />      </TooltipIconButton>    </ComposerPrimitive.AddAttachment>  );};
"use client";import { ComponentPropsWithRef, forwardRef } from "react";import { Slottable } from "@radix-ui/react-slot";import {  Tooltip,  TooltipContent,  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 (    <Tooltip>      <TooltipTrigger asChild>        <Button          variant="ghost"          size="icon"          {...rest}          className={cn("aui-button-icon size-6 p-1", className)}          ref={ref}        >          <Slottable>{children}</Slottable>          <span className="aui-sr-only sr-only">{tooltip}</span>        </Button>      </TooltipTrigger>      <TooltipContent side={side}>{tooltip}</TooltipContent>    </Tooltip>  );});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 mt-4 flex items-center justify-between gap-4 rounded-t-lg bg-muted-foreground/15 px-4 py-2 font-semibold text-foreground text-sm dark:bg-muted-foreground/20">      <span className="aui-code-header-language lowercase [&>span]:text-xs">        {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) 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-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",        className,      )}      {...props}    />  ),  h2: ({ className, ...props }) => (    <h2      className={cn(        "aui-md-h2 mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",        className,      )}      {...props}    />  ),  h3: ({ className, ...props }) => (    <h3      className={cn(        "aui-md-h3 mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",        className,      )}      {...props}    />  ),  h4: ({ className, ...props }) => (    <h4      className={cn(        "aui-md-h4 mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",        className,      )}      {...props}    />  ),  h5: ({ className, ...props }) => (    <h5      className={cn(        "aui-md-h5 my-4 font-semibold text-lg first:mt-0 last:mb-0",        className,      )}      {...props}    />  ),  h6: ({ className, ...props }) => (    <h6      className={cn(        "aui-md-h6 my-4 font-semibold first:mt-0 last:mb-0",        className,      )}      {...props}    />  ),  p: ({ className, ...props }) => (    <p      className={cn(        "aui-md-p mt-5 mb-5 leading-7 first:mt-0 last:mb-0",        className,      )}      {...props}    />  ),  a: ({ className, ...props }) => (    <a      className={cn(        "aui-md-a font-medium text-primary underline underline-offset-4",        className,      )}      {...props}    />  ),  blockquote: ({ className, ...props }) => (    <blockquote      className={cn("aui-md-blockquote border-l-2 pl-6 italic", className)}      {...props}    />  ),  ul: ({ className, ...props }) => (    <ul      className={cn("aui-md-ul my-5 ml-6 list-disc [&>li]:mt-2", className)}      {...props}    />  ),  ol: ({ className, ...props }) => (    <ol      className={cn("aui-md-ol my-5 ml-6 list-decimal [&>li]:mt-2", className)}      {...props}    />  ),  hr: ({ className, ...props }) => (    <hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />  ),  table: ({ className, ...props }) => (    <table      className={cn(        "aui-md-table my-5 w-full border-separate border-spacing-0 overflow-y-auto",        className,      )}      {...props}    />  ),  th: ({ className, ...props }) => (    <th      className={cn(        "aui-md-th bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [[align=center]]:text-center [[align=right]]:text-right",        className,      )}      {...props}    />  ),  td: ({ className, ...props }) => (    <td      className={cn(        "aui-md-td border-b border-l px-4 py-2 text-left last:border-r [[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-bl-lg [&:last-child>td:last-child]:rounded-br-lg",        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 overflow-x-auto rounded-t-none! rounded-b-lg bg-black p-4 text-white",        className,      )}      {...props}    />  ),  code: function Code({ className, ...props }) {    const isCodeBlock = useIsMarkdownCodeBlock();    return (      <code        className={cn(          !isCodeBlock &&            "aui-md-inline-code rounded border bg-muted font-semibold",          className,        )}        {...props}      />    );  },  CodeHeader,});
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";import {  CheckIcon,  ChevronDownIcon,  ChevronUpIcon,  XCircleIcon,} from "lucide-react";import { useState } from "react";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export const ToolFallback: ToolCallMessagePartComponent = ({  toolName,  argsText,  result,  status,}) => {  const [isCollapsed, setIsCollapsed] = useState(true);  const isCancelled =    status?.type === "incomplete" && status.reason === "cancelled";  const cancelledReason =    isCancelled && status.error      ? typeof status.error === "string"        ? status.error        : JSON.stringify(status.error)      : null;  return (    <div      className={cn(        "aui-tool-fallback-root mb-4 flex w-full flex-col gap-3 rounded-lg border py-3",        isCancelled && "border-muted-foreground/30 bg-muted/30",      )}    >      <div className="aui-tool-fallback-header flex items-center gap-2 px-4">        {isCancelled ? (          <XCircleIcon className="aui-tool-fallback-icon size-4 text-muted-foreground" />        ) : (          <CheckIcon className="aui-tool-fallback-icon size-4" />        )}        <p          className={cn(            "aui-tool-fallback-title grow",            isCancelled && "text-muted-foreground line-through",          )}        >          {isCancelled ? "Cancelled tool: " : "Used tool: "}          <b>{toolName}</b>        </p>        <Button onClick={() => setIsCollapsed(!isCollapsed)}>          {isCollapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}        </Button>      </div>      {!isCollapsed && (        <div className="aui-tool-fallback-content flex flex-col gap-2 border-t pt-2">          {cancelledReason && (            <div className="aui-tool-fallback-cancelled-root px-4">              <p className="aui-tool-fallback-cancelled-header font-semibold text-muted-foreground">                Cancelled reason:              </p>              <p className="aui-tool-fallback-cancelled-reason text-muted-foreground">                {cancelledReason}              </p>            </div>          )}          <div            className={cn(              "aui-tool-fallback-args-root px-4",              isCancelled && "opacity-60",            )}          >            <pre className="aui-tool-fallback-args-value whitespace-pre-wrap">              {argsText}            </pre>          </div>          {!isCancelled && result !== undefined && (            <div className="aui-tool-fallback-result-root border-t border-dashed px-4 pt-2">              <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>          )}        </div>      )}    </div>  );};

shadcn/ui dependencies

npm install @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
yarn add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
pnpm add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
bun add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
xpm add @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-slot @radix-ui/react-tooltip
import * as React from "react";import { Slot } from "@radix-ui/react-slot";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva(  "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm no-underline outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",  {    variants: {      variant: {        default:          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",        destructive:          "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",        outline:          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",        secondary:          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",        ghost:          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",        link: "text-primary underline-offset-4 hover:underline",      },      size: {        default: "h-9 px-4 py-2 has-[>svg]:px-3",        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",        icon: "size-9",      },    },    defaultVariants: {      variant: "default",      size: "default",    },  },);function Button({  className,  variant,  size,  asChild = false,  ...props}: React.ComponentProps<"button"> &  VariantProps<typeof buttonVariants> & {    asChild?: boolean;  }) {  const Comp = asChild ? Slot : "button";  return (    <Comp      data-slot="button"      className={cn(buttonVariants({ variant, size, className }))}      {...props}    />  );}export { Button, buttonVariants };
"use client";import * as React from "react";import * as DialogPrimitive from "@radix-ui/react-dialog";import { XIcon } from "lucide-react";import { cn } from "@/lib/utils";function Dialog({  ...props}: React.ComponentProps<typeof DialogPrimitive.Root>) {  return <DialogPrimitive.Root data-slot="dialog" {...props} />;}function DialogTrigger({  ...props}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;}function DialogPortal({  ...props}: React.ComponentProps<typeof DialogPrimitive.Portal>) {  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;}function DialogClose({  ...props}: React.ComponentProps<typeof DialogPrimitive.Close>) {  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;}function DialogOverlay({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {  return (    <DialogPrimitive.Overlay      data-slot="dialog-overlay"      className={cn(        "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",        className,      )}      {...props}    />  );}function DialogContent({  className,  children,  ...props}: React.ComponentProps<typeof DialogPrimitive.Content>) {  return (    <DialogPortal data-slot="dialog-portal">      <DialogOverlay />      <DialogPrimitive.Content        data-slot="dialog-content"        className={cn(          "data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",          className,        )}        {...props}      >        {children}        <DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0">          <XIcon />          <span className="sr-only">Close</span>        </DialogPrimitive.Close>      </DialogPrimitive.Content>    </DialogPortal>  );}function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="dialog-header"      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}      {...props}    />  );}function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="dialog-footer"      className={cn(        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",        className,      )}      {...props}    />  );}function DialogTitle({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Title>) {  return (    <DialogPrimitive.Title      data-slot="dialog-title"      className={cn("font-semibold text-lg leading-none", className)}      {...props}    />  );}function DialogDescription({  className,  ...props}: React.ComponentProps<typeof DialogPrimitive.Description>) {  return (    <DialogPrimitive.Description      data-slot="dialog-description"      className={cn("text-muted-foreground text-sm", className)}      {...props}    />  );}export {  Dialog,  DialogClose,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogOverlay,  DialogPortal,  DialogTitle,  DialogTrigger,};
"use client";import * as React from "react";import * as TooltipPrimitive from "@radix-ui/react-tooltip";import { cn } from "@/lib/utils";function TooltipProvider({  delayDuration = 0,  ...props}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {  return (    <TooltipPrimitive.Provider      data-slot="tooltip-provider"      delayDuration={delayDuration}      {...props}    />  );}function Tooltip({  ...props}: React.ComponentProps<typeof TooltipPrimitive.Root>) {  return (    <TooltipProvider>      <TooltipPrimitive.Root data-slot="tooltip" {...props} />    </TooltipProvider>  );}function TooltipTrigger({  ...props}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;}function TooltipContent({  className,  sideOffset = 0,  children,  ...props}: React.ComponentProps<typeof TooltipPrimitive.Content>) {  return (    <TooltipPrimitive.Portal>      <TooltipPrimitive.Content        data-slot="tooltip-content"        sideOffset={sideOffset}        className={cn(          "fade-in-0 zoom-in-95 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 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",          className,        )}        {...props}      >        {children}        <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />      </TooltipPrimitive.Content>    </TooltipPrimitive.Portal>  );}export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
"use client";import * as React from "react";import * as AvatarPrimitive from "@radix-ui/react-avatar";import { cn } from "@/lib/utils";function Avatar({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Root>) {  return (    <AvatarPrimitive.Root      data-slot="avatar"      className={cn(        "relative flex size-8 shrink-0 overflow-hidden rounded-full",        className,      )}      {...props}    />  );}function AvatarImage({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Image>) {  return (    <AvatarPrimitive.Image      data-slot="avatar-image"      className={cn("aspect-square size-full", className)}      {...props}    />  );}function AvatarFallback({  className,  ...props}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {  return (    <AvatarPrimitive.Fallback      data-slot="avatar-fallback"      className={cn(        "flex size-full items-center justify-center rounded-full bg-muted",        className,      )}      {...props}    />  );}export { Avatar, AvatarImage, AvatarFallback };

This adds a /components/assistant-ui/thread.tsx file to your project, which you can adjust as needed.

Use in your application

/app/page.tsx
import { Thread } from "@/components/assistant-ui/thread";

export default function Chat() {
  return (
    <div className="h-full">
      <Thread />
    </div>
  );
}

Examples

Welcome Screen

<AssistantIf condition={({ thread }) => thread.isEmpty}>
  <ThreadWelcome />
</AssistantIf>

Viewport Spacer

<AssistantIf condition={({ thread }) => !thread.isEmpty}>
  <div className="min-h-8 grow" />
</AssistantIf>

Conditional Send/Cancel Button

<AssistantIf condition={({ thread }) => !thread.isRunning}>
  <ComposerPrimitive.Send>
    Send
  </ComposerPrimitive.Send>
</AssistantIf>

<AssistantIf condition={({ thread }) => thread.isRunning}>
  <ComposerPrimitive.Cancel>
    Cancel
  </ComposerPrimitive.Cancel>
</AssistantIf>

Suggestions

<ThreadPrimitive.Suggestion
  prompt="What's the weather in San Francisco?"
  send
/>

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.

ThreadPrimitiveRootProps

asChild:

boolean = false

Merge props with child element instead of rendering a wrapper div.

className?:

string

CSS 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.

ThreadPrimitiveViewportProps

asChild:

boolean = false

Merge props with child element instead of rendering a wrapper div.

autoScroll:

boolean = true

Whether to automatically scroll to the bottom when new messages are added while the viewport was previously scrolled to the bottom.

className?:

string

CSS 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
  components={{
    UserMessage: UserMessage,
    EditComposer: EditComposer,
    AssistantMessage: AssistantMessage,
  }}
/>

ThreadPrimitiveMessagesProps

components:

MessageComponents

Components to render for different message types.

MessageComponents

Message?:

ComponentType

Default component for all messages.

UserMessage?:

ComponentType

Component for user messages.

EditComposer?:

ComponentType

Component for user messages being edited.

AssistantMessage?:

ComponentType

Component for assistant messages.

SystemMessage?:

ComponentType

Component for system messages.

MessageByIndex

Renders a single message at the specified index.

<ThreadPrimitive.MessageByIndex
  index={0}
  components={{
    UserMessage: UserMessage,
    AssistantMessage: AssistantMessage
  }}
/>

ThreadPrimitiveMessageByIndexProps

index:

number

The index of the message to render.

components?:

MessageComponents

Components to render for different message types.

Empty

Renders children only when there are no messages in the thread.

ThreadPrimitiveEmptyProps

children?:

ReactNode

Content 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.

ThreadPrimitiveScrollToBottomProps

asChild:

boolean = false

Merge props with child element instead of rendering a wrapper button.

className?:

string

CSS class name.

This primitive renders a <button> element unless asChild is set.

Suggestion

Shows a suggestion to the user. When clicked, replaces the composer's value with the suggestion and optionally sends it.

<ThreadPrimitive.Suggestion
  prompt="Tell me about React hooks"
  send
/>

ThreadPrimitiveSuggestionProps

prompt:

string

The suggestion text to use when clicked.

send?:

boolean

When true, automatically sends the message. When false, replaces or appends the composer text with the suggestion - depending on the value of `clearComposer`

clearComposer:

boolean = true

Whether to clear the composer after sending. When send is set to false, determines if composer text is replaced with suggestion (true, default), or if the suggestion's prompt is appended to the composer text (false).

autoSend?:

boolean

Deprecated. Use 'send' instead.

method?:

'replace'

Deprecated. This parameter is no longer used.

asChild:

boolean = false

Merge props with child element instead of rendering a wrapper button.

className?:

string

CSS class name.

This primitive renders a <button> element unless asChild is set.

AssistantIf

Conditionally renders children based on assistant state. This is a generic component that can access thread, message, composer, and other state.

import { AssistantIf } from "@assistant-ui/react";

<AssistantIf condition={({ thread }) => thread.isEmpty}>
  <WelcomeScreen />
</AssistantIf>

<AssistantIf condition={({ thread }) => thread.isRunning}>
  <LoadingIndicator />
</AssistantIf>

<AssistantIf condition={({ message }) => message.role === "assistant"}>
  <AssistantAvatar />
</AssistantIf>

AssistantIfProps

condition:

(state: AssistantState) => boolean

A 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.

  • ThreadList - List of threads, with or without sidebar