Assistant UI
itemeve-chatsource: ok · refreshed
Eve Chat
Chat page for an Eve agent, rendering the session through an assistant-ui thread.
npx shadcn add "https://r.assistant-ui.com/eve-chat.json"free
Where it goes
chat
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files27 · source cached 10/7/2026, 11:39:22 PM
app/page.tsx
"use client";
import { Thread } from "@/components/assistant-ui/elements/thread.aui";
import { eveAskQuestionToolkit } from "@/components/eve-ask-question";
import { useEveAgentRuntime } from "@assistant-ui/eve";
import {
AssistantRuntimeProvider,
AuiConfig,
Tools,
} from "@assistant-ui/react";
export default function Home() {
const runtime = useEveAgentRuntime();
const config = AuiConfig({
tools: Tools({ toolkit: eveAskQuestionToolkit }),
});
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
<div className="h-dvh">
<Thread />
</div>
</AssistantRuntimeProvider>
);
}
components/assistant-ui/elements/thread.aui.tsx
"use client";
import {
ComposerAddAttachment,
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/elements/attachment.aui";
import { File, getFileHref } from "@/components/assistant-ui/elements/file";
import { ThreadFollowupSuggestions } from "@/components/assistant-ui/elements/follow-up-suggestions.aui";
import { Image } from "@/components/assistant-ui/elements/image";
import { MarkdownText } from "@/components/assistant-ui/elements/markdown-text";
import {
Reasoning,
ReasoningContent,
ReasoningRoot,
ReasoningText,
ReasoningTrigger,
} from "@/components/assistant-ui/elements/reasoning.aui";
import { ToolFallback } from "@/components/assistant-ui/elements/tool-fallback.aui";
import {
ToolGroupContent,
ToolGroupRoot,
ToolGroupTrigger,
} from "@/components/assistant-ui/elements/tool-group.aui";
import { TooltipIconButton } from "@/components/assistant-ui/elements/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import {
ActionBarMorePrimitive,
ActionBarPrimitive,
AuiIf,
type AssistantState,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
groupPartByType,
MessagePrimitive,
SuggestionPrimitive,
ThreadPrimitive,
type FileMessagePartComponent,
type ImageMessagePartComponent,
type TextMessagePartComponent,
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
AudioLinesIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
MicIcon,
MoreHorizontalIcon,
PencilIcon,
PhoneIcon,
RefreshCwIcon,
SquareIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import {
createContext,
useContext,
useLayoutEffect,
useRef,
type ComponentType,
type FC,
type PropsWithChildren,
} from "react";
export type ThreadGroupPart = MessagePrimitive.GroupedParts.GroupPart;
const reasoningDuration = (timing: ThreadGroupPart["timing"]) =>
timing?.completedAt === undefined
? undefined
: Math.round((timing.completedAt - timing.startedAt) / 1000);
/**
* 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`. When `TaskGroup` is set, tool calls that carry a nested
* conversation and have no registered UI render through it instead of the
* tool group; without it they render like any other tool call.
*/
export type ThreadComponents = {
AssistantMessage?: ComponentType | undefined;
Welcome?: ComponentType | undefined;
ToolFallback?: ToolCallMessagePartComponent | undefined;
ToolGroup?:
| ComponentType<PropsWithChildren<{ group: ThreadGroupPart }>>
| undefined;
ReasoningGroup?:
| ComponentType<PropsWithChildren<{ group: ThreadGroupPart }>>
| undefined;
TaskGroup?: ComponentType<{ group: ThreadGroupPart }> | undefined;
};
const messageGroupBy = groupPartByType({
reasoning: ["group-chainOfThought", "group-reasoning"],
"tool-call": ["group-chainOfThought", "group-tool"],
"standalone-tool-call": [],
});
type ThreadGroupKey =
| "group-chainOfThought"
| "group-reasoning"
| "group-tool"
| "group-task";
const TASK_GROUP_PATH: readonly ThreadGroupKey[] = [
"group-chainOfThought",
"group-task",
];
const taskAwareGroupBy = (
part: Parameters<typeof messageGroupBy>[0],
context?: Parameters<typeof messageGroupBy>[1],
): readonly ThreadGroupKey[] => {
const path = messageGroupBy(part, context);
return part.type === "tool-call" &&
part.messages !== undefined &&
path.length > 0 &&
!context?.toolUIs?.[part.toolName]?.length
? TASK_GROUP_PATH
: path;
};
export type ThreadProps = {
components?: ThreadComponents | undefined;
autoFocus?: boolean | 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);
// A switched thread that is still fetching its history: skeleton, not welcome.
const isHistoryLoadingView = (s: AssistantState) =>
s.thread.messages.length === 0 &&
s.thread.isLoading &&
!s.thread.isDisabled &&
!s.threads.isLoading;
const ThreadHistorySkeleton: FC = () => (
<div
data-slot="aui_thread-history-skeleton"
role="status"
className="animate-in fade-in fill-mode-both flex flex-col gap-y-6 [animation-delay:150ms] [animation-duration:200ms]"
>
<span className="sr-only">Loading conversation</span>
<Skeleton className="ml-auto h-9 w-2/5 rounded-xl motion-reduce:animate-none" />
<div className="flex flex-col gap-y-2">
<Skeleton className="h-4 w-11/12 motion-reduce:animate-none" />
<Skeleton className="h-4 w-4/5 motion-reduce:animate-none" />
<Skeleton className="h-4 w-3/5 motion-reduce:animate-none" />
</div>
<Skeleton className="ml-auto h-9 w-1/3 rounded-xl motion-reduce:animate-none" />
<div className="flex flex-col gap-y-2">
<Skeleton className="h-4 w-10/12 motion-reduce:animate-none" />
<Skeleton className="h-4 w-2/3 motion-reduce:animate-none" />
</div>
</div>
);
export const Thread: FC<ThreadProps> = ({
components = EMPTY_COMPONENTS,
autoFocus = true,
}) => {
const isEmpty = useAuiState(isNewChatView);
return (
<ThreadComponentsContext.Provider value={components}>
<ThreadRoot isEmpty={isEmpty} autoFocus={autoFocus} />
</ThreadComponentsContext.Provider>
);
};
const ThreadRoot: FC<{ isEmpty: boolean; autoFocus: boolean }> = ({
isEmpty,
autoFocus,
}) => {
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-bg" as string]:
"color-mix(in oklab, var(--color-muted) 30%, transparent)",
["--composer-radius" as string]: "1rem",
["--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>
<AuiIf condition={isHistoryLoadingView}>
<ThreadHistorySkeleton />
</AuiIf>
<ThreadLoadEarlier />
<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-(--composer-radius)",
)}
>
<ThreadScrollToBottom />
<ThreadFollowupSuggestions />
<Composer autoFocus={autoFocus} />
<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);
const isSpoken = useAuiState((s) => s.message.metadata.modality === "voice");
if (isEditing) return <EditComposer />;
if (isSpoken) return <SpokenMessage />;
if (role === "user") return <UserMessage />;
return <AssistantMessageComponent />;
};
type VoiceRunPosition = "single" | "start" | "middle" | "end";
const useVoiceRunPosition = (): VoiceRunPosition =>
useAuiState((s) => {
const before =
s.thread.messages[s.message.index - 1]?.metadata.modality === "voice";
const after =
s.thread.messages[s.message.index + 1]?.metadata.modality === "voice";
if (before) return after ? "middle" : "end";
return after ? "start" : "single";
});
const SpokenText: TextMessagePartComponent = ({ text }) => (
<p className="aui-spoken-message-text m-0">{text}</p>
);
const SpokenMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
const position = useVoiceRunPosition();
const isSpeaking = useAuiState(
(s) =>
s.message.role === "assistant" && s.message.status?.type === "running",
);
const opensExchange = position === "start" || position === "single";
return (
<MessagePrimitive.Root
data-slot="aui_spoken-message-root"
data-role={role}
data-voice-run={position}
className={cn(
"aui-spoken-message bg-muted/40 mx-2 px-3 py-1.5 [contain-intrinsic-size:auto_48px] [content-visibility:auto]",
position === "single" && "rounded-xl py-2",
position === "start" && "rounded-t-xl pt-2",
position === "middle" && "-mt-6",
position === "end" && "-mt-6 rounded-b-xl pb-2",
)}
>
{opensExchange && (
<div
data-slot="aui_spoken-exchange-header"
className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-xs"
>
<PhoneIcon className="size-3" aria-hidden />
<span>Voice conversation</span>
</div>
)}
<div
data-slot="aui_spoken-message-content"
className="text-foreground flex items-start gap-2 text-sm leading-relaxed"
>
<span className="text-muted-foreground mt-1 shrink-0" aria-hidden>
{role === "user" ? (
<MicIcon className="size-3.5" />
) : (
<AudioLinesIcon className="size-3.5" />
)}
</span>
<span className="sr-only">
{role === "user" ? "You said" : "Assistant said"}
</span>
<div className="min-w-0 flex-1 wrap-break-word">
<MessagePrimitive.Parts components={{ Text: SpokenText }} />
{isSpeaking && (
<span
data-slot="aui_spoken-message-indicator"
role="status"
className="text-muted-foreground ms-1 animate-pulse font-sans"
aria-label="Assistant is speaking"
>
●
</span>
)}
</div>
<SpokenActionBar />
</div>
</MessagePrimitive.Root>
);
};
const SpokenActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="always"
className="aui-spoken-action-bar text-muted-foreground flex shrink-0 gap-1"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy" className="size-6">
<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.Root>
);
};
const FOCUSABLE_SELECTOR =
"a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1']):not([disabled])";
const nextFocusable = (start: Element) => {
for (
let element = start.nextElementSibling;
element;
element = element.nextElementSibling
) {
const target = element.matches(FOCUSABLE_SELECTOR)
? element
: element.querySelector(FOCUSABLE_SELECTOR);
if (target instanceof HTMLElement) return target;
}
return null;
};
const ThreadLoadEarlier: FC = () => {
const visible = useAuiState(
(s) => s.thread.hasEarlier || s.thread.isLoadingEarlier,
);
const loading = useAuiState((s) => s.thread.isLoadingEarlier);
const slotRef = useRef<HTMLDivElement>(null);
const focusedRef = useRef<Element | null>(null);
// The last page removes the button; a keyboard user on it continues from the
// next control in tab order rather than from the document body.
useLayoutEffect(() => {
const focused = focusedRef.current;
const slot = slotRef.current;
if (visible || !focused || focused.isConnected || !slot) return;
focusedRef.current = null;
const active = document.activeElement;
if (active && active !== document.body) return;
nextFocusable(slot)?.focus();
}, [visible]);
return (
<div
ref={slotRef}
className="contents"
onFocus={(event) => {
focusedRef.current = event.target;
}}
>
<span role="status" className="sr-only">
{loading ? "Loading earlier messages" : ""}
</span>
{visible && (
<ThreadPrimitive.LoadEarlier asChild>
<Button
variant="ghost"
size="sm"
data-slot="aui_thread-load-earlier"
className="aui-thread-load-earlier text-muted-foreground mb-6 self-center rounded-full"
>
<span
className={cn(loading && "shimmer motion-reduce:animate-none")}
>
{loading ? "Loading earlier messages" : "Load earlier messages"}
</span>
</Button>
</ThreadPrimitive.LoadEarlier>
)}
</div>
);
};
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 px-2">
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-2xl font-medium tracking-tight duration-200">
How can I help you today?
</p>
</div>
);
};
const ThreadSuggestions: FC = () => {
return (
<div className="aui-thread-welcome-suggestions flex w-full flex-col">
<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
type="button"
className="aui-thread-welcome-suggestion group hover:bg-foreground/[0.03] focus-visible:ring-ring/50 flex w-full items-baseline gap-2.5 rounded-md px-2 py-2 text-start text-sm transition-colors outline-none focus-visible:ring-1 motion-reduce:transition-none"
>
<span
aria-hidden
className="text-muted-foreground/60 group-hover:text-foreground font-mono text-xs transition-colors motion-reduce:transition-none"
>
{">"}
</span>
<span className="min-w-0 flex-1 truncate">
<SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 text-foreground" />{" "}
<SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
</span>
</button>
</SuggestionPrimitive.Trigger>
</div>
);
};
const Composer: FC<{ autoFocus: boolean }> = ({ autoFocus }) => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone asChild>
<div
data-slot="aui_composer-shell"
className="border-foreground/10 focus-within:border-foreground/25 data-[dragging=true]:border-ring flex w-full cursor-text flex-col gap-2 rounded-(--composer-radius) border bg-(--composer-bg) p-(--composer-padding) transition-[border-color] data-[dragging=true]:border-dashed data-[dragging=true]:bg-[color-mix(in_oklab,var(--color-accent)_50%,var(--color-background))]"
>
<ComposerAttachments />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input caret-primary placeholder:text-muted-foreground/60 max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-1 text-base leading-6 outline-none"
rows={1}
autoFocus={autoFocus}
enterKeyHint="send"
aria-label="Message input"
/>
<ComposerAction />
</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
// The stop control only cancels the send while no run it could stop is going.
const isSending = useAuiState(
(s) =>
s.composer.submission !== undefined &&
!(s.thread.isRunning && s.thread.capabilities.cancel),
);
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 text-muted-foreground hover:text-foreground 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.composer.canCancel ||
(s.thread.voice !== undefined &&
s.composer.submission === undefined)
}
>
<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" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf
condition={(s) =>
s.composer.canCancel &&
(s.thread.voice === undefined ||
s.composer.submission !== undefined)
}
>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-7 rounded-full"
aria-label={isSending ? "Cancel sending" : "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,
TaskGroup: TaskGroupComponent,
} = useContext(ThreadComponentsContext);
const groupBy = TaskGroupComponent ? taskAwareGroupBy : messageGroupBy;
const ACTION_BAR_PT = "pt-1.5";
// Keep the action bar inside the contained root's paint box, then cancel its reserved space in flow.
const ACTION_BAR_HEIGHT = `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 -mb-7.5 pb-7.5 duration-150 [contain-intrinsic-size:auto_200px] [content-visibility:auto]"
>
<div
data-slot="aui_assistant-message-content"
className="text-foreground px-2 leading-relaxed wrap-break-word"
>
<MessagePrimitive.GroupedParts groupBy={groupBy}>
{({ part, children }) => {
switch (part.type) {
case "group-chainOfThought":
return <div data-slot="aui_chain-of-thought">{children}</div>;
case "group-task":
return TaskGroupComponent ? (
<TaskGroupComponent group={part} />
) : null;
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}
duration={reasoningDuration(part.timing)}
/>
<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 "file":
return (
<div data-slot="aui_assistant-message-file" className="py-1">
<MessageFile {...part} />
</div>
);
case "image":
return (
<div data-slot="aui_assistant-message-image" className="py-1">
<Image {...part} />
</div>
);
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>
<AuiIf condition={(s) => s.thread.capabilities.feedback}>
<ActionBarPrimitive.FeedbackPositive asChild>
<TooltipIconButton
tooltip="Helpful"
className="data-[submitted=true]:bg-accent data-[submitted=true]:text-accent-foreground"
>
<ThumbsUpIcon />
</TooltipIconButton>
</ActionBarPrimitive.FeedbackPositive>
<ActionBarPrimitive.FeedbackNegative asChild>
<TooltipIconButton
tooltip="Not helpful"
className="data-[submitted=true]:bg-accent data-[submitted=true]:text-accent-foreground"
>
<ThumbsDownIcon />
</TooltipIconButton>
</ActionBarPrimitive.FeedbackNegative>
</AuiIf>
<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 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"
>
<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 MessageFile: FileMessagePartComponent = (part) => {
const image = part.mimeType.toLowerCase().startsWith("image/")
? getFileHref(part.data, part.mimeType, part.sourceType)
: null;
if (!image) return <File {...part} />;
return (
<Image
type="image"
image={image}
status={part.status}
{...(part.filename !== undefined && { filename: part.filename })}
/>
);
};
const UserFilePart: FileMessagePartComponent = (part) => (
<div data-slot="aui_user-message-file" className="py-1">
<MessageFile {...part} />
</div>
);
const UserImagePart: ImageMessagePartComponent = (part) => (
<div data-slot="aui_user-message-image" className="py-1">
<Image {...part} />
</div>
);
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_200px] [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-(--composer-radius) px-4 py-2 wrap-break-word empty:hidden">
<MessagePrimitive.Parts
components={{ File: UserFilePart, Image: UserImagePart }}
/>
</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 -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 [contain-intrinsic-size:auto_200px] [content-visibility:auto]"
>
<ComposerPrimitive.Root className="aui-edit-composer-root border-foreground/10 focus-within:border-foreground/25 ms-auto flex w-full max-w-[85%] cursor-text flex-col rounded-(--composer-radius) border bg-(--composer-bg) transition-[border-color]">
<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 px-3">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm" className="h-8 px-3">
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>
);
};
components/assistant-ui/elements/attachment.aui.tsx
"use client";
import {
type PropsWithChildren,
useState,
type FC,
isValidElement,
} from "react";
import {
XIcon,
PlusIcon,
FileText,
Loader2Icon,
AlertCircleIcon,
} from "lucide-react";
import {
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
useAuiState,
useAui,
} from "@assistant-ui/react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
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/elements/tooltip-icon-button";
import { useAttachmentSrc } from "@/hooks/use-attachment-src";
import { cn } from "@/lib/utils";
type AttachmentPreviewProps = {
src: string;
name: string;
};
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src, name }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<img
src={src}
alt={`Preview of ${name}`}
className={cn(
"block h-auto max-h-[80vh] w-auto max-w-full rounded-sm object-contain transition-opacity duration-300 motion-reduce:transition-none",
isLoaded
? "aui-attachment-preview-image-loaded opacity-100"
: "aui-attachment-preview-image-loading opacity-0",
)}
onLoad={() => setIsLoaded(true)}
/>
);
};
type AttachmentPreviewDialogProps = PropsWithChildren<{
src: string | undefined;
name: string;
}>;
const AttachmentPreviewDialog: FC<AttachmentPreviewDialogProps> = ({
children,
src,
name,
}) => {
if (!src) return children;
return (
<Dialog>
<DialogTrigger
className="aui-attachment-preview-trigger cursor-zoom-in"
asChild
>
{isValidElement(children) ? (
children
) : (
<button type="button">{children}</button>
)}
</DialogTrigger>
<DialogContent className="aui-attachment-preview-dialog-content [&>button]:bg-foreground/60 [&>button]:hover:bg-foreground/80 [&_svg]:text-background 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">
Preview {name}
</DialogTitle>
<div className="aui-attachment-preview bg-background relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden rounded-sm">
<AttachmentPreview src={src} name={name} />
</div>
</DialogContent>
</Dialog>
);
};
const AttachmentThumb: FC<{ src: string | undefined }> = ({ src }) => {
return (
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
<AvatarImage
src={src}
alt=""
className="aui-attachment-tile-image rounded-none object-cover"
/>
<AvatarFallback>
<FileText className="aui-attachment-tile-fallback-icon text-muted-foreground/80 size-6 stroke-[1.5]" />
</AvatarFallback>
</Avatar>
);
};
const AttachmentUI: FC = () => {
const aui = useAui();
const isComposer = aui.attachment.source !== "message";
const src = useAttachmentSrc();
const isImage = useAuiState((s) => s.attachment.type === "image");
const name = useAuiState((s) => s.attachment.name);
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;
}
});
// An attachment on a submission is still being prepared, whether or not the
// adapter reports progress while it uploads.
const uploadState = useAuiState((s) =>
s.attachment.status.type === "incomplete" &&
s.attachment.status.reason === "error"
? "error"
: s.attachment.status.type === "running" ||
(s.optional.message?.submission !== undefined &&
s.attachment.status.type !== "complete")
? "uploading"
: undefined,
);
const isUploading = uploadState === "uploading";
const isError = uploadState === "error";
const errorMessage = useAuiState((s) =>
s.attachment.status.type === "incomplete" &&
s.attachment.status.reason === "error"
? (s.attachment.status.message ?? "Upload failed")
: undefined,
);
return (
<TooltipProvider>
<Tooltip>
<AttachmentPrimitive.Root
className={cn(
"aui-attachment-root relative",
isComposer &&
"animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none",
isImage &&
!isComposer &&
"aui-attachment-root-message only:*:first:size-24",
)}
>
<AttachmentPreviewDialog src={src} name={name}>
<TooltipTrigger asChild>
<div
className={cn(
"aui-attachment-tile bg-muted hover:after:bg-foreground/10 focus-visible:ring-ring/50 relative size-14 overflow-hidden rounded-[calc(var(--composer-radius,1rem)-var(--composer-padding,8px))] transition-transform outline-none after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-black/10 after:transition-colors after:ring-inset focus-visible:ring-1 motion-reduce:transition-none dark:after:ring-white/10",
src ? "cursor-zoom-in active:scale-[0.96]" : "cursor-default",
isError &&
"after:ring-destructive/60 dark:after:ring-destructive/60",
)}
role={src ? "button" : "group"}
tabIndex={0}
onKeyDown={
src
? (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.click();
} else if (e.key === " ") {
e.preventDefault();
}
}
: undefined
}
onKeyUp={
src
? (e) => {
if (e.key === " ") e.currentTarget.click();
}
: undefined
}
aria-label={`${src ? `Preview ${name}` : `${typeLabel} attachment ${name}`}${
isError ? ", upload failed" : isUploading ? ", uploading" : ""
}`}
>
<AttachmentThumb src={src} />
{isUploading && (
<div
aria-hidden="true"
className="aui-attachment-tile-uploading bg-background/60 animate-in fade-in-0 absolute inset-0 flex items-center justify-center backdrop-blur-[2px] motion-reduce:animate-none"
>
<Loader2Icon className="text-muted-foreground size-4 animate-spin" />
</div>
)}
{isError && (
<div
aria-hidden="true"
className="aui-attachment-tile-error bg-background/70 animate-in fade-in-0 absolute inset-0 flex items-center justify-center backdrop-blur-[2px] motion-reduce:animate-none"
>
<AlertCircleIcon className="text-destructive size-4" />
</div>
)}
</div>
</TooltipTrigger>
</AttachmentPreviewDialog>
{isComposer && <AttachmentRemove name={name} />}
</AttachmentPrimitive.Root>
<TooltipContent side="top">
<AttachmentPrimitive.Name />
{errorMessage && (
<p className="aui-attachment-error-message">{errorMessage}</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const AttachmentRemove: FC<{ name: string }> = ({ name }) => {
return (
<AttachmentPrimitive.Remove asChild>
<TooltipIconButton
tooltip={`Remove ${name}`}
className="aui-attachment-tile-remove absolute end-1 top-1 size-5 rounded-full bg-black/50! text-white after:absolute after:-inset-1.5 hover:bg-black/70! hover:text-white! active:scale-[0.96] motion-reduce:transition-none"
side="top"
>
<XIcon className="aui-attachment-remove-icon size-3 stroke-[2.5]" />
</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 empty:hidden">
<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 text-muted-foreground hover:text-foreground hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30 size-7 rounded-full active:scale-[0.96] motion-reduce:transition-none"
aria-label="Add Attachment"
>
<PlusIcon className="aui-attachment-add-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
);
};
components/assistant-ui/elements/tooltip-icon-button.tsx
"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";
hooks/use-attachment-src.ts
"use client";
import { useEffect, useState } from "react";
import { useAuiState } from "@assistant-ui/react";
import { useShallow } from "zustand/react/shallow";
const useFileSrc = (file: File | undefined) => {
const [entry, setEntry] = useState<{ file: File; url: string } | undefined>(
undefined,
);
useEffect(() => {
// The object URL is a browser resource whose lifetime has to straddle
// commit, so allocation, revocation, and clearing the entry that names a
// revoked URL all belong to the effect.
if (!file) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setEntry(undefined);
return;
}
const objectUrl = URL.createObjectURL(file);
setEntry({ file, url: objectUrl });
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [file]);
return entry !== undefined && entry.file === file ? entry.url : undefined;
};
export 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;
};
components/assistant-ui/elements/file.tsx
"use client";
import { memo, type FC } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
FileIcon,
FileTextIcon,
ImageIcon,
MusicIcon,
VideoIcon,
BracesIcon,
DownloadIcon,
} from "lucide-react";
import type { FileMessagePartComponent } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { AudioPlayer, VideoPlayer } from "./media-player";
const fileVariants = cva(
"aui-file-root inline-flex items-center gap-3 rounded-lg transition-colors",
{
variants: {
variant: {
outline: "border-border hover:bg-muted/50 border",
ghost: "hover:bg-muted/50",
muted: "bg-muted/50 hover:bg-muted/70",
},
size: {
sm: "px-2.5 py-1.5 text-xs",
default: "px-3 py-2 text-sm",
lg: "px-4 py-3 text-base",
},
},
defaultVariants: {
variant: "outline",
size: "default",
},
},
);
function getMimeTypeIcon(mimeType: string): FC<{ className?: string }> {
const type = mimeType.toLowerCase();
if (type.startsWith("image/")) {
return ImageIcon;
}
if (type === "application/pdf") {
return FileTextIcon;
}
if (type === "application/json") {
return BracesIcon;
}
if (type.startsWith("text/")) {
return FileTextIcon;
}
if (type.startsWith("audio/")) {
return MusicIcon;
}
if (type.startsWith("video/")) {
return VideoIcon;
}
return FileIcon;
}
export type FileDataKind = "data-uri" | "url" | "base64" | "id";
function getFileDataKind(
data: string,
sourceType?: "url" | "id",
): FileDataKind {
if (sourceType === "url" && /^data:/i.test(data)) return "data-uri";
if (sourceType) return sourceType;
if (/^data:/i.test(data)) return "data-uri";
if (/^(https?:\/\/|blob:)/i.test(data)) return "url";
return "base64";
}
function isUnsafeScheme(data: string): boolean {
return (
/^[a-z][a-z\d+.-]*:/i.test(data) &&
!/^(data:|https?:\/\/|blob:)/i.test(data)
);
}
function getFileHref(
data: string,
mimeType: string,
sourceType?: "url" | "id",
): string | null {
if (isUnsafeScheme(data)) return null;
const kind = getFileDataKind(data, sourceType);
if (kind === "id") return null;
if (kind === "data-uri") return /^data:[^,]*,/i.test(data) ? data : null;
if (kind === "url") return /^(https?:\/\/|blob:)/i.test(data) ? data : null;
return `data:${mimeType};base64,${data}`;
}
function getBase64PayloadSize(payload: string): number {
const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
const firstNonBase64 = payload.search(/[^A-Za-z\d+/]/);
if (
(firstNonBase64 !== -1 && firstNonBase64 !== payload.length - padding) ||
payload.length % 4 === 1 ||
(padding > 0 && payload.length % 4 !== 0)
) {
return 0;
}
return Math.floor((payload.length * 3) / 4) - padding;
}
function getBase64Size(base64: string): number {
const payload = /[\t\n\f\r ]/.test(base64)
? base64.replace(/[\t\n\f\r ]/g, "")
: base64;
return getBase64PayloadSize(payload);
}
function getDataUrlSize(data: string): number {
const fragment = data.indexOf("#");
const end = fragment < 0 ? data.length : fragment;
const comma = data.indexOf(",");
if (comma < 0 || comma >= end) {
return 0;
}
let payload = data.slice(comma + 1, end);
if (/;base64$/i.test(data.slice(0, comma))) {
if (/[%\t\n\f\r ]/.test(payload)) {
payload = payload
.replace(/%([\da-f]{2})/gi, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/[\t\n\f\r ]/g, "");
}
return getBase64PayloadSize(payload);
}
// Each percent escape is one byte, including octets that are not valid UTF-8.
return new TextEncoder().encode(payload.replace(/%[\da-f]{2}/gi, "_"))
.byteLength;
}
function getFileLabel(mimeType: string): string {
const subtype = /^[^/;]+\/(?:x-)?(?:[^;+]*\.)?([^;+.\s]+)/.exec(
mimeType.toLowerCase(),
)?.[1];
if (!subtype || subtype === "octet-stream") return "File";
if (subtype === "plain") return "Text file";
return `${subtype.toUpperCase()} file`;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export type FileRootProps = React.ComponentProps<"div"> &
VariantProps<typeof fileVariants>;
function FileRoot({
className,
variant,
size,
children,
...props
}: FileRootProps) {
return (
<div
data-slot="file-root"
data-variant={variant}
data-size={size}
className={cn(fileVariants({ variant, size, className }))}
{...props}
>
{children}
</div>
);
}
type FileIconDisplayProps = React.ComponentProps<"span"> & {
mimeType?: string;
};
function FileIconDisplay({
mimeType,
className,
children,
...props
}: FileIconDisplayProps) {
const IconComponent = mimeType ? getMimeTypeIcon(mimeType) : FileIcon;
return (
<span
data-slot="file-icon"
className={cn("text-muted-foreground shrink-0", className)}
{...props}
>
{/* eslint-disable-next-line react-hooks/static-components -- The helper only selects module-level icon components. */}
{children ?? <IconComponent className="size-5" />}
</span>
);
}
function FileName({
className,
children,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="file-name"
className={cn("min-w-0 flex-1 truncate font-medium", className)}
{...props}
>
{children || "Unnamed file"}
</span>
);
}
type FileSizeProps = React.ComponentProps<"span"> & {
bytes: number;
};
function FileSize({ bytes, className, ...props }: FileSizeProps) {
return (
<span
data-slot="file-size"
className={cn("text-muted-foreground shrink-0", className)}
{...props}
>
{formatFileSize(bytes)}
</span>
);
}
type FileDownloadProps = Omit<React.ComponentProps<"a">, "href"> & {
data: string;
mimeType: string;
filename?: string;
sourceType?: "url" | "id";
};
function FileDownload({
data,
mimeType,
filename,
sourceType,
className,
children,
...props
}: FileDownloadProps) {
if (typeof data !== "string") return null;
const kind = getFileDataKind(data, sourceType);
const href = getFileHref(data, mimeType, sourceType);
if (!href) return null;
return (
<a
data-slot="file-download"
href={href}
download={filename || "download"}
{...(kind === "url" && { target: "_blank", rel: "noopener noreferrer" })}
className={cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground shrink-0 rounded-md p-1 transition-colors",
className,
)}
aria-label={!children ? `Download ${filename || "file"}` : undefined}
{...props}
>
{children || <DownloadIcon className="size-4" />}
</a>
);
}
export interface FilePlayerProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
data: string;
mimeType: string;
filename?: string | undefined;
sourceType?: "url" | "id" | undefined;
}
function FilePlayer({
data,
mimeType,
filename,
sourceType,
className,
...props
}: FilePlayerProps) {
const src = getFileHref(data, mimeType, sourceType);
const normalizedMimeType = mimeType.toLowerCase();
if (
!src ||
(!normalizedMimeType.startsWith("audio/") &&
!normalizedMimeType.startsWith("video/"))
) {
return null;
}
return (
<div data-slot="file-player" className={cn("w-full", className)} {...props}>
{normalizedMimeType.startsWith("audio/") ? (
<AudioPlayer src={src} title={filename} />
) : (
<VideoPlayer src={src} title={filename} />
)}
</div>
);
}
const FileImpl: FileMessagePartComponent = ({
filename,
data,
mimeType,
sourceType,
}) => {
const kind = getFileDataKind(data, sourceType);
const showSize =
typeof data === "string" && (kind === "base64" || kind === "data-uri");
const mediaSource = getFileHref(data, mimeType, sourceType);
const isMedia =
mimeType.toLowerCase().startsWith("audio/") ||
mimeType.toLowerCase().startsWith("video/");
if (mediaSource && isMedia) {
return (
<div className="flex w-full max-w-xl flex-col gap-2">
<FilePlayer
data={data}
mimeType={mimeType}
{...(filename !== undefined && { filename })}
{...(sourceType !== undefined && { sourceType })}
/>
<div className="flex min-w-0 items-center justify-end gap-2 px-1">
{showSize && (
<FileSize
bytes={
kind === "data-uri" ? getDataUrlSize(data) : getBase64Size(data)
}
className="text-muted-foreground text-[11px]"
/>
)}
<FileDownload
data={data}
mimeType={mimeType}
{...(filename !== undefined && { filename })}
{...(sourceType !== undefined && { sourceType })}
/>
</div>
</div>
);
}
return (
<FileRoot>
<FileIconDisplay mimeType={mimeType} />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<FileName>{filename || getFileLabel(mimeType)}</FileName>
{showSize && (
<FileSize
bytes={
kind === "data-uri" ? getDataUrlSize(data) : getBase64Size(data)
}
className="text-xs"
/>
)}
</div>
<FileDownload
data={data}
mimeType={mimeType}
{...(filename !== undefined && { filename })}
{...(sourceType !== undefined && { sourceType })}
/>
</FileRoot>
);
};
const File = memo(FileImpl) as unknown as FileMessagePartComponent & {
Root: typeof FileRoot;
Icon: typeof FileIconDisplay;
Name: typeof FileName;
Size: typeof FileSize;
Download: typeof FileDownload;
Player: typeof FilePlayer;
};
File.displayName = "File";
File.Root = FileRoot;
File.Icon = FileIconDisplay;
File.Name = FileName;
File.Size = FileSize;
File.Download = FileDownload;
File.Player = FilePlayer;
export {
File,
FileRoot,
FileIconDisplay,
FileName,
FileSize,
FileDownload,
FilePlayer,
fileVariants,
getMimeTypeIcon,
getFileDataKind,
getFileHref,
getBase64Size,
formatFileSize,
};
components/assistant-ui/elements/media-player.tsx
"use client";
import {
useEffect,
useRef,
useState,
type ComponentProps,
type RefObject,
} from "react";
import { PauseIcon, PlayIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, inkButton, mono, paper } from "./surfaces";
export interface AudioPlayerProps extends Omit<
ComponentProps<"div">,
"children"
> {
src: string;
title?: string | undefined;
artwork?: string | undefined;
durationMs?: number | undefined;
}
export interface VideoPlayerProps extends Omit<
ComponentProps<"div">,
"children"
> {
src: string;
poster?: string | undefined;
title?: string | undefined;
ratio?: "16:9" | "4:3" | "1:1" | "9:16" | "auto" | undefined;
durationMs?: number | undefined;
}
function formatDuration(seconds: number): string {
const wholeSeconds = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(wholeSeconds / 60);
const remainingSeconds = wholeSeconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}
function useMediaDuration(
mediaRef: RefObject<HTMLMediaElement | null>,
src: string,
durationMs?: number | undefined,
) {
const [metadata, setMetadata] = useState<{ src: string; duration?: number }>({
src,
});
// A server-rendered media element can load metadata before hydration attaches onLoadedMetadata.
useEffect(() => {
const media = mediaRef.current;
if (!media || media.readyState < HTMLMediaElement.HAVE_METADATA) return;
if (Number.isFinite(media.duration) && media.duration >= 0) {
setMetadata({ src, duration: media.duration });
}
}, [mediaRef, src]);
const fallbackDuration =
durationMs !== undefined && Number.isFinite(durationMs)
? Math.max(0, durationMs / 1000)
: undefined;
return {
duration:
metadata.src === src
? (metadata.duration ?? fallbackDuration)
: fallbackDuration,
setDurationFromMetadata: (duration: number) => {
if (Number.isFinite(duration) && duration >= 0) {
setMetadata({ src, duration });
}
},
};
}
export function AudioPlayer({
src,
title,
artwork,
durationMs,
className,
...props
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [playback, setPlayback] = useState({
src,
currentTime: 0,
playing: false,
hasError: false,
});
const { duration, setDurationFromMetadata } = useMediaDuration(
audioRef,
src,
durationMs,
);
const playbackState =
playback.src === src
? playback
: { src, currentTime: 0, playing: false, hasError: false };
const { currentTime, playing, hasError } = playbackState;
const update = (patch: Partial<Omit<typeof playback, "src">>) =>
setPlayback((current) => ({
...(current.src === src
? current
: { src, currentTime: 0, playing: false, hasError: false }),
...patch,
}));
const displayTitle = title ?? "Audio";
const playbackLabel = title ? ` ${title}` : " audio";
const durationLabel =
duration === undefined ? "--:--" : formatDuration(duration);
useEffect(() => {
if (audioRef.current?.error) {
setPlayback({ src, currentTime: 0, playing: false, hasError: true });
}
}, [src]);
const togglePlayback = () => {
const audio = audioRef.current;
if (!audio || hasError) return;
if (playing) {
audio.pause();
update({ playing: false });
return;
}
void audio.play().then(
() => update({ playing: true }),
() =>
update(
audio.error ? { hasError: true, playing: false } : { playing: false },
),
);
};
return (
<div
data-slot="audio-player"
className={cn(
paper,
"flex w-full max-w-md items-center gap-3 rounded-2xl p-3",
className,
)}
{...props}
>
{artwork ? (
<img
src={artwork}
alt=""
className="size-10 shrink-0 rounded-lg object-cover"
/>
) : null}
<button
type="button"
aria-label={`${playing ? "Pause" : "Play"}${playbackLabel}`}
disabled={hasError}
onClick={togglePlayback}
className={cn(
inkButton,
"flex size-9 shrink-0 items-center justify-center rounded-full disabled:cursor-default disabled:opacity-40",
)}
>
{playing ? (
<PauseIcon aria-hidden className="size-4" />
) : (
<PlayIcon aria-hidden className="ml-0.5 size-4" />
)}
</button>
<div className="flex min-w-0 grow basis-16 flex-col gap-1.5">
<span className="truncate text-[13.5px] font-medium">
{displayTitle}
</span>
<input
type="range"
min={0}
max={duration ?? 0}
step={0.1}
value={Math.min(currentTime, duration ?? 0)}
aria-label="Seek"
aria-valuetext={`${formatDuration(currentTime)} of ${durationLabel}`}
disabled={hasError}
onChange={(event) => {
const nextTime = Number(event.currentTarget.value);
if (!Number.isFinite(nextTime)) return;
const resolvedTime = Math.min(nextTime, duration ?? nextTime);
if (audioRef.current) audioRef.current.currentTime = resolvedTime;
update({ currentTime: resolvedTime });
}}
className="accent-foreground w-full cursor-pointer disabled:cursor-default"
/>
</div>
<span className={cn(mono, "text-muted-foreground tabular-nums")}>
{formatDuration(currentTime)} / {durationLabel}
</span>
<audio
ref={audioRef}
src={src}
preload="metadata"
className="hidden"
onLoadedMetadata={(event) => {
setDurationFromMetadata(event.currentTarget.duration);
}}
onTimeUpdate={(event) =>
update({ currentTime: event.currentTarget.currentTime })
}
onPlay={() => update({ playing: true })}
onPause={() => update({ playing: false })}
onError={() => update({ hasError: true, playing: false })}
/>
{hasError ? (
<span role="alert" className="text-muted-foreground text-xs">
Can't play this audio
</span>
) : null}
</div>
);
}
export function VideoPlayer({
src,
poster,
title,
ratio = "16:9",
durationMs,
className,
...props
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { duration, setDurationFromMetadata } = useMediaDuration(
videoRef,
src,
durationMs,
);
const ratioClassName =
ratio === "1:1"
? "aspect-square"
: ratio === "4:3"
? "aspect-[4/3]"
: ratio === "16:9"
? "aspect-video"
: ratio === "9:16"
? "aspect-[9/16]"
: undefined;
return (
<div
data-slot="video-player"
className={cn("flex w-full max-w-xl flex-col gap-2", className)}
{...props}
>
<div className={cn(field, "overflow-hidden rounded-xl", ratioClassName)}>
<video
ref={videoRef}
src={src}
poster={poster}
controls
playsInline
preload="metadata"
aria-label={title ?? "Video"}
className={cn(
"block max-w-full",
ratio !== "auto" && "h-full w-full object-contain",
)}
onLoadedMetadata={(event) => {
setDurationFromMetadata(event.currentTarget.duration);
}}
/>
</div>
{(title || duration !== undefined) && (
<div className="flex min-w-0 items-center gap-2 px-1">
{title ? (
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium">
{title}
</span>
) : null}
{duration !== undefined ? (
<span
className={cn(
mono,
"text-muted-foreground shrink-0 tabular-nums",
)}
>
{formatDuration(duration)}
</span>
) : null}
</div>
)}
</div>
);
}
components/assistant-ui/elements/surfaces.tsx
"use client";
import type { ComponentProps } from "react";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
export const paper = "bg-background border border-border/60 dark:bg-popover";
export const floating = "bg-background border border-border/60 dark:bg-popover";
export const field =
"bg-foreground/[0.04] forced-colors:border dark:bg-foreground/[0.06]";
export const fieldInteractive =
"bg-foreground/[0.04] transition-colors hover:bg-foreground/[0.07] forced-colors:border dark:bg-foreground/[0.06] dark:hover:bg-foreground/[0.09]";
export const pressable =
"transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96] motion-reduce:transition-none";
export const ghostButton =
"flex items-center justify-center rounded-full text-muted-foreground outline-hidden transition-[background-color,color,scale] duration-150 hover:bg-foreground/[0.06] hover:text-foreground/90 active:scale-[0.96] focus-visible:ring-1 focus-visible:ring-foreground/20 motion-reduce:transition-none dark:hover:bg-foreground/[0.09]";
export const inkButton =
"bg-foreground text-background transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:opacity-90 active:scale-[0.96] motion-reduce:transition-none forced-colors:border";
export const iconSwap =
"[grid-area:1/1] transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none";
export const iconSwapIn = "scale-100 opacity-100 blur-none";
export const iconSwapOut = "scale-[0.25] opacity-0 blur-[4px]";
export const labelSwap =
"col-start-1 row-start-1 flex w-max items-center gap-1.5 leading-none transition-[opacity,filter] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none";
export const labelSwapIn = "opacity-100 blur-none";
export const labelSwapOut =
"pointer-events-none select-none opacity-0 blur-[2px]";
export const collapsePanel =
"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none";
export const live = "text-blue-500 dark:text-blue-400";
export const mono = "font-mono text-[11px] tracking-tight";
export function ShimmerLabel({
active = true,
className,
...props
}: ComponentProps<"span"> & { active?: boolean }) {
return (
<span
className={cn(active && "shimmer motion-reduce:animate-none", className)}
{...props}
/>
);
}
/**
* Scroll region for content that keeps its own whitespace. `whitespace-pre` in
* a bounded box clips a long line with no way to reach it, so the rows scroll
* instead.
*
* `codeSurface` wraps all the rows as one block, and the rows are its children.
* It cannot go on each row: `min-width: 100%` resolves against the scroll
* container's visible width rather than its scroll width, so a per-row width
* leaves every row except the longest ending its background at the fold.
*/
export const codeScroll = "overflow-x-auto";
export const codeSurface = "w-max min-w-full";
export function SwapLabel({
active,
children,
className,
}: {
active: 0 | 1;
children: [React.ReactNode, React.ReactNode];
className?: string;
}) {
const layers = [useRef<HTMLSpanElement>(null), useRef<HTMLSpanElement>(null)];
const [width, setWidth] = useState<number | null>(null);
useIsomorphicLayoutEffect(() => {
const target = layers[active]?.current;
if (!target) return undefined;
const measure = () =>
setWidth(Math.ceil(target.getBoundingClientRect().width));
measure();
const observer = new ResizeObserver(measure);
observer.observe(target);
return () => observer.disconnect();
}, [active]);
return (
<span
style={width === null ? undefined : { width }}
className={cn(
"grid overflow-x-clip transition-[width] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
className,
)}
>
{children.map((layer, index) => (
<span
key={index}
ref={layers[index]}
aria-hidden={active !== index}
className={cn(
labelSwap,
active === index ? labelSwapIn : labelSwapOut,
)}
>
{layer}
</span>
))}
</span>
);
}
components/assistant-ui/utils/useIsomorphicLayoutEffect.ts
import { useEffect, useLayoutEffect } from "react";
const hasWindow =
typeof (globalThis as typeof globalThis & { window?: unknown }).window !==
"undefined";
export const useIsomorphicLayoutEffect = hasWindow
? useLayoutEffect
: useEffect;
components/assistant-ui/elements/follow-up-suggestions.aui.tsx
"use client";
import { AuiIf, useAuiState, ThreadPrimitive } from "@assistant-ui/react";
import { useCallback, useEffect, useRef, useState, type FC } from "react";
const FollowupSuggestionsRow: FC = () => {
const suggestions = useAuiState((s) => s.thread.suggestions);
const scrollRef = useRef<HTMLDivElement>(null);
const rtlRef = useRef<boolean | null>(null);
const [fades, setFades] = useState({ left: false, right: false });
const updateFades = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const maxScroll = el.scrollWidth - el.clientWidth;
// scrollLeft runs 0..-max in RTL; normalize to hidden width per physical edge.
const fromStart = Math.abs(el.scrollLeft);
// getComputedStyle forces a style recalc per scroll event; direction is stable, read it once.
const rtl = (rtlRef.current ??= getComputedStyle(el).direction === "rtl");
const [left, right] = rtl
? [maxScroll - fromStart, fromStart]
: [fromStart, maxScroll - fromStart];
setFades((prev) => {
const next = { left: left > 1, right: right > 1 };
return prev.left === next.left && prev.right === next.right ? prev : next;
});
}, []);
useEffect(() => {
updateFades();
const el = scrollRef.current;
if (!el?.firstElementChild) return undefined;
const observer = new ResizeObserver(updateFades);
observer.observe(el);
observer.observe(el.firstElementChild);
return () => observer.disconnect();
}, [updateFades]);
const maskImage = `linear-gradient(to right, ${
fades.left ? "transparent, black 2rem" : "black"
}, ${fades.right ? "black calc(100% - 2rem), transparent" : "black"})`;
return (
<div
ref={scrollRef}
onScroll={updateFades}
// overflow-x clips both axes; py-1/-my-1 gives focus rings vertical room without changing outer height.
className="aui-thread-followup-suggestions -my-1 w-full [scrollbar-width:none] overflow-x-auto py-1 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{ maskImage, WebkitMaskImage: maskImage }}
>
<div className="mx-auto flex min-h-8 w-max items-center gap-2 px-0.5">
{suggestions.map((suggestion, idx) => (
<ThreadPrimitive.Suggestion
key={idx}
className="aui-thread-followup-suggestion border-foreground/10 hover:bg-foreground/[0.03] hover:border-foreground/25 rounded-md border px-2.5 py-1 text-sm whitespace-nowrap transition-colors ease-in motion-reduce:transition-none"
prompt={suggestion.prompt}
send
>
{suggestion.title ?? suggestion.prompt}
{suggestion.label && (
<span className="aui-thread-followup-suggestion-label text-muted-foreground ms-1">
{suggestion.label}
</span>
)}
</ThreadPrimitive.Suggestion>
))}
</div>
</div>
);
};
export const ThreadFollowupSuggestions: FC = () => (
<AuiIf
condition={(s) =>
!s.thread.isEmpty &&
!s.thread.isRunning &&
s.thread.suggestions.length > 0
}
>
<FollowupSuggestionsRow />
</AuiIf>
);
components/assistant-ui/elements/image.tsx
"use client";
import {
memo,
useState,
useEffect,
useCallback,
useRef,
type PropsWithChildren,
} from "react";
import { createPortal } from "react-dom";
import { cva, type VariantProps } from "class-variance-authority";
import {
CopyIcon,
DownloadIcon,
ImageIcon,
ImageOffIcon,
Loader2Icon,
RefreshCwIcon,
ShieldAlertIcon,
XIcon,
} from "lucide-react";
import type {
ImageMessagePart,
ImageMessagePartComponent,
} from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { hostOf, safeHref } from "../utils/href";
const extensionForMimeType = (mimeType?: string): string => {
switch (mimeType) {
case "image/png":
return "png";
case "image/jpeg":
case "image/jpg":
return "jpg";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
case "image/svg+xml":
return "svg";
default:
return "png";
}
};
const dataUriToBlob = (dataUri: string): Blob | null => {
const commaIndex = dataUri.indexOf(",");
const meta = commaIndex >= 0 ? dataUri.slice(0, commaIndex) : dataUri;
const data = commaIndex >= 0 ? dataUri.slice(commaIndex + 1) : "";
const mime =
meta.match(/data:([^;]+)/i)?.[1]?.toLowerCase() ??
"application/octet-stream";
if (!/;base64/i.test(meta)) {
const parts: BlobPart[] = [];
let last = 0;
for (const match of data.matchAll(/(?:%[\da-f]{2})+/gi)) {
if (match.index > last) parts.push(data.slice(last, match.index));
const run = match[0];
const escaped = new Uint8Array(run.length / 3);
for (let index = 0; index < escaped.length; index++) {
escaped[index] = Number.parseInt(
run.slice(index * 3 + 1, index * 3 + 3),
16,
);
}
parts.push(escaped);
last = match.index + run.length;
}
parts.push(data.slice(last));
return new Blob(parts, { type: mime });
}
let bytes: string;
try {
const base64 = data.replace(/%([\da-f]{2})/gi, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
bytes = atob(base64);
} catch {
return null;
}
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
return new Blob([arr], { type: mime });
};
const mimeFromImage = (image: string): string | undefined =>
image.match(/^data:([^;,]+)/i)?.[1]?.toLowerCase();
const defaultFilenameFromImage = (image: string): string => {
const mime = mimeFromImage(image);
if (mime) return `image.${extensionForMimeType(mime)}`;
try {
const path = new URL(image, document.baseURI).pathname;
const encodedBasename = path.split("/").pop() ?? "";
let basename = encodedBasename;
try {
basename = decodeURIComponent(encodedBasename);
} catch {}
if (/\.(png|jpe?g|webp|gif|svg)$/i.test(basename)) return basename;
} catch {}
return "image.png";
};
const safeImageDownloadHref = (image: string): string | null => {
try {
const url = new URL(image, document.baseURI);
return url.protocol === "http:" ||
url.protocol === "https:" ||
url.protocol === "blob:"
? image
: null;
} catch {
return null;
}
};
const downloadImagePart = (
part: Pick<ImageMessagePart, "image" | "filename">,
): void => {
if (typeof document === "undefined") return;
const filename = part.filename ?? defaultFilenameFromImage(part.image);
const isDataUri = /^data:/i.test(part.image);
const blob = isDataUri ? dataUriToBlob(part.image) : null;
if (isDataUri && !blob) return;
const objectUrl = blob ? URL.createObjectURL(blob) : null;
const href = objectUrl ?? safeImageDownloadHref(part.image);
if (!href) return;
const a = document.createElement("a");
a.href = href;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl), 40_000);
};
const copyImagePart = async (
part: Pick<ImageMessagePart, "image">,
): Promise<void> => {
if (
typeof navigator === "undefined" ||
!navigator.clipboard ||
typeof ClipboardItem === "undefined"
) {
throw new Error("Clipboard API is not available in this environment.");
}
const blob = /^data:/i.test(part.image)
? dataUriToBlob(part.image)
: await fetch(part.image).then((r) => r.blob());
if (!blob) return;
const mime = mimeFromImage(part.image) ?? blob.type ?? "image/png";
await navigator.clipboard.write([new ClipboardItem({ [mime]: blob })]);
};
const imageVariants = cva(
"aui-image-root relative overflow-hidden rounded-lg",
{
variants: {
variant: {
outline: "border-border border",
ghost: "",
muted: "bg-muted/50",
},
size: {
sm: "max-w-64",
default: "max-w-96",
lg: "max-w-[512px]",
full: "w-full",
},
},
defaultVariants: {
variant: "outline",
size: "default",
},
},
);
export type ImageRootProps = React.ComponentProps<"div"> &
VariantProps<typeof imageVariants>;
function ImageRoot({
className,
variant,
size,
children,
...props
}: ImageRootProps) {
return (
<div
data-slot="image-root"
data-variant={variant}
data-size={size}
className={cn(imageVariants({ variant, size, className }))}
{...props}
>
{children}
</div>
);
}
type ImagePreviewProps = Omit<React.ComponentProps<"img">, "children"> & {
containerClassName?: string | undefined;
ratio?: "auto" | "1:1" | "4:3" | "16:9" | "9:16" | undefined;
fit?: "cover" | "contain" | undefined;
};
function ImagePreview({
className,
containerClassName,
ratio = "auto",
fit = "contain",
onLoad,
onError,
alt = "Image content",
src,
...props
}: ImagePreviewProps) {
const imgRef = useRef<HTMLImageElement>(null);
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
const [errorSrc, setErrorSrc] = useState<string | undefined>(undefined);
const loaded = loadedSrc === src;
const error = errorSrc === src;
const fixedRatio = ratio !== "auto";
const ratioClassName =
ratio === "1:1"
? "aspect-square"
: ratio === "4:3"
? "aspect-[4/3]"
: ratio === "16:9"
? "aspect-video"
: ratio === "9:16"
? "aspect-[9/16]"
: undefined;
useEffect(() => {
const image = imgRef.current;
if (typeof src !== "string" || !image?.complete) return;
if (image.naturalWidth > 0) setLoadedSrc(src);
else setErrorSrc(src);
}, [src]);
return (
<div
data-slot="image-preview"
className={cn(
"relative",
fixedRatio ? ratioClassName : "min-h-32",
containerClassName,
)}
>
{!loaded && !error && (
<div
data-slot="image-preview-loading"
className="bg-muted/50 absolute inset-0 flex items-center justify-center"
>
<ImageIcon className="text-muted-foreground size-8 animate-pulse" />
</div>
)}
{error ? (
<div
data-slot="image-preview-error"
className={cn(
"bg-muted/50 flex min-h-32 items-center justify-center p-4",
fixedRatio && "absolute inset-0",
)}
>
<ImageOffIcon className="text-muted-foreground size-8" />
</div>
) : (
<img
ref={imgRef}
src={src}
alt={alt}
className={cn(
fixedRatio
? cn("block h-full w-full", {
"object-cover": fit === "cover",
"object-contain": fit === "contain",
})
: "block h-auto w-full object-contain",
!loaded && "invisible",
className,
)}
onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e);
}}
onError={(e) => {
if (typeof src === "string") setErrorSrc(src);
onError?.(e);
}}
{...props}
/>
)}
</div>
);
}
export type ImageSourceProps = {
label?: string | undefined;
url?: string | undefined;
iconUrl?: string | undefined;
} & React.ComponentProps<"div">;
function ImageSource({
className,
label,
url,
iconUrl,
...props
}: ImageSourceProps) {
const href = safeHref(url);
const host = hostOf(url);
const displayLabel = label || host;
const [failedIconUrl, setFailedIconUrl] = useState<string | undefined>();
if (!displayLabel) return null;
const showIcon = iconUrl !== undefined && failedIconUrl !== iconUrl;
return (
<div
data-slot="image-source"
className={cn(
"text-muted-foreground flex items-center gap-2 border-t px-2 py-1.5 text-xs",
className,
)}
{...props}
>
{showIcon ? (
<img
data-slot="image-source-icon"
src={iconUrl}
alt=""
className="size-4 shrink-0 rounded-sm"
onError={() => setFailedIconUrl(iconUrl)}
/>
) : (
<span
data-slot="image-source-icon-fallback"
aria-hidden="true"
className="bg-muted flex size-4 shrink-0 items-center justify-center rounded-sm text-[10px] font-medium"
>
{displayLabel.charAt(0).toUpperCase()}
</span>
)}
{href ? (
<a
data-slot="image-source-label"
href={href}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground truncate"
>
{displayLabel}
<span className="sr-only"> (opens in a new tab)</span>
</a>
) : (
<span data-slot="image-source-label" className="truncate">
{displayLabel}
</span>
)}
</div>
);
}
function ImageFilename({
className,
children,
...props
}: React.ComponentProps<"span">) {
if (!children) return null;
return (
<span
data-slot="image-filename"
className={cn(
"text-muted-foreground block truncate px-2 py-1.5 text-xs",
className,
)}
{...props}
>
{children}
</span>
);
}
type ImageZoomProps = PropsWithChildren<{
src: string;
alt?: string;
}>;
function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => {
setIsOpen(false);
triggerRef.current?.focus();
}, []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
return;
}
if (e.key !== "Tab") return;
const focusables = overlayRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const first = focusables?.[0];
const last = focusables?.[focusables.length - 1];
if (!first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleClose]);
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow;
};
}, [isOpen]);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
return (
<>
<div
ref={triggerRef}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.click();
} else if (e.key === " ") {
e.preventDefault();
}
}}
onKeyUp={(e) => {
if (e.key === " ") e.currentTarget.click();
}}
role="button"
tabIndex={0}
className="aui-image-zoom-trigger cursor-zoom-in"
aria-label="Click to zoom image"
>
{children}
</div>
{isOpen &&
createPortal(
<div
ref={overlayRef}
data-slot="image-zoom-overlay"
role="dialog"
aria-modal="true"
className="aui-image-zoom-overlay fade-in animate-in fixed inset-0 z-50 flex items-center justify-center bg-black/80 duration-200"
onClick={handleClose}
aria-label="Zoomed image"
>
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 animate-in max-h-[90%] max-w-[90%] cursor-zoom-out object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
/>
<button
ref={closeRef}
type="button"
aria-label="Close zoomed image"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="text-muted-foreground hover:text-foreground bg-background/80 absolute end-4 top-4 cursor-pointer rounded-md p-2"
>
<XIcon className="size-5" />
</button>
</div>,
document.body,
)}
</>
);
}
function ImageGenerating({ className }: { className?: string }) {
return (
<div
data-slot="image-generating"
className={cn(
"bg-muted/50 flex min-h-32 items-center justify-center p-4",
className,
)}
>
<Loader2Icon className="text-muted-foreground size-8 animate-spin" />
<span className="sr-only">Generating image…</span>
</div>
);
}
function ImageContentFilterError({
className,
reason,
}: {
className?: string;
reason?: string;
}) {
return (
<div
data-slot="image-content-filter-error"
className={cn(
"bg-muted/50 flex min-h-32 flex-col items-center justify-center gap-2 p-4 text-center",
className,
)}
>
<ShieldAlertIcon className="text-muted-foreground size-8" />
<p className="text-sm font-medium">Image could not be generated</p>
{reason && <p className="text-muted-foreground text-xs">{reason}</p>}
</div>
);
}
export type ImageActionsProps = {
part: ImageMessagePart;
/**
* Wire to your own generation call to show a regenerate button. The button
* renders only when this is set and the part carries a `prompt`.
*/
onRegenerate?: () => void | Promise<void>;
className?: string;
};
function RegenerateButton({
onRegenerate,
}: {
onRegenerate: () => void | Promise<void>;
}) {
const [isRegenerating, setIsRegenerating] = useState(false);
return (
<button
type="button"
onClick={async () => {
setIsRegenerating(true);
try {
await onRegenerate();
} catch {
} finally {
setIsRegenerating(false);
}
}}
disabled={isRegenerating}
data-slot="image-regenerate"
aria-label="Regenerate image"
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded disabled:opacity-50"
>
<RefreshCwIcon
className={cn("size-4", isRegenerating && "animate-spin")}
/>
</button>
);
}
function ImageActions({ part, onRegenerate, className }: ImageActionsProps) {
return (
<div
data-slot="image-actions"
className={cn("flex items-center gap-1 p-1", className)}
>
<button
type="button"
onClick={() => downloadImagePart(part)}
data-slot="image-download"
aria-label="Download image"
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded"
>
<DownloadIcon className="size-4" />
</button>
<button
type="button"
onClick={() => {
copyImagePart(part).catch(() => {});
}}
data-slot="image-copy"
aria-label="Copy image"
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded"
>
<CopyIcon className="size-4" />
</button>
{onRegenerate && <RegenerateButton onRegenerate={onRegenerate} />}
</div>
);
}
const ImageImpl: ImageMessagePartComponent = (props) => {
const { image, filename, status } = props;
if (status?.type === "running") {
return (
<ImageRoot>
<ImageGenerating />
<ImageFilename>{filename}</ImageFilename>
</ImageRoot>
);
}
if (status?.type === "incomplete" && status.reason === "content-filter") {
return (
<ImageRoot>
<ImageContentFilterError reason="The provider blocked this image." />
</ImageRoot>
);
}
return (
<ImageRoot>
<ImageZoom src={image} alt={filename || "Image content"}>
<ImagePreview src={image} alt={filename || "Image content"} />
</ImageZoom>
<ImageFilename>{filename}</ImageFilename>
</ImageRoot>
);
};
const Image = memo(ImageImpl) as unknown as ImageMessagePartComponent & {
Root: typeof ImageRoot;
Preview: typeof ImagePreview;
Source: typeof ImageSource;
Filename: typeof ImageFilename;
Zoom: typeof ImageZoom;
Actions: typeof ImageActions;
Generating: typeof ImageGenerating;
ContentFilterError: typeof ImageContentFilterError;
};
Image.displayName = "Image";
Image.Root = ImageRoot;
Image.Preview = ImagePreview;
Image.Source = ImageSource;
Image.Filename = ImageFilename;
Image.Zoom = ImageZoom;
Image.Actions = ImageActions;
Image.Generating = ImageGenerating;
Image.ContentFilterError = ImageContentFilterError;
export {
Image,
ImageRoot,
ImagePreview,
ImageSource,
ImageFilename,
ImageZoom,
ImageActions,
ImageGenerating,
ImageContentFilterError,
imageVariants,
};
components/assistant-ui/utils/href.ts
/**
* Link targets the elements take from a model or a tool.
*
* React 19 refuses a `javascript:` href but React 18 only warns, and neither
* blocks `data:` or `vbscript:`, so an element passes a caller's URL through
* here before it becomes an `href` or a navigation target.
*/
const RELATIVE = /^(?:[/?#]|\.{1,2}\/)/;
const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
const parse = (url: string) => {
try {
return new URL(url);
} catch {
return undefined;
}
};
/**
* `url` when a browser following it runs no script: an `http:`, `https:`, or
* `mailto:` URL, or one relative to the page. Anything else, including a
* string that is not a URL at all, is `undefined`.
*/
export function safeHref(url: string | undefined): string | undefined {
if (typeof url !== "string") return undefined;
const trimmed = url.trim();
if (trimmed === "") return undefined;
const parsed = parse(trimmed);
if (parsed) {
return ALLOWED_PROTOCOLS.has(parsed.protocol) ? trimmed : undefined;
}
return RELATIVE.test(trimmed) ? trimmed : undefined;
}
/** The host `url` points at, without a leading `www.`, or `undefined`. */
export function hostOf(url: string | undefined): string | undefined {
const href = safeHref(url);
const hostname = href === undefined ? "" : (parse(href)?.hostname ?? "");
return hostname.replace(/^www\./, "") || undefined;
}
components/assistant-ui/elements/markdown-text.tsx
"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
type CodeHeaderProps,
MarkdownTextPrimitive,
type MarkdownTextPrimitiveProps,
memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import remarkGfm from "remark-gfm";
import { type FC, memo, useMemo, useRef } from "react";
import type { TextMessagePartProps } from "@assistant-ui/react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { TooltipIconButton } from "@/components/assistant-ui/elements/tooltip-icon-button";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { cn } from "@/lib/utils";
type MarkdownTextProps = Partial<TextMessagePartProps> & {
components?: Parameters<typeof memoizeMarkdownComponents>[0];
componentsByLanguage?: MarkdownTextPrimitiveProps["componentsByLanguage"];
};
const useShallowStable = <T extends Record<string, unknown> | undefined>(
value: T,
): T => {
const ref = useRef(value);
if (value !== ref.current) {
const prev = ref.current;
const stable =
value !== undefined &&
prev !== undefined &&
Object.keys(prev).length === Object.keys(value).length &&
Object.keys(value).every((key) => prev[key] === value[key]);
if (!stable) ref.current = value;
}
return ref.current;
};
const MarkdownTextImpl: FC<MarkdownTextProps> = ({
components,
componentsByLanguage,
}) => {
const stableComponents = useShallowStable(components);
const stableComponentsByLanguage = useShallowStable(componentsByLanguage);
const markdownComponents = useMemo(() => {
if (!stableComponents) return defaultComponents;
return {
...defaultComponents,
...memoizeMarkdownComponents(stableComponents),
};
}, [stableComponents]);
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md"
components={markdownComponents}
componentsByLanguage={stableComponentsByLanguage}
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 defaultComponents = memoizeMarkdownComponents({
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 }) => (
<div className="aui-md-table-wrapper my-3 overflow-x-auto">
<table
className={cn(
"aui-md-table w-full border-separate border-spacing-0",
className,
)}
{...props}
/>
</div>
),
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,
});
hooks/use-copy-to-clipboard.ts
"use client";
import { useEffect, useRef, useState } from "react";
export type UseCopyToClipboardOptions = {
copiedDuration?: number;
};
export const useCopyToClipboard = ({
copiedDuration = 3000,
}: UseCopyToClipboardOptions = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const scopeGenerationRef = useRef(0);
useEffect(
() => () => {
scopeGenerationRef.current += 1;
if (copiedTimerRef.current === undefined) return;
clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = undefined;
setIsCopied(false);
},
[],
);
const copyToClipboard = (value: string) => {
if (!value || typeof navigator === "undefined" || !navigator.clipboard) {
return;
}
const scopeGeneration = scopeGenerationRef.current;
navigator.clipboard.writeText(value).then(
() => {
if (scopeGeneration !== scopeGenerationRef.current) return;
if (copiedTimerRef.current !== undefined) {
clearTimeout(copiedTimerRef.current);
}
setIsCopied(true);
copiedTimerRef.current = setTimeout(() => {
copiedTimerRef.current = undefined;
setIsCopied(false);
}, copiedDuration);
},
() => {},
);
};
return { isCopied, copyToClipboard };
};
components/assistant-ui/elements/reasoning.aui.tsx
"use client";
import { forwardRef, memo, useCallback, useRef } from "react";
import {
useScrollLock,
useAuiState,
type ReasoningMessagePartComponent,
type ReasoningGroupComponent,
} from "@assistant-ui/react";
import { MarkdownText } from "@/components/assistant-ui/elements/markdown-text";
import {
ANIMATION_DURATION,
ReasoningRoot as ReasoningRootBase,
ReasoningTrigger,
ReasoningContent,
ReasoningText,
ReasoningFade,
reasoningVariants,
type ReasoningRootProps,
} from "./reasoning";
export type { ReasoningRootProps } from "./reasoning";
/** `ReasoningRoot` with the thread viewport scroll locked during disclosure animations. */
const ReasoningRoot = forwardRef<
HTMLDivElement,
Omit<ReasoningRootProps, "ref">
>(function ReasoningRoot({ onAnimationStart, ...props }, ref) {
const collapsibleRef = useRef<HTMLDivElement | null>(null);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const handleAnimationStart = useCallback(() => {
lockScroll();
onAnimationStart?.();
}, [lockScroll, onAnimationStart]);
const composedRef = useCallback(
(node: HTMLDivElement | null) => {
collapsibleRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as { current: HTMLDivElement | null }).current = node;
}
},
[ref],
);
return (
<ReasoningRootBase
ref={composedRef}
onAnimationStart={handleAnimationStart}
{...props}
/>
);
});
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
const ReasoningGroupImpl: ReasoningGroupComponent = ({
children,
startIndex,
endIndex,
}) => {
const isReasoningStreaming = useAuiState((s) => {
if (s.message.status?.type !== "running") return false;
for (let index = startIndex; index <= endIndex; index++) {
if (s.message.parts[index]?.status.type === "running") return true;
}
return false;
});
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.aui.tsx` for an example.
*/
const ReasoningGroup = memo(ReasoningGroupImpl);
ReasoningGroup.displayName = "ReasoningGroup";
export {
Reasoning,
ReasoningGroup,
ReasoningRoot,
ReasoningTrigger,
ReasoningContent,
ReasoningText,
ReasoningFade,
reasoningVariants,
};
components/assistant-ui/elements/reasoning.tsx
"use client";
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
import { cva, type VariantProps } from "class-variance-authority";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
CollapsibleRoot as Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible-root";
export 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. While `true` the
* disclosure is held open with a bottom-pinned live preview; when
* streaming ends it returns to `defaultOpen`, and the first manual
* toggle takes over the open/close state permanently. The live preview
* keeps following the newest tokens while the disclosure is open during
* streaming, even after a manual toggle, and pauses while the reader is
* scrolled up.
*/
streaming?: boolean;
/** Called right before the disclosure animates, on toggle and on streaming transitions. */
onAnimationStart?: () => void;
};
const ReasoningRoot = forwardRef<
HTMLDivElement,
Omit<ReasoningRootProps, "ref">
>(function ReasoningRoot(
{
className,
variant,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
defaultOpen = false,
streaming,
onAnimationStart,
children,
...props
},
ref,
) {
const [initialOpen] = useState(defaultOpen);
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled
? controlledOpen
: (userOpen ?? (streaming || initialOpen));
const isPreview = streaming === true && isOpen;
const prevStreamingRef = useRef(streaming);
useIsomorphicLayoutEffect(() => {
if (prevStreamingRef.current === streaming) return;
prevStreamingRef.current = streaming;
// A streaming transition only animates the panel when the resting state
// is collapsed; with `defaultOpen` the disclosure stays open across it.
if (!isControlled && userOpen === null && !initialOpen) {
onAnimationStart?.();
}
}, [streaming, isControlled, userOpen, initialOpen, onAnimationStart]);
const handleOpenChange = useCallback(
(open: boolean) => {
onAnimationStart?.();
if (!isControlled) {
setUserOpen(open);
}
controlledOnOpenChange?.(open);
},
[onAnimationStart, isControlled, controlledOnOpenChange],
);
return (
<Collapsible
ref={ref}
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,color-mix(in_oklab,var(--color-muted)_50%,var(--color-background)),transparent)]",
"fade-in-0 animate-in",
"animation-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,color-mix(in_oklab,var(--color-muted)_50%,var(--color-background)),transparent)]",
"fade-in-0 animate-in",
"animation-duration-(--animation-duration)",
className,
)}
{...props}
/>
);
}
function ReasoningTrigger({
active,
duration,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
active?: boolean;
duration?: number | undefined;
}) {
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%] origin-left items-center gap-2 py-1.5 text-sm transition-[color,scale] active:scale-[0.98]",
className,
)}
{...props}
>
<BrainIcon
data-slot="reasoning-trigger-icon"
className="aui-reasoning-trigger-icon size-4 shrink-0"
/>
<span
data-slot="reasoning-trigger-label"
className={cn(
"aui-reasoning-trigger-label-wrapper inline-block leading-none tabular-nums",
active && "shimmer motion-reduce:animate-none",
)}
>
Reasoning{durationText}
</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-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
"-rotate-90",
"group-data-open/trigger:rotate-0",
"group-data-panel-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-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
"data-closed:animate-collapsible-up",
"data-open:animate-collapsible-down",
"data-closed:fill-mode-forwards",
"data-closed:pointer-events-none",
"[--tw-duration:var(--animation-duration)]",
className,
)}
{...props}
>
<ReasoningFade side="top" />
{children}
{isPreview ? <ReasoningFade /> : null}
</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;
let pinned = true;
let lastScrollTop = scrollEl.scrollTop;
let lastScrollHeight = scrollEl.scrollHeight;
const isAtBottom = () =>
Math.abs(
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight,
) <= 1 || scrollEl.scrollHeight <= scrollEl.clientHeight;
const pin = () => {
if (!pinned) return;
scrollEl.scrollTop = scrollEl.scrollHeight;
};
// A pin's own scroll event can arrive after new content grew the scroll
// height and read as "not at bottom"; only an upward move at unchanged
// scroll height is user intent.
const onScroll = () => {
if (isAtBottom()) {
pinned = true;
} else if (
scrollEl.scrollTop < lastScrollTop &&
scrollEl.scrollHeight === lastScrollHeight
) {
pinned = false;
}
lastScrollTop = scrollEl.scrollTop;
lastScrollHeight = scrollEl.scrollHeight;
};
pin();
scrollEl.addEventListener("scroll", onScroll);
const observer = new ResizeObserver(pin);
observer.observe(contentEl);
return () => {
scrollEl.removeEventListener("scroll", onScroll);
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 text-pretty",
"transform-gpu transition-[transform,opacity] ease-[cubic-bezier(0.32,0.72,0,1)]",
"motion-reduce:animate-none",
"group-data-open/collapsible-content:animate-in",
"group-data-closed/collapsible-content:animate-out",
"group-data-open/collapsible-content:fade-in-0",
"group-data-closed/collapsible-content:fade-out-0",
"group-data-open/collapsible-content:slide-in-from-top-4",
"group-data-closed/collapsible-content:slide-out-to-top-4",
"group-data-open/collapsible-content:blur-in-[2px]",
"group-data-closed/collapsible-content:blur-out-[2px]",
"group-data-open/collapsible-content:animation-duration-(--animation-duration)",
"group-data-closed/collapsible-content:animation-duration-(--animation-duration)",
className,
)}
{...props}
>
<div ref={contentRef} className="aui-reasoning-text-content space-y-4">
{children}
</div>
</div>
);
}
export {
ReasoningRoot,
ReasoningTrigger,
ReasoningContent,
ReasoningText,
ReasoningFade,
reasoningVariants,
};
components/assistant-ui/utils/useIsomorphicLayoutEffect.ts
import { useEffect, useLayoutEffect } from "react";
const hasWindow =
typeof (globalThis as typeof globalThis & { window?: unknown }).window !==
"undefined";
export const useIsomorphicLayoutEffect = hasWindow
? useLayoutEffect
: useEffect;
components/assistant-ui/elements/collapsible-root.tsx
"use client";
import { Collapsible } from "radix-ui";
import { forwardRef } from "react";
export const CollapsibleRoot = forwardRef<
React.ComponentRef<typeof Collapsible.Root>,
React.ComponentPropsWithoutRef<typeof Collapsible.Root>
>(function CollapsibleRoot(props, ref) {
return <Collapsible.Root ref={ref} data-slot="collapsible" {...props} />;
});
export function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof Collapsible.CollapsibleTrigger>) {
return (
<Collapsible.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
export function CollapsibleContent({
...props
}: React.ComponentProps<typeof Collapsible.CollapsibleContent>) {
return (
<Collapsible.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
components/assistant-ui/elements/tool-fallback.aui.tsx
"use client";
import {
forwardRef,
memo,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
AlertCircleIcon,
CheckIcon,
ChevronDownIcon,
CircleMinusIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import {
toolApprovalAcceptsText,
useAuiState,
useScrollLock,
useToolCallElapsed,
type ToolApprovalAnswer,
type ToolApprovalOption,
type ToolApprovalQuestion,
type ToolCallMessagePart,
type ToolCallMessagePartProps,
type ToolCallMessagePartStatus,
type ToolCallMessagePartComponent,
} from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import {
CollapsibleRoot as Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible-root";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
const ANIMATION_DURATION = 200;
const pressable = "active:scale-[0.98]";
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 =
statusType === "running"
? "Running tool"
: statusType === "requires-action"
? "Waiting on tool"
: statusType === "incomplete"
? `${isCancelled ? "Cancelled" : "Failed"} 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 max-w-full origin-left items-center gap-2 py-1.5 text-sm transition-[color,scale] active:scale-[0.98]",
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 [animation-duration:0.6s]",
)}
/>
<span
data-slot="tool-fallback-trigger-label"
className={cn(
"aui-tool-fallback-trigger-label-wrapper inline-block min-w-0 text-start leading-none wrap-anywhere",
isCancelled && "text-muted-foreground line-through",
isRunning && "shimmer motion-reduce:animate-none",
)}
>
{label}: <b>{toolName}</b>
</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-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
"-rotate-90",
"group-data-open/trigger:rotate-0",
"group-data-panel-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-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
"data-closed:animate-collapsible-up",
"data-open:animate-collapsible-down",
"data-closed:fill-mode-forwards",
"data-closed:pointer-events-none",
"[--tw-duration:var(--animation-duration)]",
className,
)}
{...props}
>
<div
className={cn(
"flex flex-col gap-2 ps-6 pt-1 pb-2 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
"group-data-open/collapsible-content:animate-in group-data-open/collapsible-content:fade-in-0 group-data-open/collapsible-content:blur-in-[2px] group-data-open/collapsible-content:slide-in-from-top-1",
"group-data-closed/collapsible-content:animate-out group-data-closed/collapsible-content:fade-out-0 group-data-closed/collapsible-content:blur-out-[2px] group-data-closed/collapsible-content:slide-out-to-top-1",
"group-data-closed/collapsible-content:animation-duration-(--animation-duration) group-data-open/collapsible-content:animation-duration-(--animation-duration)",
)}
>
{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-foreground/90 rounded-md p-2.5 text-xs break-words whitespace-pre-wrap">
{argsText}
</pre>
</div>
);
}
const formatUnknownValue = (value: unknown, space?: number): string => {
if (typeof value === "string") return value;
try {
if (value instanceof Error) return String(value);
const json = JSON.stringify(value, null, space);
if (json !== undefined) return json;
} catch {}
try {
return String(value);
} catch {
return "[Unserializable value]";
}
};
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-foreground/90 mt-1 rounded-md p-2.5 text-xs break-words whitespace-pre-wrap">
{formatUnknownValue(result, 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 === undefined || error === null ? null : formatUnknownValue(error);
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 break-words whitespace-pre-line">
{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 isKnownKind = (kind: string) =>
Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, kind);
const isAllowKind = (kind: string) =>
kind === "allow-once" || kind === "allow-always";
const approvalOptionLabel = (option: ToolApprovalOption) =>
option.label ??
(isKnownKind(option.kind)
? APPROVAL_OPTION_DEFAULT_LABELS[option.kind]
: undefined) ??
option.id;
/**
* A request that declares how it wants to be presented is asking a question,
* not gating an action, so a refusal is not one of the answers it accepts
* unless the request declares itself dismissible.
*/
const isQuestion = (approval: ToolCallMessagePart["approval"]) =>
approval?.display === "select" ||
approval?.display === "text" ||
approval?.display === "questions";
const questionAcceptsText = (question: ToolApprovalQuestion) =>
!question.options?.length || question.allowFreeform === true;
const answerLabels = (
question: ToolApprovalQuestion,
answer: ToolApprovalAnswer | undefined,
) => [
...(answer?.optionIds ?? []).map(
(id) => question.options?.find((option) => option.id === id)?.label ?? id,
),
...(answer?.text?.trim() ? [answer.text] : []),
];
const isSettled = (approval: ToolCallMessagePart["approval"]) =>
approval != null &&
(approval.approved !== undefined || approval.resolution !== undefined);
type ApprovalReceipt = {
outcome: "allowed" | "refused" | "closed";
label: string;
option?: string | undefined;
};
/**
* A settled request reads as a past-tense record of what happened to it, so
* scrolling back never shows a live control for a decision already made.
*/
const approvalReceipt = (
approval: NonNullable<ToolCallMessagePart["approval"]>,
): ApprovalReceipt => {
if (approval.resolution !== undefined)
return {
outcome: "closed",
label:
approval.resolution === "cancelled"
? "Cancelled before a decision"
: "Expired before a decision",
};
const chosen =
approval.optionId === undefined
? undefined
: approval.options?.find((option) => option.id === approval.optionId);
const option =
chosen !== undefined
? approvalOptionLabel(chosen)
: approval.optionId === undefined
? undefined
: approval.optionId;
const answered =
isQuestion(approval) || (chosen !== undefined && !isKnownKind(chosen.kind));
const automatic = approval.isAutomatic ? " automatically" : "";
if (approval.approved)
return {
outcome: "allowed",
label: `${answered ? "Answered" : "Allowed"}${automatic}`,
option,
};
return {
outcome: "refused",
label: `${answered ? "Dismissed" : "Denied"}${automatic}`,
option,
};
};
const receiptIcons = {
allowed: CheckIcon,
refused: XCircleIcon,
closed: CircleMinusIcon,
} satisfies Record<ApprovalReceipt["outcome"], React.ElementType>;
function ToolFallbackApprovalReceipt({
approval,
className,
focusReceiptRef,
receiptRef,
...props
}: React.ComponentPropsWithoutRef<"div"> & {
approval: NonNullable<ToolCallMessagePart["approval"]>;
focusReceiptRef: React.MutableRefObject<string | null | undefined>;
receiptRef: React.MutableRefObject<HTMLDivElement | null>;
}) {
useLayoutEffect(() => {
const shouldFocus = focusReceiptRef.current === approval.id;
focusReceiptRef.current = null;
if (shouldFocus) receiptRef.current?.focus({ preventScroll: true });
}, [approval.id, focusReceiptRef, receiptRef]);
const receipt = approvalReceipt(approval);
const Icon = receiptIcons[receipt.outcome];
const notes = [
...new Set(
[approval.text, approval.reason].filter(
(value): value is string => typeof value === "string" && value !== "",
),
),
];
return (
<div
ref={receiptRef}
data-slot="tool-fallback-approval-receipt"
data-outcome={receipt.outcome}
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval-receipt flex flex-col gap-1.5 pt-1",
className,
)}
{...props}
>
{approval.prompt ? (
<p className="aui-tool-fallback-approval-prompt text-muted-foreground whitespace-pre-line">
{approval.prompt}
</p>
) : null}
<p className="aui-tool-fallback-approval-receipt-label flex items-center gap-1.5">
<Icon aria-hidden className="text-muted-foreground size-3.5 shrink-0" />
<span className="font-medium">{receipt.label}</span>
{receipt.option !== undefined ? (
<span className="text-muted-foreground">· {receipt.option}</span>
) : null}
</p>
{approval.answers &&
approval.questions?.map((question) => {
const labels = answerLabels(
question,
approval.answers && Object.hasOwn(approval.answers, question.id)
? approval.answers[question.id]
: undefined,
);
if (labels.length === 0) return null;
return (
<p
key={question.id}
className="aui-tool-fallback-approval-receipt-answer whitespace-pre-line"
>
<span className="text-muted-foreground">
{question.header ?? question.prompt}
</span>{" "}
· {labels.join(", ")}
</p>
);
})}
{notes.map((text) => (
<p
key={text}
className="aui-tool-fallback-approval-receipt-note text-muted-foreground whitespace-pre-line"
>
{text}
</p>
))}
</div>
);
}
function ToolFallbackApprovalQuestions({
questions,
dismissible,
locked,
onSend,
onDismiss,
}: {
questions: readonly ToolApprovalQuestion[];
dismissible: boolean;
locked: boolean;
onSend: (answers: Record<string, ToolApprovalAnswer>) => void;
onDismiss: () => void;
}) {
const [selected, setSelected] = useState<
ReadonlyMap<string, readonly string[]>
>(() => new Map());
const [typed, setTyped] = useState<ReadonlyMap<string, string>>(
() => new Map(),
);
const toggle = (question: ToolApprovalQuestion, optionId: string) =>
setSelected((current) => {
const chosen = current.get(question.id) ?? [];
const next = chosen.includes(optionId)
? chosen.filter((id) => id !== optionId)
: question.multiple
? [...chosen, optionId]
: [optionId];
return new Map(current).set(question.id, next);
});
const answerOf = (question: ToolApprovalQuestion): ToolApprovalAnswer => {
const optionIds = selected.get(question.id) ?? [];
const draft = typed.get(question.id);
const text = draft?.trim() ? draft : undefined;
return {
...(optionIds.length > 0 && { optionIds }),
...(text !== undefined && { text }),
};
};
const complete =
questions.length > 0 &&
questions.every((question) => {
const answer = answerOf(question);
return answer.optionIds !== undefined || answer.text !== undefined;
});
const send = () => {
if (locked || !complete) return;
onSend(
Object.fromEntries(
questions.map((question) => [question.id, answerOf(question)]),
),
);
};
return (
<>
{questions.map((question) => {
const chosen = selected.get(question.id) ?? [];
const described =
question.options?.some((option) => option.description) ?? false;
return (
<div
key={question.id}
role="group"
aria-label={question.prompt}
data-slot="tool-fallback-approval-question"
className="aui-tool-fallback-approval-question flex flex-col gap-2"
>
<p className="aui-tool-fallback-approval-question-prompt text-foreground whitespace-pre-line">
{question.header ? (
<span className="text-muted-foreground me-1.5 text-xs font-medium uppercase">
{question.header}
</span>
) : null}
{question.prompt}
</p>
{question.options && question.options.length > 0 ? (
<div
className={cn(
"flex gap-2",
described
? "flex-col items-stretch"
: "flex-wrap items-center",
)}
>
{question.options.map((option) => {
const pressed = chosen.includes(option.id);
return (
<Button
key={option.id}
size="sm"
variant={pressed ? "default" : "outline"}
className={cn(
pressable,
described &&
"h-auto flex-col items-start gap-0.5 py-2 text-start whitespace-normal",
)}
aria-pressed={pressed}
onClick={() => toggle(question, option.id)}
disabled={locked}
>
<span>{option.label}</span>
{option.description ? (
<span className="text-xs font-normal opacity-80">
{option.description}
</span>
) : null}
</Button>
);
})}
</div>
) : null}
{questionAcceptsText(question) ? (
<Textarea
value={typed.get(question.id) ?? ""}
onChange={(event) =>
setTyped((current) =>
new Map(current).set(question.id, event.target.value),
)
}
disabled={locked}
aria-label={question.prompt}
placeholder={
question.options?.length
? "Or type an answer"
: "Type your answer"
}
/>
) : null}
</div>
);
})}
<div className="flex items-center gap-2">
<Button
size="sm"
className={pressable}
onClick={send}
disabled={locked || !complete}
>
Send
</Button>
{dismissible ? (
<Button
size="sm"
variant="outline"
className={pressable}
onClick={onDismiss}
disabled={locked}
>
Dismiss
</Button>
) : null}
</div>
</>
);
}
const offersInterruptAction = (
status: ToolCallMessagePartStatus | undefined,
approval: ToolCallMessagePart["approval"],
interrupt: ToolCallMessagePart["interrupt"],
) =>
status?.type !== "requires-action" ||
status.reason !== "interrupt" ||
approval != null ||
interrupt != null;
type ToolFallbackApprovalProps = React.ComponentPropsWithoutRef<"div"> &
Partial<
Pick<
ToolCallMessagePartProps,
"addResult" | "resume" | "respondToApproval" | "status"
>
> & {
interrupt?: ToolCallMessagePart["interrupt"];
approval?: ToolCallMessagePart["approval"];
};
const ToolFallbackApproval = forwardRef<
HTMLDivElement,
ToolFallbackApprovalProps
>(function ToolFallbackApproval(props, ref) {
const carryFocusRef = useRef(false);
return (
<ToolFallbackApprovalImpl
key={props.approval?.id}
carryFocusRef={carryFocusRef}
forwardedRef={ref}
{...props}
/>
);
});
function ToolFallbackApprovalImpl({
className,
forwardedRef,
carryFocusRef,
addResult,
resume,
interrupt,
approval,
respondToApproval,
status,
...props
}: Omit<ToolFallbackApprovalProps, "ref"> & {
forwardedRef: React.ForwardedRef<HTMLDivElement>;
carryFocusRef: React.MutableRefObject<boolean>;
}) {
const [submitted, setSubmitted] = useState(false);
const voiceActive = useAuiState((s) => s.thread.voice !== undefined);
const canAnswer = useAuiState((s) => s.thread.capabilities.answerToolCall);
const locked = submitted || voiceActive;
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const [answer, setAnswer] = useState("");
const [error, setError] = useState<string | null>(null);
const isRequiresAction = status?.type === "requires-action";
const [prevRequiresAction, setPrevRequiresAction] =
useState(isRequiresAction);
const [attempt, setAttempt] = useState(0);
const attemptRef = useRef(attempt);
useLayoutEffect(() => {
attemptRef.current = attempt;
}, [attempt]);
if (isRequiresAction !== prevRequiresAction) {
setPrevRequiresAction(isRequiresAction);
if (isRequiresAction) {
setSubmitted(false);
setConfirmingId(null);
setAnswer("");
setError(null);
setAttempt(attempt + 1);
}
}
const pendingGroupRef = useRef<HTMLDivElement | null>(null);
const receiptRef = useRef<HTMLDivElement | null>(null);
const focusReceiptRef = useRef<string | null | undefined>(null);
useLayoutEffect(() => {
if (carryFocusRef.current) {
carryFocusRef.current = false;
pendingGroupRef.current?.focus({ preventScroll: true });
}
return () => {
const element = pendingGroupRef.current ?? receiptRef.current;
carryFocusRef.current =
element?.contains(element.ownerDocument.activeElement) ?? false;
};
}, [carryFocusRef]);
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(
forwardedRef,
() => pendingGroupRef.current ?? receiptRef.current,
);
const setPendingGroup = useCallback(
(element: HTMLDivElement | null) => {
const group = pendingGroupRef.current;
focusReceiptRef.current =
!element && group?.contains(group.ownerDocument.activeElement)
? approval?.id
: null;
pendingGroupRef.current = element;
},
[approval?.id],
);
const hidePendingApproval =
approval != null &&
!isSettled(approval) &&
status !== undefined &&
status.type !== "requires-action" &&
!(submitted && status.type === "running");
useLayoutEffect(() => {
if (hidePendingApproval) focusReceiptRef.current = null;
}, [hidePendingApproval]);
const focusPendingGroup = () => {
const group = pendingGroupRef.current;
if (group?.contains(group.ownerDocument.activeElement)) {
group.focus({ preventScroll: true });
}
};
if (approval != null && isSettled(approval))
return (
<ToolFallbackApprovalReceipt
approval={approval}
receiptRef={receiptRef}
focusReceiptRef={focusReceiptRef}
className={className}
{...props}
/>
);
if (
hidePendingApproval ||
!offersInterruptAction(status, approval, interrupt)
)
return null;
const promptText = approval?.prompt ? (
<p className="aui-tool-fallback-approval-prompt text-foreground whitespace-pre-line">
{approval.prompt}
</p>
) : null;
if (!canAnswer)
return (
promptText && (
<div
data-slot="tool-fallback-approval"
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval flex flex-col gap-2 pt-1",
className,
)}
{...props}
ref={setPendingGroup}
>
{promptText}
</div>
)
);
// A declared option list is a host constraint: the kit never adds an
// approval path beyond it, and preserves a refusal path only where the
// request is an action the user may refuse.
const declaredOptions = respondToApproval ? approval?.options : undefined;
const acceptsText =
approval != null &&
respondToApproval != null &&
toolApprovalAcceptsText(approval);
// A refused response leaves the request open, so the controls come back
// rather than staying spent on a decision the runtime never recorded.
const submit = (send: () => Promise<void> | void) => {
focusPendingGroup();
setSubmitted(true);
setConfirmingId(null);
setError(null);
void (async () => {
try {
await send();
} catch (sendError) {
if (attemptRef.current !== attempt) return;
setSubmitted(false);
setError(
sendError instanceof Error ? sendError.message : String(sendError),
);
}
})();
};
const respond = (approved: boolean) => {
if (locked) return;
if (
approval != null &&
approval.approved === undefined &&
respondToApproval
) {
submit(() => respondToApproval({ approved, ...typedNote() }));
} else if (interrupt) {
submit(() => resume?.({ approved }));
} else if (
status?.type === "requires-action" &&
status.reason === "interrupt"
) {
return;
} else {
submit(() => addResult?.(approved ? APPROVED_RESULT : DENIED_RESULT));
}
};
const respondWithOption = (option: ToolApprovalOption) => {
if (locked) return;
// A custom kind has no decision class for the runtime to derive, and
// responding without one throws; picking a declared option is an answer,
// so it resolves as approved.
submit(() =>
respondToApproval?.(
isKnownKind(option.kind)
? { optionId: option.id, ...typedNote() }
: { optionId: option.id, approved: true, ...typedNote() },
),
);
};
const typedNote = () => (answer.trim() ? { text: answer } : {});
// The kit does not validate an answer the request never constrained: a host
// that cannot record an empty one rejects it, which reopens the controls.
const submitAnswer = () => {
if (locked) return;
submit(() => respondToApproval?.({ text: answer }));
};
// A dismissal is no answer at all, so a typed draft does not travel with it.
const dismiss = () => {
if (locked) return;
submit(() => respondToApproval?.({ approved: false }));
};
const handleOption = (option: ToolApprovalOption) => {
if (option.confirm) {
focusPendingGroup();
setConfirmingId(option.id);
} else {
respondWithOption(option);
}
};
const confirming =
confirmingId != null
? declaredOptions?.find((o) => o.id === confirmingId)
: undefined;
const question = isQuestion(approval);
const dismissible =
question && respondToApproval != null && approval?.dismissible === true;
const dismissButton = dismissible ? (
<Button
size="sm"
variant="outline"
className={pressable}
onClick={dismiss}
disabled={locked}
>
Dismiss
</Button>
) : null;
const errorText = error ? (
<p
role="alert"
className="aui-tool-fallback-approval-error text-destructive text-xs whitespace-pre-line"
>
{error}
</p>
) : null;
const answerField = acceptsText ? (
<div className="aui-tool-fallback-approval-answer flex flex-col items-start gap-2">
<Textarea
value={answer}
onChange={(event) => setAnswer(event.target.value)}
disabled={locked}
aria-label={question ? (approval?.prompt ?? "Answer") : "Note"}
placeholder={
question ? "Type your answer" : "Add a note to your decision"
}
/>
{question && (
<div className="flex items-center gap-2">
<Button
size="sm"
className={pressable}
onClick={submitAnswer}
disabled={locked}
>
Send
</Button>
{dismissButton}
</div>
)}
</div>
) : null;
if (approval?.display === "questions" && respondToApproval) {
return (
<div
data-slot="tool-fallback-approval"
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval flex flex-col gap-3 pt-1",
className,
)}
{...props}
ref={setPendingGroup}
>
{promptText}
<ToolFallbackApprovalQuestions
questions={approval.questions ?? []}
dismissible={approval.dismissible === true}
locked={locked}
onSend={(answers) => submit(() => respondToApproval({ answers }))}
onDismiss={dismiss}
/>
{errorText}
</div>
);
}
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"
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval-confirm flex flex-col gap-2 pt-1",
className,
)}
{...props}
ref={setPendingGroup}
>
<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 whitespace-pre-line">
{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"
className={pressable}
onClick={() => respondWithOption(confirming)}
disabled={locked}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
className={pressable}
onClick={() => {
focusPendingGroup();
setConfirmingId(null);
}}
disabled={locked}
>
Back
</Button>
</div>
</div>
);
}
if (declaredOptions && declaredOptions.length > 0) {
const allowOptions = declaredOptions.filter((o) => isAllowKind(o.kind));
const customOptions = declaredOptions.filter((o) => !isKnownKind(o.kind));
const rejectOptions = declaredOptions.filter(
(o) => isKnownKind(o.kind) && !isAllowKind(o.kind),
);
return (
<div
data-slot="tool-fallback-approval"
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval flex flex-col gap-2 pt-1",
className,
)}
{...props}
ref={setPendingGroup}
>
{promptText}
<div className="flex flex-wrap items-center gap-2">
{[...allowOptions, ...customOptions, ...rejectOptions].map(
(option) => (
<Button
key={option.id}
size="sm"
variant={option === allowOptions[0] ? "default" : "outline"}
className={pressable}
onClick={() => handleOption(option)}
disabled={locked}
>
{approvalOptionLabel(option)}
</Button>
),
)}
{rejectOptions.length === 0 && !question && (
<Button
size="sm"
variant="outline"
className={pressable}
onClick={() => respond(false)}
disabled={locked}
>
Deny
</Button>
)}
{!acceptsText && dismissButton}
</div>
{answerField}
{errorText}
</div>
);
}
// A question carries no decision to fabricate, so it renders only what the
// request declared, even when that leaves nothing to act on here.
if (question) {
return (
<div
data-slot="tool-fallback-approval"
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval flex flex-col gap-2 pt-1",
className,
)}
{...props}
ref={setPendingGroup}
>
{promptText}
{answerField}
{!acceptsText && dismissButton && (
<div className="flex items-center gap-2">{dismissButton}</div>
)}
{errorText}
</div>
);
}
return (
<div
data-slot="tool-fallback-approval"
tabIndex={-1}
className={cn(
"aui-tool-fallback-approval flex flex-col gap-2 pt-1",
className,
)}
{...props}
ref={setPendingGroup}
>
{promptText}
<div className="flex items-center gap-2">
<Button
size="sm"
className={pressable}
onClick={() => respond(true)}
disabled={locked}
>
Allow
</Button>
<Button
size="sm"
variant="outline"
className={pressable}
onClick={() => respond(false)}
disabled={locked}
>
Deny
</Button>
</div>
{answerField}
{errorText}
</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 shouldRenderApproval =
approval != null ||
(isRequiresAction && offersInterruptAction(status, approval, interrupt));
const [open, setOpen] = useState(isRequiresAction);
const [prevRequiresAction, setPrevRequiresAction] =
useState(isRequiresAction);
if (isRequiresAction !== prevRequiresAction) {
setPrevRequiresAction(isRequiresAction);
if (isRequiresAction) setOpen(true);
}
const approvalHadFocusRef = useRef(false);
const approvalElementRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useLayoutEffect(() => {
const focusedElement = approvalElementRef.current;
if (
!focusedElement ||
focusedElement.isConnected ||
isRequiresAction ||
!approvalHadFocusRef.current
) {
return;
}
const shouldFocusTrigger = approval == null || !isSettled(approval);
approvalHadFocusRef.current = false;
const { activeElement, body } = focusedElement.ownerDocument;
if (
shouldFocusTrigger &&
(activeElement == null || activeElement === body)
) {
triggerRef.current?.focus({ preventScroll: true });
}
}, [approval, isRequiresAction, status?.type]);
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>
<ToolFallbackError status={status} />
<ToolFallbackArgs
argsText={argsText}
className={cn(isCancelled && "opacity-60")}
/>
{shouldRenderApproval && (
<ToolFallbackApproval
addResult={addResult}
resume={resume}
interrupt={interrupt}
approval={approval}
respondToApproval={respondToApproval}
status={status}
onFocusCapture={(event) => {
const root = event.currentTarget.closest(
'[data-slot="tool-fallback-root"]',
);
triggerRef.current =
root?.querySelector<HTMLButtonElement>(
'[data-slot="tool-fallback-trigger"]',
) ?? null;
approvalElementRef.current = event.currentTarget;
approvalHadFocusRef.current = true;
}}
onBlurCapture={(event) => {
const relatedTarget = event.relatedTarget;
const stillFocused = relatedTarget
? event.currentTarget.contains(relatedTarget as Node)
: event.currentTarget.contains(
event.currentTarget.ownerDocument.activeElement,
);
if (!stillFocused) approvalHadFocusRef.current = false;
}}
/>
)}
<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 {
formatUnknownValue,
offersInterruptAction,
ToolFallback,
ToolFallbackRoot,
ToolFallbackTrigger,
ToolFallbackContent,
ToolFallbackArgs,
ToolFallbackResult,
ToolFallbackError,
ToolFallbackApproval,
};
components/assistant-ui/elements/tool-group.aui.tsx
"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 { cn } from "@/lib/utils";
import {
CollapsibleRoot as Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible-root";
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 origin-left items-center gap-2 text-sm transition-[color,scale] active:scale-[0.98]",
"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.5",
"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-3 shrink-0 animate-spin [animation-duration:0.6s]"
/>
)}
<span
data-slot="tool-group-trigger-label"
className={cn(
"aui-tool-group-trigger-label-wrapper inline-block text-start text-xs 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",
active && "shimmer motion-reduce:animate-none",
)}
>
{label}
</span>
<ChevronDownIcon
data-slot="tool-group-trigger-chevron"
className={cn(
"aui-tool-group-trigger-chevron size-3 shrink-0",
"transition-transform duration-(--animation-duration) ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
"-rotate-90",
"group-data-open/trigger:rotate-0",
"group-data-panel-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-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
"data-closed:animate-collapsible-up",
"data-open:animate-collapsible-down",
"data-closed:fill-mode-forwards",
"data-closed:pointer-events-none",
"[--tw-duration:var(--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",
"[&>*]:animate-in [&>*]:fade-in-0 [&>*]:blur-in-[2px] [&>*]:slide-in-from-top-1 [&>*]:animation-duration-(--animation-duration) [&>*]:ease-[cubic-bezier(0.32,0.72,0,1)]",
"[&>*]:motion-reduce:animate-none",
"[&>*:nth-child(2)]:[animation-delay:40ms]",
"[&>*:nth-child(3)]:[animation-delay:80ms]",
"[&>*:nth-child(4)]:[animation-delay:120ms]",
"[&>*:nth-child(n+5)]:[animation-delay:160ms]",
)}
>
{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,
};
components/eve-ask-question.tsx
"use client";
import {
defineToolkit,
type ToolCallMessagePartComponent,
} from "@assistant-ui/react";
import {
ToolFallbackApproval,
ToolFallbackResult,
} from "@/components/assistant-ui/elements/tool-fallback.aui";
type AskQuestionArgs = {
question?: string;
options?: { label: string; description?: string }[];
};
type AskQuestionOutcome =
| { answer: string }
| { note: string }
| { raw: unknown };
const readOutcome = (
result: unknown,
isError: boolean | undefined,
): AskQuestionOutcome | undefined => {
if (result === undefined) return undefined;
if (!isError && typeof result === "object" && result !== null) {
if ("interrupted" in result && result.interrupted === true)
return { note: "Skipped" };
if ("status" in result && result.status === "unavailable")
return { note: "Unavailable in this session" };
if (
"status" in result &&
result.status === "answered" &&
"answer" in result &&
typeof result.answer === "string"
)
return { answer: result.answer };
}
if (
isError &&
typeof result === "object" &&
result !== null &&
"error" in result &&
typeof result.error === "string"
)
return { note: `Error: ${result.error}` };
return { raw: result };
};
const EveAskQuestion: ToolCallMessagePartComponent<AskQuestionArgs> = ({
args,
approval,
result,
isError,
status,
respondToApproval,
}) => {
const outcome = readOutcome(result, isError);
const settled =
approval != null &&
(approval.approved !== undefined || approval.resolution !== undefined);
if (
approval != null &&
(outcome === undefined ||
(settled &&
("answer" in outcome || (isError && approval.approved === false))))
) {
return (
<ToolFallbackApproval
className="aui-eve-ask-question my-2"
approval={
outcome && "answer" in outcome && approval.text === undefined
? { ...approval, text: outcome.answer }
: approval
}
respondToApproval={respondToApproval}
status={status}
/>
);
}
if (outcome === undefined) return null;
const prompt = approval?.prompt ?? args.question;
return (
<div className="aui-eve-ask-question my-2 flex flex-col gap-1.5">
{prompt && (
<p className="text-muted-foreground whitespace-pre-line">{prompt}</p>
)}
{"raw" in outcome ? (
<ToolFallbackResult result={outcome.raw} />
) : (
<p className="whitespace-pre-line">
{"answer" in outcome ? outcome.answer : outcome.note}
</p>
)}
</div>
);
};
export const eveAskQuestionToolkit = defineToolkit({
ask_question: {
type: "backend",
display: "standalone",
render: EveAskQuestion,
},
});
components/ui/avatar.tsx
"use client";
import type * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className,
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
};
components/ui/button.tsx
import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center gap-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white",
outline:
"bg-muted/70 text-foreground hover:bg-muted dark:bg-muted/50 dark:hover:bg-muted border-transparent",
secondary:
"bg-secondary text-secondary-foreground 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",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-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",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
components/ui/dialog.tsx
"use client";
import type * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
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 data-[state=closed]:animate-out data-[state=open]:animate-in fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
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 bg-popover text-popover-foreground ring-foreground/10 data-[state=closed]:animate-out data-[state=open]:animate-in fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl p-5 text-sm ring-1 duration-100 outline-none sm:max-w-sm rtl:-translate-x-[-50%]",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-1 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<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", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"cn-font-heading text-base leading-none font-medium",
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,
};
components/ui/skeleton.tsx
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
components/ui/textarea.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 bg-muted/60 focus-visible:bg-background field-sizing-content min-h-16 w-full min-w-0 rounded-lg border border-transparent px-3 py-2 text-base transition-colors outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
components/ui/tooltip.tsx
"use client";
import type * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
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 <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
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 animate-in bg-foreground text-background data-[state=closed]:animate-out z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-[var(--radius-surface,var(--radius-lg))] px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow
data-slot="tooltip-arrow"
className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]"
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };