Floating chat bubble for support widgets and help desks.
A floating chat modal built on Radix UI Popover. Ideal for support widgets, help desks, and embedded assistants.
Getting Started
Add assistant-modal
npx shadcn@latest add https://r.assistant-ui.com/assistant-modal.jsonMain Component
npm install @assistant-ui/react"use client";import { BotIcon, ChevronDownIcon } from "lucide-react";import { type FC, forwardRef } from "react";import { AssistantModalPrimitive } from "@assistant-ui/react";import { Thread } from "@/components/assistant-ui/thread";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";export const AssistantModal: FC = () => { return ( <AssistantModalPrimitive.Root> <AssistantModalPrimitive.Anchor className="aui-root aui-modal-anchor fixed right-4 bottom-4 size-11"> <AssistantModalPrimitive.Trigger asChild> <AssistantModalButton /> </AssistantModalPrimitive.Trigger> </AssistantModalPrimitive.Anchor> <AssistantModalPrimitive.Content sideOffset={16} className="aui-root aui-modal-content data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-bottom-1/2 data-[state=closed]:slide-out-to-right-1/2 data-[state=closed]:zoom-out data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-bottom-1/2 data-[state=open]:slide-in-from-right-1/2 data-[state=open]:zoom-in z-50 h-125 w-100 overflow-clip overscroll-contain rounded-xl border bg-popover p-0 text-popover-foreground shadow-md outline-none data-[state=closed]:animate-out data-[state=open]:animate-in [&>.aui-thread-root]:bg-inherit [&>.aui-thread-root_.aui-thread-viewport-footer]:bg-inherit" > <Thread /> </AssistantModalPrimitive.Content> </AssistantModalPrimitive.Root> );};type AssistantModalButtonProps = { "data-state"?: "open" | "closed" };const AssistantModalButton = forwardRef< HTMLButtonElement, AssistantModalButtonProps>(({ "data-state": state, ...rest }, ref) => { const tooltip = state === "open" ? "Close Assistant" : "Open Assistant"; return ( <TooltipIconButton variant="default" tooltip={tooltip} side="left" {...rest} className="aui-modal-button size-full rounded-full shadow transition-transform hover:scale-110 active:scale-90" ref={ref} > <BotIcon data-state={state} className="aui-modal-button-closed-icon absolute size-6 transition-all data-[state=closed]:rotate-0 data-[state=open]:rotate-90 data-[state=closed]:scale-100 data-[state=open]:scale-0" /> <ChevronDownIcon data-state={state} className="aui-modal-button-open-icon absolute size-6 transition-all data-[state=closed]:-rotate-90 data-[state=open]:rotate-0 data-[state=closed]:scale-0 data-[state=open]:scale-100" /> <span className="aui-sr-only sr-only">{tooltip}</span> </TooltipIconButton> );});AssistantModalButton.displayName = "AssistantModalButton";assistant-ui dependencies
npm install @assistant-ui/react @assistant-ui/react-markdown radix-ui remark-gfm zustandimport { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments,} from "@/components/assistant-ui/attachment";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 { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";import { ActionBarMorePrimitive, ActionBarPrimitive, AuiIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, SuggestionPrimitive, ThreadPrimitive,} from "@assistant-ui/react";import { ArrowDownIcon, ArrowUpIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, DownloadIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon, SquareIcon,} from "lucide-react";import type { FC } from "react";export const Thread: FC = () => { return ( <ThreadPrimitive.Root className="aui-root aui-thread-root @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" > <AuiIf condition={(s) => s.thread.isEmpty}> <ThreadWelcome /> </AuiIf> <ThreadPrimitive.Messages components={{ UserMessage, EditComposer, AssistantMessage, }} /> <ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto 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 absolute -top-12 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-4"> <h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200"> Hello there! </h1> <p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200"> How can I help you today? </p> </div> </div> <ThreadSuggestions /> </div> );};const ThreadSuggestions: FC = () => { return ( <div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4"> <ThreadPrimitive.Suggestions components={{ Suggestion: ThreadSuggestionItem, }} /> </div> );};const ThreadSuggestionItem: FC = () => { return ( <div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200"> <SuggestionPrimitive.Trigger send asChild> <Button variant="ghost" className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-2xl border px-4 py-3 text-left text-sm transition-colors hover:bg-muted" > <span className="aui-thread-welcome-suggestion-text-1 font-medium"> <SuggestionPrimitive.Title /> </span> <span className="aui-thread-welcome-suggestion-text-2 text-muted-foreground"> <SuggestionPrimitive.Description /> </span> </Button> </SuggestionPrimitive.Trigger> </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-2xl border border-input bg-background px-1 pt-2 outline-none transition-shadow has-[textarea:focus-visible]:border-ring has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50"> <ComposerAttachments /> <ComposerPrimitive.Input placeholder="Send a message..." className="aui-composer-input mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm 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-2 mb-2 flex items-center justify-between"> <ComposerAddAttachment /> <AuiIf condition={(s) => !s.thread.isRunning}> <ComposerPrimitive.Send asChild> <TooltipIconButton tooltip="Send message" side="bottom" type="button" variant="default" size="icon" className="aui-composer-send size-8 rounded-full" aria-label="Send message" > <ArrowUpIcon className="aui-composer-send-icon size-4" /> </TooltipIconButton> </ComposerPrimitive.Send> </AuiIf> <AuiIf condition={(s) => s.thread.isRunning}> <ComposerPrimitive.Cancel asChild> <Button type="button" variant="default" size="icon" className="aui-composer-cancel size-8 rounded-full" aria-label="Stop generating" > <SquareIcon className="aui-composer-cancel-icon size-3 fill-current" /> </Button> </ComposerPrimitive.Cancel> </AuiIf> </div> );};const MessageError: FC = () => { return ( <MessagePrimitive.Error> <ErrorPrimitive.Root className="aui-message-error-root 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-3 duration-150" data-role="assistant" > <div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed"> <MessagePrimitive.Parts components={{ Text: MarkdownText, tools: { Fallback: ToolFallback }, }} /> <MessageError /> </div> <div className="aui-assistant-message-footer mt-1 ml-2 flex min-h-6 items-center"> <BranchPicker /> <AssistantActionBar /> </div> </MessagePrimitive.Root> );};const AssistantActionBar: FC = () => { return ( <ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground" > <ActionBarPrimitive.Copy asChild> <TooltipIconButton tooltip="Copy"> <AuiIf condition={(s) => s.message.isCopied}> <CheckIcon /> </AuiIf> <AuiIf condition={(s) => !s.message.isCopied}> <CopyIcon /> </AuiIf> </TooltipIconButton> </ActionBarPrimitive.Copy> <ActionBarPrimitive.Reload asChild> <TooltipIconButton tooltip="Refresh"> <RefreshCwIcon /> </TooltipIconButton> </ActionBarPrimitive.Reload> <ActionBarMorePrimitive.Root> <ActionBarMorePrimitive.Trigger asChild> <TooltipIconButton tooltip="More" className="data-[state=open]:bg-accent" > <MoreHorizontalIcon /> </TooltipIconButton> </ActionBarMorePrimitive.Trigger> <ActionBarMorePrimitive.Content side="bottom" align="start" className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md" > <ActionBarPrimitive.ExportMarkdown asChild> <ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"> <DownloadIcon className="size-4" /> Export as Markdown </ActionBarMorePrimitive.Item> </ActionBarPrimitive.ExportMarkdown> </ActionBarMorePrimitive.Content> </ActionBarMorePrimitive.Root> </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-3 duration-150 [&: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-2xl bg-muted px-4 py-2.5 text-foreground"> <MessagePrimitive.Parts /> </div> <div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2"> <UserActionBar /> </div> </div> <BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" /> </MessagePrimitive.Root> );};const UserActionBar: FC = () => { return ( <ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className="aui-user-action-bar-root flex flex-col items-end" > <ActionBarPrimitive.Edit asChild> <TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4"> <PencilIcon /> </TooltipIconButton> </ActionBarPrimitive.Edit> </ActionBarPrimitive.Root> );};const EditComposer: FC = () => { return ( <MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3"> <ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted"> <ComposerPrimitive.Input className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none" autoFocus /> <div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end"> <ComposerPrimitive.Cancel asChild> <Button variant="ghost" size="sm"> Cancel </Button> </ComposerPrimitive.Cancel> <ComposerPrimitive.Send asChild> <Button size="sm">Update</Button> </ComposerPrimitive.Send> </div> </ComposerPrimitive.Root> </MessagePrimitive.Root> );};const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest}) => { return ( <BranchPickerPrimitive.Root hideWhenSingleBranch className={cn( "aui-branch-picker-root mr-2 -ml-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> );};"use client";import { 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="Image 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 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 = useAuiState((s) => s.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 aui = useAui(); const isComposer = aui.attachment.source === "composer"; const isImage = useAuiState((s) => s.attachment.type === "image"); const typeLabel = useAuiState((s) => { const type = s.attachment.type; switch (type) { case "image": return "Image"; case "document": return "Document"; case "file": return "File"; default: return type; } }); return ( <Tooltip> <AttachmentPrimitive.Root className={cn( "aui-attachment-root relative", isImage && "aui-attachment-root-composer only:[&>#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-8.5 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 { Slot } from "radix-ui";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} > <Slot.Slottable>{children}</Slot.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-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs"> <span className="aui-code-header-language font-medium text-muted-foreground lowercase"> {language} </span> <TooltipIconButton tooltip="Copy" onClick={onCopy}> {!isCopied && <CopyIcon />} {isCopied && <CheckIcon />} </TooltipIconButton> </div> );};const useCopyToClipboard = ({ copiedDuration = 3000,}: { copiedDuration?: number;} = {}) => { const [isCopied, setIsCopied] = useState<boolean>(false); const copyToClipboard = (value: string) => { if (!value) return; navigator.clipboard.writeText(value).then(() => { setIsCopied(true); setTimeout(() => setIsCopied(false), copiedDuration); }); }; return { isCopied, copyToClipboard };};const defaultComponents = memoizeMarkdownComponents({ h1: ({ className, ...props }) => ( <h1 className={cn( "aui-md-h1 mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0", className, )} {...props} /> ), h2: ({ className, ...props }) => ( <h2 className={cn( "aui-md-h2 mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0", className, )} {...props} /> ), h3: ({ className, ...props }) => ( <h3 className={cn( "aui-md-h3 mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0", className, )} {...props} /> ), h4: ({ className, ...props }) => ( <h4 className={cn( "aui-md-h4 mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0", className, )} {...props} /> ), h5: ({ className, ...props }) => ( <h5 className={cn( "aui-md-h5 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0", className, )} {...props} /> ), h6: ({ className, ...props }) => ( <h6 className={cn( "aui-md-h6 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0", className, )} {...props} /> ), p: ({ className, ...props }) => ( <p className={cn( "aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0", className, )} {...props} /> ), a: ({ className, ...props }) => ( <a className={cn( "aui-md-a text-primary underline underline-offset-2 hover:text-primary/80", className, )} {...props} /> ), blockquote: ({ className, ...props }) => ( <blockquote className={cn( "aui-md-blockquote my-2.5 border-muted-foreground/30 border-l-2 pl-3 text-muted-foreground italic", className, )} {...props} /> ), ul: ({ className, ...props }) => ( <ul className={cn( "aui-md-ul my-2 ml-4 list-disc marker:text-muted-foreground [&>li]:mt-1", className, )} {...props} /> ), ol: ({ className, ...props }) => ( <ol className={cn( "aui-md-ol my-2 ml-4 list-decimal marker:text-muted-foreground [&>li]:mt-1", className, )} {...props} /> ), hr: ({ className, ...props }) => ( <hr className={cn("aui-md-hr my-2 border-muted-foreground/20", className)} {...props} /> ), table: ({ className, ...props }) => ( <table className={cn( "aui-md-table my-2 w-full border-separate border-spacing-0 overflow-y-auto", className, )} {...props} /> ), th: ({ className, ...props }) => ( <th className={cn( "aui-md-th bg-muted px-2 py-1 text-left font-medium 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-muted-foreground/20 border-b border-l px-2 py-1 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} /> ), li: ({ className, ...props }) => ( <li className={cn("aui-md-li leading-normal", className)} {...props} /> ), sup: ({ className, ...props }) => ( <sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} /> ), pre: ({ className, ...props }) => ( <pre className={cn( "aui-md-pre overflow-x-auto rounded-t-none rounded-b-lg border border-border/50 border-t-0 bg-muted/30 p-3 text-xs leading-relaxed", className, )} {...props} /> ), code: function Code({ className, ...props }) { const isCodeBlock = useIsMarkdownCodeBlock(); return ( <code className={cn( !isCodeBlock && "aui-md-inline-code rounded-md border border-border/50 bg-muted/50 px-1.5 py-0.5 font-mono text-[0.85em]", className, )} {...props} /> ); }, CodeHeader,});"use client";import { memo, useCallback, useRef, useState } from "react";import { AlertCircleIcon, CheckIcon, ChevronDownIcon, LoaderIcon, XCircleIcon,} from "lucide-react";import { useScrollLock, type ToolCallMessagePartStatus, type ToolCallMessagePartComponent,} from "@assistant-ui/react";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@/components/ui/collapsible";import { cn } from "@/lib/utils";const ANIMATION_DURATION = 200;export type ToolFallbackRootProps = Omit< React.ComponentProps<typeof Collapsible>, "open" | "onOpenChange"> & { open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean;};function ToolFallbackRoot({ className, open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children, ...props}: ToolFallbackRootProps) { const collapsibleRef = useRef<HTMLDivElement>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; const handleOpenChange = useCallback( (open: boolean) => { if (!open) { lockScroll(); } if (!isControlled) { setUncontrolledOpen(open); } controlledOnOpenChange?.(open); }, [lockScroll, isControlled, controlledOnOpenChange], ); return ( <Collapsible ref={collapsibleRef} data-slot="tool-fallback-root" open={isOpen} onOpenChange={handleOpenChange} className={cn( "aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3", className, )} style={ { "--animation-duration": `${ANIMATION_DURATION}ms`, } as React.CSSProperties } {...props} > {children} </Collapsible> );}type ToolStatus = ToolCallMessagePartStatus["type"];const statusIconMap: Record<ToolStatus, React.ElementType> = { running: LoaderIcon, complete: CheckIcon, incomplete: XCircleIcon, "requires-action": AlertCircleIcon,};function ToolFallbackTrigger({ toolName, status, className, ...props}: React.ComponentProps<typeof CollapsibleTrigger> & { toolName: string; status?: ToolCallMessagePartStatus;}) { const statusType = status?.type ?? "complete"; const isRunning = statusType === "running"; const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; const Icon = statusIconMap[statusType]; const label = isCancelled ? "Cancelled tool" : "Used tool"; return ( <CollapsibleTrigger data-slot="tool-fallback-trigger" className={cn( "aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors", className, )} {...props} > <Icon data-slot="tool-fallback-trigger-icon" className={cn( "aui-tool-fallback-trigger-icon size-4 shrink-0", isCancelled && "text-muted-foreground", isRunning && "animate-spin", )} /> <span data-slot="tool-fallback-trigger-label" className={cn( "aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-left leading-none", isCancelled && "text-muted-foreground line-through", )} > <span> {label}: <b>{toolName}</b> </span> {isRunning && ( <span aria-hidden data-slot="tool-fallback-trigger-shimmer" className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" > {label}: <b>{toolName}</b> </span> )} </span> <ChevronDownIcon data-slot="tool-fallback-trigger-chevron" className={cn( "aui-tool-fallback-trigger-chevron size-4 shrink-0", "transition-transform duration-(--animation-duration) ease-out", "group-data-[state=closed]/trigger:-rotate-90", "group-data-[state=open]/trigger:rotate-0", )} /> </CollapsibleTrigger> );}function ToolFallbackContent({ className, children, ...props}: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-slot="tool-fallback-content" className={cn( "aui-tool-fallback-content relative overflow-hidden text-sm outline-none", "group/collapsible-content ease-out", "data-[state=closed]:animate-collapsible-up", "data-[state=open]:animate-collapsible-down", "data-[state=closed]:fill-mode-forwards", "data-[state=closed]:pointer-events-none", "data-[state=open]:duration-(--animation-duration)", "data-[state=closed]:duration-(--animation-duration)", className, )} {...props} > <div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div> </CollapsibleContent> );}function ToolFallbackArgs({ argsText, className, ...props}: React.ComponentProps<"div"> & { argsText?: string;}) { if (!argsText) return null; return ( <div data-slot="tool-fallback-args" className={cn("aui-tool-fallback-args px-4", className)} {...props} > <pre className="aui-tool-fallback-args-value whitespace-pre-wrap"> {argsText} </pre> </div> );}function ToolFallbackResult({ result, className, ...props}: React.ComponentProps<"div"> & { result?: unknown;}) { if (result === undefined) return null; return ( <div data-slot="tool-fallback-result" className={cn( "aui-tool-fallback-result border-t border-dashed px-4 pt-2", className, )} {...props} > <p className="aui-tool-fallback-result-header font-semibold">Result:</p> <pre className="aui-tool-fallback-result-content whitespace-pre-wrap"> {typeof result === "string" ? result : JSON.stringify(result, null, 2)} </pre> </div> );}function ToolFallbackError({ status, className, ...props}: React.ComponentProps<"div"> & { status?: ToolCallMessagePartStatus;}) { if (status?.type !== "incomplete") return null; const error = status.error; const errorText = error ? typeof error === "string" ? error : JSON.stringify(error) : null; if (!errorText) return null; const isCancelled = status.reason === "cancelled"; const headerText = isCancelled ? "Cancelled reason:" : "Error:"; return ( <div data-slot="tool-fallback-error" className={cn("aui-tool-fallback-error px-4", className)} {...props} > <p className="aui-tool-fallback-error-header font-semibold text-muted-foreground"> {headerText} </p> <p className="aui-tool-fallback-error-reason text-muted-foreground"> {errorText} </p> </div> );}const ToolFallbackImpl: ToolCallMessagePartComponent = ({ toolName, argsText, result, status,}) => { const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; return ( <ToolFallbackRoot className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")} > <ToolFallbackTrigger toolName={toolName} status={status} /> <ToolFallbackContent> <ToolFallbackError status={status} /> <ToolFallbackArgs argsText={argsText} className={cn(isCancelled && "opacity-60")} /> {!isCancelled && <ToolFallbackResult result={result} />} </ToolFallbackContent> </ToolFallbackRoot> );};const ToolFallback = memo( ToolFallbackImpl,) as unknown as ToolCallMessagePartComponent & { Root: typeof ToolFallbackRoot; Trigger: typeof ToolFallbackTrigger; Content: typeof ToolFallbackContent; Args: typeof ToolFallbackArgs; Result: typeof ToolFallbackResult; Error: typeof ToolFallbackError;};ToolFallback.displayName = "ToolFallback";ToolFallback.Root = ToolFallbackRoot;ToolFallback.Trigger = ToolFallbackTrigger;ToolFallback.Content = ToolFallbackContent;ToolFallback.Args = ToolFallbackArgs;ToolFallback.Result = ToolFallbackResult;ToolFallback.Error = ToolFallbackError;export { ToolFallback, ToolFallbackRoot, ToolFallbackTrigger, ToolFallbackContent, ToolFallbackArgs, ToolFallbackResult, ToolFallbackError,};This adds /components/assistant-ui/assistant-modal.tsx to your project, which you can adjust as needed.
Use in your application
import { AssistantModal } from "@/components/assistant-ui/assistant-modal";
export default function Home() {
return (
<div className="h-full">
<AssistantModal />
</div>
);
}Anatomy
The AssistantModal component is built with the following primitives:
import { AssistantModalPrimitive } from "@assistant-ui/react";
<AssistantModalPrimitive.Root>
<AssistantModalPrimitive.Anchor />
<AssistantModalPrimitive.Trigger />
<AssistantModalPrimitive.Content>
{/* Thread component goes here */}
</AssistantModalPrimitive.Content>
</AssistantModalPrimitive.Root>API Reference
Root
Contains all parts of the modal. Based on Radix UI Popover.
AssistantModalPrimitiveRootPropsdefaultOpen?: booleanThe initial open state when uncontrolled.
open?: booleanThe controlled open state.
onOpenChange?: (open: boolean) => voidCallback when the open state changes.
unstable_openOnRunStart?: booleanAutomatically open the modal when the assistant starts running.
Trigger
A button that toggles the modal open/closed state.
AssistantModalPrimitiveTriggerPropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper button.
This primitive renders a <button> element unless asChild is set.
Content
The popover content container that holds the chat interface.
AssistantModalPrimitiveContentPropsside: 'top' | 'right' | 'bottom' | 'left'= 'top'The preferred side of the anchor to render against.
align: 'start' | 'center' | 'end'= 'end'The preferred alignment against the anchor.
dissmissOnInteractOutside?: booleanWhether to close the modal when clicking outside.
asChild: boolean= falseMerge props with child element instead of rendering a wrapper div.
Anchor
An optional anchor element to position the modal relative to.
AssistantModalPrimitiveAnchorPropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper div.
Related Components
- Thread - The main chat interface used inside the modal
- AssistantSidebar - Alternative layout for side panel chat