diff --git a/components/ai-elements/actions.tsx b/components/ai-elements/actions.tsx
new file mode 100644
index 0000000..273585c
--- /dev/null
+++ b/components/ai-elements/actions.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ComponentProps } from "react";
+
+export type ActionsProps = ComponentProps<"div">;
+
+export const Actions = ({ className, children, ...props }: ActionsProps) => (
+
+ {children}
+
+);
+
+export type ActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+};
+
+export const Action = ({
+ tooltip,
+ children,
+ label,
+ className,
+ variant = "ghost",
+ size = "sm",
+ ...props
+}: ActionProps) => {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
diff --git a/components/ai-elements/artifact.tsx b/components/ai-elements/artifact.tsx
new file mode 100644
index 0000000..c90cb5f
--- /dev/null
+++ b/components/ai-elements/artifact.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { type LucideIcon, XIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes } from "react";
+
+export type ArtifactProps = HTMLAttributes;
+
+export const Artifact = ({ className, ...props }: ArtifactProps) => (
+
+);
+
+export type ArtifactHeaderProps = HTMLAttributes;
+
+export const ArtifactHeader = ({
+ className,
+ ...props
+}: ArtifactHeaderProps) => (
+
+);
+
+export type ArtifactCloseProps = ComponentProps;
+
+export const ArtifactClose = ({
+ className,
+ children,
+ size = "sm",
+ variant = "ghost",
+ ...props
+}: ArtifactCloseProps) => (
+
+);
+
+export type ArtifactTitleProps = HTMLAttributes;
+
+export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
+
+);
+
+export type ArtifactDescriptionProps = HTMLAttributes;
+
+export const ArtifactDescription = ({
+ className,
+ ...props
+}: ArtifactDescriptionProps) => (
+
+);
+
+export type ArtifactActionsProps = HTMLAttributes;
+
+export const ArtifactActions = ({
+ className,
+ ...props
+}: ArtifactActionsProps) => (
+
+);
+
+export type ArtifactActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+ icon?: LucideIcon;
+};
+
+export const ArtifactAction = ({
+ tooltip,
+ label,
+ icon: Icon,
+ children,
+ className,
+ size = "sm",
+ variant = "ghost",
+ ...props
+}: ArtifactActionProps) => {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
+
+export type ArtifactContentProps = HTMLAttributes;
+
+export const ArtifactContent = ({
+ className,
+ ...props
+}: ArtifactContentProps) => (
+
+);
diff --git a/components/ai-elements/branch.tsx b/components/ai-elements/branch.tsx
new file mode 100644
index 0000000..bf0cda9
--- /dev/null
+++ b/components/ai-elements/branch.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { UIMessage } from "ai";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
+
+type BranchContextType = {
+ currentBranch: number;
+ totalBranches: number;
+ goToPrevious: () => void;
+ goToNext: () => void;
+ branches: ReactElement[];
+ setBranches: (branches: ReactElement[]) => void;
+};
+
+const BranchContext = createContext(null);
+
+const useBranch = () => {
+ const context = useContext(BranchContext);
+
+ if (!context) {
+ throw new Error("Branch components must be used within Branch");
+ }
+
+ return context;
+};
+
+export type BranchProps = HTMLAttributes & {
+ defaultBranch?: number;
+ onBranchChange?: (branchIndex: number) => void;
+};
+
+export const Branch = ({
+ defaultBranch = 0,
+ onBranchChange,
+ className,
+ ...props
+}: BranchProps) => {
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+ const [branches, setBranches] = useState([]);
+
+ const handleBranchChange = (newBranch: number) => {
+ setCurrentBranch(newBranch);
+ onBranchChange?.(newBranch);
+ };
+
+ const goToPrevious = () => {
+ const newBranch =
+ currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
+ handleBranchChange(newBranch);
+ };
+
+ const goToNext = () => {
+ const newBranch =
+ currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
+ handleBranchChange(newBranch);
+ };
+
+ const contextValue: BranchContextType = {
+ currentBranch,
+ totalBranches: branches.length,
+ goToPrevious,
+ goToNext,
+ branches,
+ setBranches,
+ };
+
+ return (
+
+ div]:pb-0", className)}
+ {...props}
+ />
+
+ );
+};
+
+export type BranchMessagesProps = HTMLAttributes
;
+
+export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
+ const { currentBranch, setBranches, branches } = useBranch();
+ const childrenArray = Array.isArray(children) ? children : [children];
+
+ // Use useEffect to update branches when they change
+ useEffect(() => {
+ if (branches.length !== childrenArray.length) {
+ setBranches(childrenArray);
+ }
+ }, [childrenArray, branches, setBranches]);
+
+ return childrenArray.map((branch, index) => (
+ div]:pb-0",
+ index === currentBranch ? "block" : "hidden"
+ )}
+ key={branch.key}
+ {...props}
+ >
+ {branch}
+
+ ));
+};
+
+export type BranchSelectorProps = HTMLAttributes & {
+ from: UIMessage["role"];
+};
+
+export const BranchSelector = ({
+ className,
+ from,
+ ...props
+}: BranchSelectorProps) => {
+ const { totalBranches } = useBranch();
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export type BranchPreviousProps = ComponentProps;
+
+export const BranchPrevious = ({
+ className,
+ children,
+ ...props
+}: BranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useBranch();
+
+ return (
+
+ );
+};
+
+export type BranchNextProps = ComponentProps;
+
+export const BranchNext = ({
+ className,
+ children,
+ ...props
+}: BranchNextProps) => {
+ const { goToNext, totalBranches } = useBranch();
+
+ return (
+
+ );
+};
+
+export type BranchPageProps = HTMLAttributes;
+
+export const BranchPage = ({ className, ...props }: BranchPageProps) => {
+ const { currentBranch, totalBranches } = useBranch();
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ );
+};
diff --git a/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx
new file mode 100644
index 0000000..e0466ae
--- /dev/null
+++ b/components/ai-elements/canvas.tsx
@@ -0,0 +1,24 @@
+import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
+import type { ReactNode } from "react";
+import "@xyflow/react/dist/style.css";
+import { Controls } from "./controls";
+
+type CanvasProps = ReactFlowProps & {
+ children?: ReactNode;
+};
+
+export const Canvas = ({ children, ...props }: CanvasProps) => (
+
+
+
+ {children}
+
+);
diff --git a/components/ai-elements/chain-of-thought.tsx b/components/ai-elements/chain-of-thought.tsx
new file mode 100644
index 0000000..c6bb179
--- /dev/null
+++ b/components/ai-elements/chain-of-thought.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import {
+ BrainIcon,
+ ChevronDownIcon,
+ DotIcon,
+ type LucideIcon,
+} from "lucide-react";
+import type { ComponentProps } from "react";
+import { createContext, memo, useContext, useMemo } from "react";
+
+type ChainOfThoughtContextValue = {
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+};
+
+const ChainOfThoughtContext = createContext(
+ null
+);
+
+const useChainOfThought = () => {
+ const context = useContext(ChainOfThoughtContext);
+ if (!context) {
+ throw new Error(
+ "ChainOfThought components must be used within ChainOfThought"
+ );
+ }
+ return context;
+};
+
+export type ChainOfThoughtProps = ComponentProps<"div"> & {
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+};
+
+export const ChainOfThought = memo(
+ ({
+ className,
+ open,
+ defaultOpen = false,
+ onOpenChange,
+ children,
+ ...props
+ }: ChainOfThoughtProps) => {
+ const [isOpen, setIsOpen] = useControllableState({
+ prop: open,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ const chainOfThoughtContext = useMemo(
+ () => ({ isOpen, setIsOpen }),
+ [isOpen, setIsOpen]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtHeaderProps = ComponentProps<
+ typeof CollapsibleTrigger
+>;
+
+export const ChainOfThoughtHeader = memo(
+ ({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
+ const { isOpen, setIsOpen } = useChainOfThought();
+
+ return (
+
+
+
+
+ {children ?? "Chain of Thought"}
+
+
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
+ icon?: LucideIcon;
+ label: string;
+ description?: string;
+ status?: "complete" | "active" | "pending";
+};
+
+export const ChainOfThoughtStep = memo(
+ ({
+ className,
+ icon: Icon = DotIcon,
+ label,
+ description,
+ status = "complete",
+ children,
+ ...props
+ }: ChainOfThoughtStepProps) => {
+ const statusStyles = {
+ complete: "text-muted-foreground",
+ active: "text-foreground",
+ pending: "text-muted-foreground/50",
+ };
+
+ return (
+
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
+
+export const ChainOfThoughtSearchResults = memo(
+ ({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
+
+ )
+);
+
+export type ChainOfThoughtSearchResultProps = ComponentProps;
+
+export const ChainOfThoughtSearchResult = memo(
+ ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
+
+ {children}
+
+ )
+);
+
+export type ChainOfThoughtContentProps = ComponentProps<
+ typeof CollapsibleContent
+>;
+
+export const ChainOfThoughtContent = memo(
+ ({ className, children, ...props }: ChainOfThoughtContentProps) => {
+ const { isOpen } = useChainOfThought();
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
+ caption?: string;
+};
+
+export const ChainOfThoughtImage = memo(
+ ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
+
+
+ {children}
+
+ {caption &&
{caption}
}
+
+ )
+);
+
+ChainOfThought.displayName = "ChainOfThought";
+ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
+ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
+ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
+ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
+ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
+ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
diff --git a/components/ai-elements/code-block.tsx b/components/ai-elements/code-block.tsx
new file mode 100644
index 0000000..984709b
--- /dev/null
+++ b/components/ai-elements/code-block.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
+import { createContext, useContext, useState } from "react";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import {
+ oneDark,
+ oneLight,
+} from "react-syntax-highlighter/dist/esm/styles/prism";
+
+type CodeBlockContextType = {
+ code: string;
+};
+
+const CodeBlockContext = createContext({
+ code: "",
+});
+
+export type CodeBlockProps = HTMLAttributes & {
+ code: string;
+ language: string;
+ showLineNumbers?: boolean;
+ children?: ReactNode;
+};
+
+export const CodeBlock = ({
+ code,
+ language,
+ showLineNumbers = false,
+ className,
+ children,
+ ...props
+}: CodeBlockProps) => (
+
+
+
+
+ {code}
+
+
+ {code}
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+);
+
+export type CodeBlockCopyButtonProps = ComponentProps & {
+ onCopy?: () => void;
+ onError?: (error: Error) => void;
+ timeout?: number;
+};
+
+export const CodeBlockCopyButton = ({
+ onCopy,
+ onError,
+ timeout = 2000,
+ children,
+ className,
+ ...props
+}: CodeBlockCopyButtonProps) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const { code } = useContext(CodeBlockContext);
+
+ const copyToClipboard = async () => {
+ if (typeof window === "undefined" || !navigator.clipboard.writeText) {
+ onError?.(new Error("Clipboard API not available"));
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(code);
+ setIsCopied(true);
+ onCopy?.();
+ setTimeout(() => setIsCopied(false), timeout);
+ } catch (error) {
+ onError?.(error as Error);
+ }
+ };
+
+ const Icon = isCopied ? CheckIcon : CopyIcon;
+
+ return (
+
+ );
+};
diff --git a/components/ai-elements/connection.tsx b/components/ai-elements/connection.tsx
new file mode 100644
index 0000000..bb73356
--- /dev/null
+++ b/components/ai-elements/connection.tsx
@@ -0,0 +1,28 @@
+import type { ConnectionLineComponent } from "@xyflow/react";
+
+const HALF = 0.5;
+
+export const Connection: ConnectionLineComponent = ({
+ fromX,
+ fromY,
+ toX,
+ toY,
+}) => (
+
+
+
+
+);
diff --git a/components/ai-elements/context.tsx b/components/ai-elements/context.tsx
new file mode 100644
index 0000000..47cd44b
--- /dev/null
+++ b/components/ai-elements/context.tsx
@@ -0,0 +1,406 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Progress } from "@/components/ui/progress";
+import { cn } from "@/lib/utils";
+import type { LanguageModelUsage } from "ai";
+import { type ComponentProps, createContext, useContext } from "react";
+import { estimateCost, type ModelId } from "tokenlens";
+
+const PERCENT_MAX = 100;
+const ICON_RADIUS = 10;
+const ICON_VIEWBOX = 24;
+const ICON_CENTER = 12;
+const ICON_STROKE_WIDTH = 2;
+
+type ContextSchema = {
+ usedTokens: number;
+ maxTokens: number;
+ usage?: LanguageModelUsage;
+ modelId?: ModelId;
+};
+
+const ContextContext = createContext(null);
+
+const useContextValue = () => {
+ const context = useContext(ContextContext);
+
+ if (!context) {
+ throw new Error("Context components must be used within Context");
+ }
+
+ return context;
+};
+
+export type ContextProps = ComponentProps & ContextSchema;
+
+export const Context = ({
+ usedTokens,
+ maxTokens,
+ usage,
+ modelId,
+ ...props
+}: ContextProps) => (
+
+
+
+);
+
+const ContextIcon = () => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const circumference = 2 * Math.PI * ICON_RADIUS;
+ const usedPercent = usedTokens / maxTokens;
+ const dashOffset = circumference * (1 - usedPercent);
+
+ return (
+
+ );
+};
+
+export type ContextTriggerProps = ComponentProps;
+
+export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const renderedPercent = new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 1,
+ }).format(usedPercent);
+
+ return (
+
+ {children ?? (
+
+ )}
+
+ );
+};
+
+export type ContextContentProps = ComponentProps;
+
+export const ContextContent = ({
+ className,
+ ...props
+}: ContextContentProps) => (
+
+);
+
+export type ContextContentHeader = ComponentProps<"div">;
+
+export const ContextContentHeader = ({
+ children,
+ className,
+ ...props
+}: ContextContentHeader) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const displayPct = new Intl.NumberFormat("en-US", {
+ style: "percent",
+ maximumFractionDigits: 1,
+ }).format(usedPercent);
+ const used = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(usedTokens);
+ const total = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(maxTokens);
+
+ return (
+
+ {children ?? (
+ <>
+
+
{displayPct}
+
+ {used} / {total}
+
+
+
+ >
+ )}
+
+ );
+};
+
+export type ContextContentBody = ComponentProps<"div">;
+
+export const ContextContentBody = ({
+ children,
+ className,
+ ...props
+}: ContextContentBody) => (
+
+ {children}
+
+);
+
+export type ContextContentFooter = ComponentProps<"div">;
+
+export const ContextContentFooter = ({
+ children,
+ className,
+ ...props
+}: ContextContentFooter) => {
+ const { modelId, usage } = useContextValue();
+ const costUSD = modelId
+ ? estimateCost({
+ modelId,
+ usage: {
+ input: usage?.inputTokens ?? 0,
+ output: usage?.outputTokens ?? 0,
+ },
+ }).totalUSD
+ : undefined;
+ const totalCost = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(costUSD ?? 0);
+
+ return (
+
+ {children ?? (
+ <>
+ Total cost
+ {totalCost}
+ >
+ )}
+
+ );
+};
+
+export type ContextInputUsageProps = ComponentProps<"div">;
+
+export const ContextInputUsage = ({
+ className,
+ children,
+ ...props
+}: ContextInputUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const inputTokens = usage?.inputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!inputTokens) {
+ return null;
+ }
+
+ const inputCost = modelId
+ ? estimateCost({
+ modelId,
+ usage: { input: inputTokens, output: 0 },
+ }).totalUSD
+ : undefined;
+ const inputCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(inputCost ?? 0);
+
+ return (
+
+ Input
+
+
+ );
+};
+
+export type ContextOutputUsageProps = ComponentProps<"div">;
+
+export const ContextOutputUsage = ({
+ className,
+ children,
+ ...props
+}: ContextOutputUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const outputTokens = usage?.outputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!outputTokens) {
+ return null;
+ }
+
+ const outputCost = modelId
+ ? estimateCost({
+ modelId,
+ usage: { input: 0, output: outputTokens },
+ }).totalUSD
+ : undefined;
+ const outputCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(outputCost ?? 0);
+
+ return (
+
+ Output
+
+
+ );
+};
+
+export type ContextReasoningUsageProps = ComponentProps<"div">;
+
+export const ContextReasoningUsage = ({
+ className,
+ children,
+ ...props
+}: ContextReasoningUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const reasoningTokens = usage?.reasoningTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!reasoningTokens) {
+ return null;
+ }
+
+ const reasoningCost = modelId
+ ? estimateCost({
+ modelId,
+ usage: { reasoningTokens },
+ }).totalUSD
+ : undefined;
+ const reasoningCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(reasoningCost ?? 0);
+
+ return (
+
+ Reasoning
+
+
+ );
+};
+
+export type ContextCacheUsageProps = ComponentProps<"div">;
+
+export const ContextCacheUsage = ({
+ className,
+ children,
+ ...props
+}: ContextCacheUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const cacheTokens = usage?.cachedInputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!cacheTokens) {
+ return null;
+ }
+
+ const cacheCost = modelId
+ ? estimateCost({
+ modelId,
+ usage: { cacheReads: cacheTokens, input: 0, output: 0 },
+ }).totalUSD
+ : undefined;
+ const cacheCostText = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(cacheCost ?? 0);
+
+ return (
+
+ Cache
+
+
+ );
+};
+
+const TokensWithCost = ({
+ tokens,
+ costText,
+}: {
+ tokens?: number;
+ costText?: string;
+}) => (
+
+ {tokens === undefined
+ ? "—"
+ : new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(tokens)}
+ {costText ? (
+ • {costText}
+ ) : null}
+
+);
diff --git a/components/ai-elements/controls.tsx b/components/ai-elements/controls.tsx
new file mode 100644
index 0000000..770a826
--- /dev/null
+++ b/components/ai-elements/controls.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { Controls as ControlsPrimitive } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+export type ControlsProps = ComponentProps;
+
+export const Controls = ({ className, ...props }: ControlsProps) => (
+ button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
+ className
+ )}
+ {...props}
+ />
+);
diff --git a/components/ai-elements/conversation.tsx b/components/ai-elements/conversation.tsx
new file mode 100644
index 0000000..a007df7
--- /dev/null
+++ b/components/ai-elements/conversation.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { ArrowDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+import { useCallback } from "react";
+import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
+
+export type ConversationProps = ComponentProps;
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+
+);
+
+export type ConversationContentProps = ComponentProps<
+ typeof StickToBottom.Content
+>;
+
+export const ConversationContent = ({
+ className,
+ ...props
+}: ConversationContentProps) => (
+
+);
+
+export type ConversationEmptyStateProps = ComponentProps<"div"> & {
+ title?: string;
+ description?: string;
+ icon?: React.ReactNode;
+};
+
+export const ConversationEmptyState = ({
+ className,
+ title = "No messages yet",
+ description = "Start a conversation to see messages here",
+ icon,
+ children,
+ ...props
+}: ConversationEmptyStateProps) => (
+
+ {children ?? (
+ <>
+ {icon &&
{icon}
}
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ >
+ )}
+
+);
+
+export type ConversationScrollButtonProps = ComponentProps;
+
+export const ConversationScrollButton = ({
+ className,
+ ...props
+}: ConversationScrollButtonProps) => {
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
+
+ const handleScrollToBottom = useCallback(() => {
+ scrollToBottom();
+ }, [scrollToBottom]);
+
+ return (
+ !isAtBottom && (
+
+ )
+ );
+};
diff --git a/components/ai-elements/edge.tsx b/components/ai-elements/edge.tsx
new file mode 100644
index 0000000..a4660cc
--- /dev/null
+++ b/components/ai-elements/edge.tsx
@@ -0,0 +1,140 @@
+import {
+ BaseEdge,
+ type EdgeProps,
+ getBezierPath,
+ getSimpleBezierPath,
+ type InternalNode,
+ type Node,
+ Position,
+ useInternalNode,
+} from "@xyflow/react";
+
+const Temporary = ({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+}: EdgeProps) => {
+ const [edgePath] = getSimpleBezierPath({
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ });
+
+ return (
+
+ );
+};
+
+const getHandleCoordsByPosition = (
+ node: InternalNode,
+ handlePosition: Position
+) => {
+ // Choose the handle type based on position - Left is for target, Right is for source
+ const handleType = handlePosition === Position.Left ? "target" : "source";
+
+ const handle = node.internals.handleBounds?.[handleType]?.find(
+ (h) => h.position === handlePosition
+ );
+
+ if (!handle) {
+ return [0, 0];
+ }
+
+ let offsetX = handle.width / 2;
+ let offsetY = handle.height / 2;
+
+ // this is a tiny detail to make the markerEnd of an edge visible.
+ // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
+ // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
+ switch (handlePosition) {
+ case Position.Left:
+ offsetX = 0;
+ break;
+ case Position.Right:
+ offsetX = handle.width;
+ break;
+ case Position.Top:
+ offsetY = 0;
+ break;
+ case Position.Bottom:
+ offsetY = handle.height;
+ break;
+ default:
+ throw new Error(`Invalid handle position: ${handlePosition}`);
+ }
+
+ const x = node.internals.positionAbsolute.x + handle.x + offsetX;
+ const y = node.internals.positionAbsolute.y + handle.y + offsetY;
+
+ return [x, y];
+};
+
+const getEdgeParams = (
+ source: InternalNode,
+ target: InternalNode
+) => {
+ const sourcePos = Position.Right;
+ const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
+ const targetPos = Position.Left;
+ const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
+
+ return {
+ sx,
+ sy,
+ tx,
+ ty,
+ sourcePos,
+ targetPos,
+ };
+};
+
+const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
+ const sourceNode = useInternalNode(source);
+ const targetNode = useInternalNode(target);
+
+ if (!(sourceNode && targetNode)) {
+ return null;
+ }
+
+ const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
+ sourceNode,
+ targetNode
+ );
+
+ const [edgePath] = getBezierPath({
+ sourceX: sx,
+ sourceY: sy,
+ sourcePosition: sourcePos,
+ targetX: tx,
+ targetY: ty,
+ targetPosition: targetPos,
+ });
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export const Edge = {
+ Temporary,
+ Animated,
+};
diff --git a/components/ai-elements/image.tsx b/components/ai-elements/image.tsx
new file mode 100644
index 0000000..542812a
--- /dev/null
+++ b/components/ai-elements/image.tsx
@@ -0,0 +1,24 @@
+import { cn } from "@/lib/utils";
+import type { Experimental_GeneratedImage } from "ai";
+
+export type ImageProps = Experimental_GeneratedImage & {
+ className?: string;
+ alt?: string;
+};
+
+export const Image = ({
+ base64,
+ uint8Array,
+ mediaType,
+ ...props
+}: ImageProps) => (
+
+);
diff --git a/components/ai-elements/inline-citation.tsx b/components/ai-elements/inline-citation.tsx
new file mode 100644
index 0000000..2bf6231
--- /dev/null
+++ b/components/ai-elements/inline-citation.tsx
@@ -0,0 +1,287 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Carousel,
+ type CarouselApi,
+ CarouselContent,
+ CarouselItem,
+} from "@/components/ui/carousel";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { cn } from "@/lib/utils";
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import {
+ type ComponentProps,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+
+export type InlineCitationProps = ComponentProps<"span">;
+
+export const InlineCitation = ({
+ className,
+ ...props
+}: InlineCitationProps) => (
+
+);
+
+export type InlineCitationTextProps = ComponentProps<"span">;
+
+export const InlineCitationText = ({
+ className,
+ ...props
+}: InlineCitationTextProps) => (
+
+);
+
+export type InlineCitationCardProps = ComponentProps;
+
+export const InlineCitationCard = (props: InlineCitationCardProps) => (
+
+);
+
+export type InlineCitationCardTriggerProps = ComponentProps & {
+ sources: string[];
+};
+
+export const InlineCitationCardTrigger = ({
+ sources,
+ className,
+ ...props
+}: InlineCitationCardTriggerProps) => (
+
+
+ {sources.length ? (
+ <>
+ {new URL(sources[0]).hostname}{" "}
+ {sources.length > 1 && `+${sources.length - 1}`}
+ >
+ ) : (
+ "unknown"
+ )}
+
+
+);
+
+export type InlineCitationCardBodyProps = ComponentProps<"div">;
+
+export const InlineCitationCardBody = ({
+ className,
+ ...props
+}: InlineCitationCardBodyProps) => (
+
+);
+
+const CarouselApiContext = createContext(undefined);
+
+const useCarouselApi = () => {
+ const context = useContext(CarouselApiContext);
+ return context;
+};
+
+export type InlineCitationCarouselProps = ComponentProps;
+
+export const InlineCitationCarousel = ({
+ className,
+ children,
+ ...props
+}: InlineCitationCarouselProps) => {
+ const [api, setApi] = useState();
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type InlineCitationCarouselContentProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselContent = (
+ props: InlineCitationCarouselContentProps
+) => ;
+
+export type InlineCitationCarouselItemProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselItem = ({
+ className,
+ ...props
+}: InlineCitationCarouselItemProps) => (
+
+);
+
+export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselHeader = ({
+ className,
+ ...props
+}: InlineCitationCarouselHeaderProps) => (
+
+);
+
+export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
+
+export const InlineCitationCarouselIndex = ({
+ children,
+ className,
+ ...props
+}: InlineCitationCarouselIndexProps) => {
+ const api = useCarouselApi();
+ const [current, setCurrent] = useState(0);
+ const [count, setCount] = useState(0);
+
+ useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ setCount(api.scrollSnapList().length);
+ setCurrent(api.selectedScrollSnap() + 1);
+
+ api.on("select", () => {
+ setCurrent(api.selectedScrollSnap() + 1);
+ });
+ }, [api]);
+
+ return (
+
+ {children ?? `${current}/${count}`}
+
+ );
+};
+
+export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
+
+export const InlineCitationCarouselPrev = ({
+ className,
+ ...props
+}: InlineCitationCarouselPrevProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollPrev();
+ }
+ }, [api]);
+
+ return (
+
+ );
+};
+
+export type InlineCitationCarouselNextProps = ComponentProps<"button">;
+
+export const InlineCitationCarouselNext = ({
+ className,
+ ...props
+}: InlineCitationCarouselNextProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollNext();
+ }
+ }, [api]);
+
+ return (
+
+ );
+};
+
+export type InlineCitationSourceProps = ComponentProps<"div"> & {
+ title?: string;
+ url?: string;
+ description?: string;
+};
+
+export const InlineCitationSource = ({
+ title,
+ url,
+ description,
+ className,
+ children,
+ ...props
+}: InlineCitationSourceProps) => (
+
+ {title && (
+
{title}
+ )}
+ {url && (
+
{url}
+ )}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+);
+
+export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
+
+export const InlineCitationQuote = ({
+ children,
+ className,
+ ...props
+}: InlineCitationQuoteProps) => (
+
+ {children}
+
+);
diff --git a/components/ai-elements/loader.tsx b/components/ai-elements/loader.tsx
new file mode 100644
index 0000000..5f0cfce
--- /dev/null
+++ b/components/ai-elements/loader.tsx
@@ -0,0 +1,96 @@
+import { cn } from "@/lib/utils";
+import type { HTMLAttributes } from "react";
+
+type LoaderIconProps = {
+ size?: number;
+};
+
+const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
+
+);
+
+export type LoaderProps = HTMLAttributes & {
+ size?: number;
+};
+
+export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
+
+
+
+);
diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx
new file mode 100644
index 0000000..e4c0ed8
--- /dev/null
+++ b/components/ai-elements/message.tsx
@@ -0,0 +1,80 @@
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+import type { UIMessage } from "ai";
+import { cva, type VariantProps } from "class-variance-authority";
+import type { ComponentProps, HTMLAttributes } from "react";
+
+export type MessageProps = HTMLAttributes & {
+ from: UIMessage["role"];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+
+);
+
+const messageContentVariants = cva(
+ "is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm",
+ {
+ variants: {
+ variant: {
+ contained: [
+ "max-w-[80%] px-4 py-3",
+ "group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground",
+ "group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
+ ],
+ flat: [
+ "group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
+ "group-[.is-assistant]:text-foreground",
+ ],
+ },
+ },
+ defaultVariants: {
+ variant: "contained",
+ },
+ }
+);
+
+export type MessageContentProps = HTMLAttributes &
+ VariantProps;
+
+export const MessageContent = ({
+ children,
+ className,
+ variant,
+ ...props
+}: MessageContentProps) => (
+
+ {children}
+
+);
+
+export type MessageAvatarProps = ComponentProps & {
+ src: string;
+ name?: string;
+};
+
+export const MessageAvatar = ({
+ src,
+ name,
+ className,
+ ...props
+}: MessageAvatarProps) => (
+
+
+ {name?.slice(0, 2) || "ME"}
+
+);
diff --git a/components/ai-elements/node.tsx b/components/ai-elements/node.tsx
new file mode 100644
index 0000000..75ac59a
--- /dev/null
+++ b/components/ai-elements/node.tsx
@@ -0,0 +1,71 @@
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import { Handle, Position } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+export type NodeProps = ComponentProps & {
+ handles: {
+ target: boolean;
+ source: boolean;
+ };
+};
+
+export const Node = ({ handles, className, ...props }: NodeProps) => (
+
+ {handles.target && }
+ {handles.source && }
+ {props.children}
+
+);
+
+export type NodeHeaderProps = ComponentProps;
+
+export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
+
+);
+
+export type NodeTitleProps = ComponentProps;
+
+export const NodeTitle = (props: NodeTitleProps) => ;
+
+export type NodeDescriptionProps = ComponentProps;
+
+export const NodeDescription = (props: NodeDescriptionProps) => (
+
+);
+
+export type NodeActionProps = ComponentProps;
+
+export const NodeAction = (props: NodeActionProps) => ;
+
+export type NodeContentProps = ComponentProps;
+
+export const NodeContent = ({ className, ...props }: NodeContentProps) => (
+
+);
+
+export type NodeFooterProps = ComponentProps;
+
+export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
+
+);
diff --git a/components/ai-elements/open-in-chat.tsx b/components/ai-elements/open-in-chat.tsx
new file mode 100644
index 0000000..289275d
--- /dev/null
+++ b/components/ai-elements/open-in-chat.tsx
@@ -0,0 +1,363 @@
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import {
+ ChevronDownIcon,
+ ExternalLinkIcon,
+ MessageCircleIcon,
+} from "lucide-react";
+import { type ComponentProps, createContext, useContext } from "react";
+
+const providers = {
+ github: {
+ title: "Open in GitHub",
+ createUrl: (url: string) => url,
+ icon: (
+
+ ),
+ },
+ scira: {
+ title: "Open in Scira",
+ createUrl: (q: string) =>
+ `https://scira.ai/?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ chatgpt: {
+ title: "Open in ChatGPT",
+ createUrl: (prompt: string) =>
+ `https://chatgpt.com/?${new URLSearchParams({
+ hints: "search",
+ prompt,
+ })}`,
+ icon: (
+
+ ),
+ },
+ claude: {
+ title: "Open in Claude",
+ createUrl: (q: string) =>
+ `https://claude.ai/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ t3: {
+ title: "Open in T3 Chat",
+ createUrl: (q: string) =>
+ `https://t3.chat/new?${new URLSearchParams({
+ q,
+ })}`,
+ icon: ,
+ },
+ v0: {
+ title: "Open in v0",
+ createUrl: (q: string) =>
+ `https://v0.app?${new URLSearchParams({
+ q,
+ })}`,
+ icon: (
+
+ ),
+ },
+ cursor: {
+ title: "Open in Cursor",
+ createUrl: (text: string) => {
+ const url = new URL("https://cursor.com/link/prompt");
+ url.searchParams.set("text", text);
+ return url.toString();
+ },
+ icon: (
+
+ ),
+ },
+};
+
+const OpenInContext = createContext<{ query: string } | undefined>(undefined);
+
+const useOpenInContext = () => {
+ const context = useContext(OpenInContext);
+ if (!context) {
+ throw new Error("OpenIn components must be used within an OpenIn provider");
+ }
+ return context;
+};
+
+export type OpenInProps = ComponentProps & {
+ query: string;
+};
+
+export const OpenIn = ({ query, ...props }: OpenInProps) => (
+
+
+
+);
+
+export type OpenInContentProps = ComponentProps;
+
+export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
+
+);
+
+export type OpenInItemProps = ComponentProps;
+
+export const OpenInItem = (props: OpenInItemProps) => (
+
+);
+
+export type OpenInLabelProps = ComponentProps;
+
+export const OpenInLabel = (props: OpenInLabelProps) => (
+
+);
+
+export type OpenInSeparatorProps = ComponentProps;
+
+export const OpenInSeparator = (props: OpenInSeparatorProps) => (
+
+);
+
+export type OpenInTriggerProps = ComponentProps;
+
+export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
+
+ {children ?? (
+
+ )}
+
+);
+
+export type OpenInChatGPTProps = ComponentProps;
+
+export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.chatgpt.icon}
+ {providers.chatgpt.title}
+
+
+
+ );
+};
+
+export type OpenInClaudeProps = ComponentProps;
+
+export const OpenInClaude = (props: OpenInClaudeProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.claude.icon}
+ {providers.claude.title}
+
+
+
+ );
+};
+
+export type OpenInT3Props = ComponentProps;
+
+export const OpenInT3 = (props: OpenInT3Props) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.t3.icon}
+ {providers.t3.title}
+
+
+
+ );
+};
+
+export type OpenInSciraProps = ComponentProps;
+
+export const OpenInScira = (props: OpenInSciraProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.scira.icon}
+ {providers.scira.title}
+
+
+
+ );
+};
+
+export type OpenInv0Props = ComponentProps;
+
+export const OpenInv0 = (props: OpenInv0Props) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.v0.icon}
+ {providers.v0.title}
+
+
+
+ );
+};
+
+export type OpenInCursorProps = ComponentProps;
+
+export const OpenInCursor = (props: OpenInCursorProps) => {
+ const { query } = useOpenInContext();
+ return (
+
+
+ {providers.cursor.icon}
+ {providers.cursor.title}
+
+
+
+ );
+};
diff --git a/components/ai-elements/panel.tsx b/components/ai-elements/panel.tsx
new file mode 100644
index 0000000..059cb7a
--- /dev/null
+++ b/components/ai-elements/panel.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+import { Panel as PanelPrimitive } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+type PanelProps = ComponentProps;
+
+export const Panel = ({ className, ...props }: PanelProps) => (
+
+);
diff --git a/components/ai-elements/plan.tsx b/components/ai-elements/plan.tsx
new file mode 100644
index 0000000..be04d88
--- /dev/null
+++ b/components/ai-elements/plan.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { ChevronsUpDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+import { createContext, useContext } from "react";
+import { Shimmer } from "./shimmer";
+
+type PlanContextValue = {
+ isStreaming: boolean;
+};
+
+const PlanContext = createContext(null);
+
+const usePlan = () => {
+ const context = useContext(PlanContext);
+ if (!context) {
+ throw new Error("Plan components must be used within Plan");
+ }
+ return context;
+};
+
+export type PlanProps = ComponentProps & {
+ isStreaming?: boolean;
+};
+
+export const Plan = ({
+ className,
+ isStreaming = false,
+ children,
+ ...props
+}: PlanProps) => (
+
+
+ {children}
+
+
+);
+
+export type PlanHeaderProps = ComponentProps;
+
+export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
+
+);
+
+export type PlanTitleProps = Omit<
+ ComponentProps,
+ "children"
+> & {
+ children: string;
+};
+
+export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ {isStreaming ? {children} : children}
+
+ );
+};
+
+export type PlanDescriptionProps = Omit<
+ ComponentProps,
+ "children"
+> & {
+ children: string;
+};
+
+export const PlanDescription = ({
+ className,
+ children,
+ ...props
+}: PlanDescriptionProps) => {
+ const { isStreaming } = usePlan();
+
+ return (
+
+ {isStreaming ? {children} : children}
+
+ );
+};
+
+export type PlanActionProps = ComponentProps;
+
+export const PlanAction = (props: PlanActionProps) => (
+
+);
+
+export type PlanContentProps = ComponentProps;
+
+export const PlanContent = (props: PlanContentProps) => (
+
+
+
+);
+
+export type PlanFooterProps = ComponentProps<"div">;
+
+export const PlanFooter = (props: PlanFooterProps) => (
+
+);
+
+export type PlanTriggerProps = ComponentProps;
+
+export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
+
+
+
+);
diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx
new file mode 100644
index 0000000..e3f5941
--- /dev/null
+++ b/components/ai-elements/prompt-input.tsx
@@ -0,0 +1,1381 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupTextarea,
+} from "@/components/ui/input-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ChatStatus, FileUIPart } from "ai";
+import {
+ ImageIcon,
+ Loader2Icon,
+ MicIcon,
+ PaperclipIcon,
+ PlusIcon,
+ SendIcon,
+ SquareIcon,
+ XIcon,
+} from "lucide-react";
+import { nanoid } from "nanoid";
+import {
+ type ChangeEvent,
+ type ChangeEventHandler,
+ Children,
+ type ClipboardEventHandler,
+ type ComponentProps,
+ createContext,
+ type FormEvent,
+ type FormEventHandler,
+ Fragment,
+ type HTMLAttributes,
+ type KeyboardEventHandler,
+ type PropsWithChildren,
+ type ReactNode,
+ type RefObject,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export type AttachmentsContext = {
+ files: (FileUIPart & { id: string })[];
+ add: (files: File[] | FileList) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+ openFileDialog: () => void;
+ fileInputRef: RefObject;
+};
+
+export type TextInputContext = {
+ value: string;
+ setInput: (v: string) => void;
+ clear: () => void;
+};
+
+export type PromptInputControllerProps = {
+ textInput: TextInputContext;
+ attachments: AttachmentsContext;
+ /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
+ __registerFileInput: (
+ ref: RefObject,
+ open: () => void
+ ) => void;
+};
+
+const PromptInputController = createContext(
+ null
+);
+const ProviderAttachmentsContext = createContext(
+ null
+);
+
+export const usePromptInputController = () => {
+ const ctx = useContext(PromptInputController);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside to use usePromptInputController()."
+ );
+ }
+ return ctx;
+};
+
+// Optional variants (do NOT throw). Useful for dual-mode components.
+const useOptionalPromptInputController = () =>
+ useContext(PromptInputController);
+
+export const useProviderAttachments = () => {
+ const ctx = useContext(ProviderAttachmentsContext);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside to use useProviderAttachments()."
+ );
+ }
+ return ctx;
+};
+
+const useOptionalProviderAttachments = () =>
+ useContext(ProviderAttachmentsContext);
+
+export type PromptInputProviderProps = PropsWithChildren<{
+ initialInput?: string;
+}>;
+
+/**
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
+ * If you don't use it, PromptInput stays fully self-managed.
+ */
+export function PromptInputProvider({
+ initialInput: initialTextInput = "",
+ children,
+}: PromptInputProviderProps) {
+ // ----- textInput state
+ const [textInput, setTextInput] = useState(initialTextInput);
+ const clearInput = useCallback(() => setTextInput(""), []);
+
+ // ----- attachments state (global when wrapped)
+ const [attachements, setAttachements] = useState<
+ (FileUIPart & { id: string })[]
+ >([]);
+ const fileInputRef = useRef(null);
+ const openRef = useRef<() => void>(() => { });
+
+ const add = useCallback((files: File[] | FileList) => {
+ const incoming = Array.from(files);
+ if (incoming.length === 0) return;
+
+ setAttachements((prev) =>
+ prev.concat(
+ incoming.map((file) => ({
+ id: nanoid(),
+ type: "file" as const,
+ url: URL.createObjectURL(file),
+ mediaType: file.type,
+ filename: file.name,
+ }))
+ )
+ );
+ }, []);
+
+ const remove = useCallback((id: string) => {
+ setAttachements((prev) => {
+ const found = prev.find((f) => f.id === id);
+ if (found?.url) URL.revokeObjectURL(found.url);
+ return prev.filter((f) => f.id !== id);
+ });
+ }, []);
+
+ const clear = useCallback(() => {
+ setAttachements((prev) => {
+ for (const f of prev) if (f.url) URL.revokeObjectURL(f.url);
+ return [];
+ });
+ }, []);
+
+ const openFileDialog = useCallback(() => {
+ openRef.current?.();
+ }, []);
+
+ const attachments = useMemo(
+ () => ({
+ files: attachements,
+ add,
+ remove,
+ clear,
+ openFileDialog,
+ fileInputRef,
+ }),
+ [attachements, add, remove, clear, openFileDialog]
+ );
+
+ const __registerFileInput = useCallback(
+ (ref: RefObject, open: () => void) => {
+ fileInputRef.current = ref.current;
+ openRef.current = open;
+ },
+ []
+ );
+
+ const controller = useMemo(
+ () => ({
+ textInput: {
+ value: textInput,
+ setInput: setTextInput,
+ clear: clearInput,
+ },
+ attachments,
+ __registerFileInput,
+ }),
+ [textInput, clearInput, attachments, __registerFileInput]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext(null);
+
+export const usePromptInputAttachments = () => {
+ // Dual-mode: prefer provider if present, otherwise use local
+ const provider = useOptionalProviderAttachments();
+ const local = useContext(LocalAttachmentsContext);
+ const context = provider ?? local;
+ if (!context) {
+ throw new Error(
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+ );
+ }
+ return context;
+};
+
+export type PromptInputAttachmentProps = HTMLAttributes & {
+ data: FileUIPart & { id: string };
+ className?: string;
+};
+
+export function PromptInputAttachment({
+ data,
+ className,
+ ...props
+}: PromptInputAttachmentProps) {
+ const attachments = usePromptInputAttachments();
+
+ const mediaType =
+ data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
+
+ return (
+
+ {mediaType === "image" ? (
+

+ ) : (
+
+
+
+
+
+ {data.filename || "Unknown file"}
+
+
+
+
+
+ {data.filename || "Unknown file"}
+
+ {data.mediaType &&
{data.mediaType}
}
+
+
+
+
+ )}
+
+
+ );
+}
+
+export type PromptInputAttachmentsProps = Omit<
+ HTMLAttributes,
+ "children"
+> & {
+ children: (attachment: FileUIPart & { id: string }) => ReactNode;
+};
+
+export function PromptInputAttachments({
+ className,
+ children,
+ ...props
+}: PromptInputAttachmentsProps) {
+ const attachments = usePromptInputAttachments();
+ const [height, setHeight] = useState(0);
+ const contentRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const el = contentRef.current;
+ if (!el) {
+ return;
+ }
+ const ro = new ResizeObserver(() => {
+ setHeight(el.getBoundingClientRect().height);
+ });
+ ro.observe(el);
+ setHeight(el.getBoundingClientRect().height);
+ return () => ro.disconnect();
+ }, []);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Force height measurement when attachments change
+ useLayoutEffect(() => {
+ const el = contentRef.current;
+ if (!el) {
+ return;
+ }
+ setHeight(el.getBoundingClientRect().height);
+ }, [attachments.files.length]);
+
+ if (attachments.files.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {attachments.files
+ .filter((f) => !(f.mediaType?.startsWith("image/") && f.url))
+ .map((file) => (
+ {children(file)}
+ ))}
+
+
+ {attachments.files
+ .filter((f) => f.mediaType?.startsWith("image/") && f.url)
+ .map((file) => (
+ {children(file)}
+ ))}
+
+
+
+ );
+}
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+ label = "Add photos or files",
+ ...props
+}: PromptInputActionAddAttachmentsProps) => {
+ const attachments = usePromptInputAttachments();
+
+ return (
+ {
+ e.preventDefault();
+ attachments.openFileDialog();
+ }}
+ >
+ {label}
+
+ );
+};
+
+export type PromptInputMessage = {
+ text?: string;
+ files?: FileUIPart[];
+};
+
+export type PromptInputProps = Omit<
+ HTMLAttributes,
+ "onSubmit" | "onError"
+> & {
+ accept?: string; // e.g., "image/*" or leave undefined for any
+ multiple?: boolean;
+ // When true, accepts drops anywhere on document. Default false (opt-in).
+ globalDrop?: boolean;
+ // Render a hidden input with given name and keep it in sync for native form posts. Default false.
+ syncHiddenInput?: boolean;
+ // Minimal constraints
+ maxFiles?: number;
+ maxFileSize?: number; // bytes
+ onError?: (err: {
+ code: "max_files" | "max_file_size" | "accept";
+ message: string;
+ }) => void;
+ onSubmit: (
+ message: PromptInputMessage,
+ event: FormEvent
+ ) => void | Promise;
+};
+
+export const PromptInput = ({
+ className,
+ accept,
+ multiple,
+ globalDrop,
+ syncHiddenInput,
+ maxFiles,
+ maxFileSize,
+ onError,
+ onSubmit,
+ children,
+ ...props
+}: PromptInputProps) => {
+ // Try to use a provider controller if present
+ const controller = useOptionalPromptInputController();
+ const usingProvider = !!controller;
+
+ // Refs
+ const inputRef = useRef(null);
+ const anchorRef = useRef(null);
+ const formRef = useRef(null);
+
+ // Find nearest form to scope drag & drop
+ useEffect(() => {
+ const root = anchorRef.current?.closest("form");
+ if (root instanceof HTMLFormElement) {
+ formRef.current = root;
+ }
+ }, []);
+
+ // ----- Local attachments (only used when no provider)
+ const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
+ const files = usingProvider ? controller.attachments.files : items;
+
+ const openFileDialogLocal = useCallback(() => {
+ inputRef.current?.click();
+ }, []);
+
+ const matchesAccept = useCallback(
+ (f: File) => {
+ if (!accept || accept.trim() === "") {
+ return true;
+ }
+ if (accept.includes("image/*")) {
+ return f.type.startsWith("image/");
+ }
+ // NOTE: keep simple; expand as needed
+ return true;
+ },
+ [accept]
+ );
+
+ const addLocal = useCallback(
+ (fileList: File[] | FileList) => {
+ const incoming = Array.from(fileList);
+ const accepted = incoming.filter((f) => matchesAccept(f));
+ if (incoming.length && accepted.length === 0) {
+ onError?.({
+ code: "accept",
+ message: "No files match the accepted types.",
+ });
+ return;
+ }
+ const withinSize = (f: File) =>
+ maxFileSize ? f.size <= maxFileSize : true;
+ const sized = accepted.filter(withinSize);
+ if (accepted.length > 0 && sized.length === 0) {
+ onError?.({
+ code: "max_file_size",
+ message: "All files exceed the maximum size.",
+ });
+ return;
+ }
+
+ setItems((prev) => {
+ const capacity =
+ typeof maxFiles === "number"
+ ? Math.max(0, maxFiles - prev.length)
+ : undefined;
+ const capped =
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ if (typeof capacity === "number" && sized.length > capacity) {
+ onError?.({
+ code: "max_files",
+ message: "Too many files. Some were not added.",
+ });
+ }
+ const next: (FileUIPart & { id: string })[] = [];
+ for (const file of capped) {
+ next.push({
+ id: nanoid(),
+ type: "file",
+ url: URL.createObjectURL(file),
+ mediaType: file.type,
+ filename: file.name,
+ });
+ }
+ return prev.concat(next);
+ });
+ },
+ [matchesAccept, maxFiles, maxFileSize, onError]
+ );
+
+ const add = usingProvider
+ ? (files: File[] | FileList) => controller.attachments.add(files)
+ : addLocal;
+
+ const remove = usingProvider
+ ? (id: string) => controller.attachments.remove(id)
+ : (id: string) =>
+ setItems((prev) => {
+ const found = prev.find((file) => file.id === id);
+ if (found?.url) {
+ URL.revokeObjectURL(found.url);
+ }
+ return prev.filter((file) => file.id !== id);
+ });
+
+ const clear = usingProvider
+ ? () => controller.attachments.clear()
+ : () =>
+ setItems((prev) => {
+ for (const file of prev) {
+ if (file.url) {
+ URL.revokeObjectURL(file.url);
+ }
+ }
+ return [];
+ });
+
+ const openFileDialog = usingProvider
+ ? () => controller.attachments.openFileDialog()
+ : openFileDialogLocal;
+
+ // Let provider know about our hidden file input so external menus can call openFileDialog()
+ useEffect(() => {
+ if (!usingProvider) return;
+ controller.__registerFileInput(inputRef, () => inputRef.current?.click());
+ }, [usingProvider, controller]);
+
+ // Note: File input cannot be programmatically set for security reasons
+ // The syncHiddenInput prop is no longer functional
+ useEffect(() => {
+ if (syncHiddenInput && inputRef.current && files.length === 0) {
+ inputRef.current.value = "";
+ }
+ }, [files, syncHiddenInput]);
+
+ // Attach drop handlers on nearest form and document (opt-in)
+ useEffect(() => {
+ const form = formRef.current;
+ if (!form) return;
+
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ add(e.dataTransfer.files);
+ }
+ };
+ form.addEventListener("dragover", onDragOver);
+ form.addEventListener("drop", onDrop);
+ return () => {
+ form.removeEventListener("dragover", onDragOver);
+ form.removeEventListener("drop", onDrop);
+ };
+ }, [add]);
+
+ useEffect(() => {
+ if (!globalDrop) return;
+
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ add(e.dataTransfer.files);
+ }
+ };
+ document.addEventListener("dragover", onDragOver);
+ document.addEventListener("drop", onDrop);
+ return () => {
+ document.removeEventListener("dragover", onDragOver);
+ document.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop]);
+
+ useEffect(
+ () => () => {
+ if (!usingProvider) {
+ for (const f of files) {
+ if (f.url) URL.revokeObjectURL(f.url);
+ }
+ }
+ },
+ [usingProvider, files]
+ );
+
+ const handleChange: ChangeEventHandler = (event) => {
+ if (event.currentTarget.files) {
+ add(event.currentTarget.files);
+ }
+ };
+
+ const convertBlobUrlToDataUrl = async (url: string): Promise => {
+ const response = await fetch(url);
+ const blob = await response.blob();
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ };
+
+ const ctx = useMemo(
+ () => ({
+ files: files.map((item) => ({ ...item, id: item.id })),
+ add,
+ remove,
+ clear,
+ openFileDialog,
+ fileInputRef: inputRef,
+ }),
+ [files, add, remove, clear, openFileDialog]
+ );
+
+ const handleSubmit: FormEventHandler = (event) => {
+ event.preventDefault();
+
+ const form = event.currentTarget;
+ const text = usingProvider
+ ? controller.textInput.value
+ : (() => {
+ const formData = new FormData(form);
+ return (formData.get("message") as string) || "";
+ })();
+
+ // Reset form immediately after capturing text to avoid race condition
+ // where user input during async blob conversion would be lost
+ if (!usingProvider) {
+ form.reset();
+ }
+
+ // Convert blob URLs to data URLs asynchronously
+ Promise.all(
+ files.map(async ({ id, ...item }) => {
+ if (item.url && item.url.startsWith("blob:")) {
+ return {
+ ...item,
+ url: await convertBlobUrlToDataUrl(item.url),
+ };
+ }
+ return item;
+ })
+ ).then((convertedFiles: FileUIPart[]) => {
+ try {
+ const result = onSubmit({ text, files: convertedFiles }, event);
+
+ // Handle both sync and async onSubmit
+ if (result instanceof Promise) {
+ result
+ .then(() => {
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ })
+ .catch(() => {
+ // Don't clear on error - user may want to retry
+ });
+ } else {
+ // Sync function completed without throwing, clear attachments
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ }
+ } catch (error) {
+ // Don't clear on error - user may want to retry
+ }
+ });
+ };
+
+ // Render with or without local provider
+ const inner = (
+ <>
+
+
+
+ >
+ );
+
+ return usingProvider ? (
+ inner
+ ) : (
+
+ {inner}
+
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({
+ className,
+ ...props
+}: PromptInputBodyProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps<
+ typeof InputGroupTextarea
+>;
+
+export const PromptInputTextarea = ({
+ onChange,
+ className,
+ placeholder = "What would you like to know?",
+ ...props
+}: PromptInputTextareaProps) => {
+ const controller = useOptionalPromptInputController();
+ const attachments = usePromptInputAttachments();
+ const [isComposing, setIsComposing] = useState(false);
+
+ const handleKeyDown: KeyboardEventHandler = (e) => {
+ if (e.key === "Enter") {
+ if (isComposing || e.nativeEvent.isComposing) {
+ return;
+ }
+ if (e.shiftKey) {
+ return;
+ }
+ e.preventDefault();
+ e.currentTarget.form?.requestSubmit();
+ }
+
+ // Remove last attachment when Backspace is pressed and textarea is empty
+ if (
+ e.key === "Backspace" &&
+ e.currentTarget.value === "" &&
+ attachments.files.length > 0
+ ) {
+ e.preventDefault();
+ const lastAttachment = attachments.files.at(-1);
+ if (lastAttachment) {
+ attachments.remove(lastAttachment.id);
+ }
+ }
+ };
+
+ const handlePaste: ClipboardEventHandler = (event) => {
+ const items = event.clipboardData?.items;
+
+ if (!items) {
+ return;
+ }
+
+ const files: File[] = [];
+
+ for (const item of items) {
+ if (item.kind === "file") {
+ const file = item.getAsFile();
+ if (file) {
+ files.push(file);
+ }
+ }
+ }
+
+ if (files.length > 0) {
+ event.preventDefault();
+ attachments.add(files);
+ }
+ };
+
+ const controlledProps = controller
+ ? {
+ value: controller.textInput.value,
+ onChange: (e: ChangeEvent) => {
+ controller.textInput.setInput(e.currentTarget.value);
+ onChange?.(e);
+ },
+ }
+ : {
+ onChange,
+ };
+
+ return (
+ setIsComposing(false)}
+ onCompositionStart={() => setIsComposing(true)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ placeholder={placeholder}
+ {...props}
+ {...controlledProps}
+ />
+ );
+};
+
+export type PromptInputHeaderProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputHeader = ({
+ className,
+ ...props
+}: PromptInputHeaderProps) => (
+
+);
+
+export type PromptInputFooterProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputFooter = ({
+ className,
+ ...props
+}: PromptInputFooterProps) => (
+
+);
+
+export type PromptInputToolsProps = HTMLAttributes;
+
+export const PromptInputTools = ({
+ className,
+ ...props
+}: PromptInputToolsProps) => (
+
+);
+
+export type PromptInputButtonProps = ComponentProps;
+
+export const PromptInputButton = ({
+ variant = "ghost",
+ className,
+ size,
+ ...props
+}: PromptInputButtonProps) => {
+ const newSize =
+ size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+
+ return (
+
+ );
+};
+
+export type PromptInputActionMenuProps = ComponentProps;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+ className,
+ children,
+ ...props
+}: PromptInputActionMenuTriggerProps) => (
+
+
+ {children ?? }
+
+
+);
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+ typeof DropdownMenuContent
+>;
+export const PromptInputActionMenuContent = ({
+ className,
+ ...props
+}: PromptInputActionMenuContentProps) => (
+
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+ typeof DropdownMenuItem
+>;
+export const PromptInputActionMenuItem = ({
+ className,
+ ...props
+}: PromptInputActionMenuItemProps) => (
+
+);
+
+// Note: Actions that perform side-effects (like opening a file dialog)
+// are provided in opt-in modules (e.g., prompt-input-attachments).
+
+export type PromptInputSubmitProps = ComponentProps & {
+ status?: ChatStatus;
+};
+
+export const PromptInputSubmit = ({
+ className,
+ variant = "default",
+ size = "icon-sm",
+ status,
+ children,
+ ...props
+}: PromptInputSubmitProps) => {
+ let Icon = ;
+
+ if (status === "submitted") {
+ Icon = ;
+ } else if (status === "streaming") {
+ Icon = ;
+ } else if (status === "error") {
+ Icon = ;
+ }
+
+ return (
+
+ {children ?? Icon}
+
+ );
+};
+
+interface SpeechRecognition extends EventTarget {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ start(): void;
+ stop(): void;
+ onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
+ onend: ((this: SpeechRecognition, ev: Event) => any) | null;
+ onresult:
+ | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
+ | null;
+ onerror:
+ | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
+ | null;
+}
+
+interface SpeechRecognitionEvent extends Event {
+ results: SpeechRecognitionResultList;
+}
+
+type SpeechRecognitionResultList = {
+ readonly length: number;
+ item(index: number): SpeechRecognitionResult;
+ [index: number]: SpeechRecognitionResult;
+};
+
+type SpeechRecognitionResult = {
+ readonly length: number;
+ item(index: number): SpeechRecognitionAlternative;
+ [index: number]: SpeechRecognitionAlternative;
+ isFinal: boolean;
+};
+
+type SpeechRecognitionAlternative = {
+ transcript: string;
+ confidence: number;
+};
+
+interface SpeechRecognitionErrorEvent extends Event {
+ error: string;
+}
+
+declare global {
+ interface Window {
+ SpeechRecognition: {
+ new(): SpeechRecognition;
+ };
+ webkitSpeechRecognition: {
+ new(): SpeechRecognition;
+ };
+ }
+}
+
+export type PromptInputSpeechButtonProps = ComponentProps<
+ typeof PromptInputButton
+> & {
+ textareaRef?: RefObject;
+ onTranscriptionChange?: (text: string) => void;
+};
+
+export const PromptInputSpeechButton = ({
+ className,
+ textareaRef,
+ onTranscriptionChange,
+ ...props
+}: PromptInputSpeechButtonProps) => {
+ const [isListening, setIsListening] = useState(false);
+ const [recognition, setRecognition] = useState(
+ null
+ );
+ const recognitionRef = useRef(null);
+
+ useEffect(() => {
+ if (
+ typeof window !== "undefined" &&
+ ("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
+ ) {
+ const SpeechRecognition =
+ window.SpeechRecognition || window.webkitSpeechRecognition;
+ const speechRecognition = new SpeechRecognition();
+
+ speechRecognition.continuous = true;
+ speechRecognition.interimResults = true;
+ speechRecognition.lang = "en-US";
+
+ speechRecognition.onstart = () => {
+ setIsListening(true);
+ };
+
+ speechRecognition.onend = () => {
+ setIsListening(false);
+ };
+
+ speechRecognition.onresult = (event) => {
+ let finalTranscript = "";
+
+ const results = Array.from(event.results);
+
+ for (const result of results) {
+ if (result.isFinal) {
+ finalTranscript += result[0].transcript;
+ }
+ }
+
+ if (finalTranscript && textareaRef?.current) {
+ const textarea = textareaRef.current;
+ const currentValue = textarea.value;
+ const newValue =
+ currentValue + (currentValue ? " " : "") + finalTranscript;
+
+ textarea.value = newValue;
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ onTranscriptionChange?.(newValue);
+ }
+ };
+
+ speechRecognition.onerror = (event) => {
+ console.error("Speech recognition error:", event.error);
+ setIsListening(false);
+ };
+
+ recognitionRef.current = speechRecognition;
+ setRecognition(speechRecognition);
+ }
+
+ return () => {
+ if (recognitionRef.current) {
+ recognitionRef.current.stop();
+ }
+ };
+ }, [textareaRef, onTranscriptionChange]);
+
+ const toggleListening = useCallback(() => {
+ if (!recognition) {
+ return;
+ }
+
+ if (isListening) {
+ recognition.stop();
+ } else {
+ recognition.start();
+ }
+ }, [recognition, isListening]);
+
+ return (
+
+
+
+ );
+};
+
+export type PromptInputModelSelectProps = ComponentProps;
+
+export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
+
+);
+
+export type PromptInputModelSelectTriggerProps = ComponentProps<
+ typeof SelectTrigger
+>;
+
+export const PromptInputModelSelectTrigger = ({
+ className,
+ ...props
+}: PromptInputModelSelectTriggerProps) => (
+
+);
+
+export type PromptInputModelSelectContentProps = ComponentProps<
+ typeof SelectContent
+>;
+
+export const PromptInputModelSelectContent = ({
+ className,
+ ...props
+}: PromptInputModelSelectContentProps) => (
+
+);
+
+export type PromptInputModelSelectItemProps = ComponentProps;
+
+export const PromptInputModelSelectItem = ({
+ className,
+ ...props
+}: PromptInputModelSelectItemProps) => (
+
+);
+
+export type PromptInputModelSelectValueProps = ComponentProps<
+ typeof SelectValue
+>;
+
+export const PromptInputModelSelectValue = ({
+ className,
+ ...props
+}: PromptInputModelSelectValueProps) => (
+
+);
+
+export type PromptInputHoverCardProps = ComponentProps;
+
+export const PromptInputHoverCard = ({
+ openDelay = 0,
+ closeDelay = 0,
+ ...props
+}: PromptInputHoverCardProps) => (
+
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+ typeof HoverCardTrigger
+>;
+
+export const PromptInputHoverCardTrigger = (
+ props: PromptInputHoverCardTriggerProps
+) => ;
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+ typeof HoverCardContent
+>;
+
+export const PromptInputHoverCardContent = ({
+ align = "start",
+ ...props
+}: PromptInputHoverCardContentProps) => (
+
+);
+
+export type PromptInputTabsListProps = HTMLAttributes;
+
+export const PromptInputTabsList = ({
+ className,
+ ...props
+}: PromptInputTabsListProps) => ;
+
+export type PromptInputTabProps = HTMLAttributes;
+
+export const PromptInputTab = ({
+ className,
+ ...props
+}: PromptInputTabProps) => ;
+
+export type PromptInputTabLabelProps = HTMLAttributes;
+
+export const PromptInputTabLabel = ({
+ className,
+ ...props
+}: PromptInputTabLabelProps) => (
+
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes;
+
+export const PromptInputTabBody = ({
+ className,
+ ...props
+}: PromptInputTabBodyProps) => (
+
+);
+
+export type PromptInputTabItemProps = HTMLAttributes;
+
+export const PromptInputTabItem = ({
+ className,
+ ...props
+}: PromptInputTabItemProps) => (
+
+);
+
+export type PromptInputCommandProps = ComponentProps;
+
+export const PromptInputCommand = ({
+ className,
+ ...props
+}: PromptInputCommandProps) => ;
+
+export type PromptInputCommandInputProps = ComponentProps;
+
+export const PromptInputCommandInput = ({
+ className,
+ ...props
+}: PromptInputCommandInputProps) => (
+
+);
+
+export type PromptInputCommandListProps = ComponentProps;
+
+export const PromptInputCommandList = ({
+ className,
+ ...props
+}: PromptInputCommandListProps) => (
+
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps;
+
+export const PromptInputCommandEmpty = ({
+ className,
+ ...props
+}: PromptInputCommandEmptyProps) => (
+
+);
+
+export type PromptInputCommandGroupProps = ComponentProps;
+
+export const PromptInputCommandGroup = ({
+ className,
+ ...props
+}: PromptInputCommandGroupProps) => (
+
+);
+
+export type PromptInputCommandItemProps = ComponentProps;
+
+export const PromptInputCommandItem = ({
+ className,
+ ...props
+}: PromptInputCommandItemProps) => (
+
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps<
+ typeof CommandSeparator
+>;
+
+export const PromptInputCommandSeparator = ({
+ className,
+ ...props
+}: PromptInputCommandSeparatorProps) => (
+
+);
diff --git a/components/ai-elements/queue.tsx b/components/ai-elements/queue.tsx
new file mode 100644
index 0000000..0c91d13
--- /dev/null
+++ b/components/ai-elements/queue.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type QueueMessagePart = {
+ type: string;
+ text?: string;
+ url?: string;
+ filename?: string;
+ mediaType?: string;
+};
+
+export type QueueMessage = {
+ id: string;
+ parts: QueueMessagePart[];
+};
+
+export type QueueTodo = {
+ id: string;
+ title: string;
+ description?: string;
+ status?: "pending" | "completed";
+};
+
+export type QueueItemProps = ComponentProps<"li">;
+
+export const QueueItem = ({ className, ...props }: QueueItemProps) => (
+
+);
+
+export type QueueItemIndicatorProps = ComponentProps<"span"> & {
+ completed?: boolean;
+};
+
+export const QueueItemIndicator = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemIndicatorProps) => (
+
+);
+
+export type QueueItemContentProps = ComponentProps<"span"> & {
+ completed?: boolean;
+};
+
+export const QueueItemContent = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemContentProps) => (
+
+);
+
+export type QueueItemDescriptionProps = ComponentProps<"div"> & {
+ completed?: boolean;
+};
+
+export const QueueItemDescription = ({
+ completed = false,
+ className,
+ ...props
+}: QueueItemDescriptionProps) => (
+
+);
+
+export type QueueItemActionsProps = ComponentProps<"div">;
+
+export const QueueItemActions = ({
+ className,
+ ...props
+}: QueueItemActionsProps) => (
+
+);
+
+export type QueueItemActionProps = Omit<
+ ComponentProps,
+ "variant" | "size"
+>;
+
+export const QueueItemAction = ({
+ className,
+ ...props
+}: QueueItemActionProps) => (
+
+);
+
+export type QueueItemAttachmentProps = ComponentProps<"div">;
+
+export const QueueItemAttachment = ({
+ className,
+ ...props
+}: QueueItemAttachmentProps) => (
+
+);
+
+export type QueueItemImageProps = ComponentProps<"img">;
+
+export const QueueItemImage = ({
+ className,
+ ...props
+}: QueueItemImageProps) => (
+
+);
+
+export type QueueItemFileProps = ComponentProps<"span">;
+
+export const QueueItemFile = ({
+ children,
+ className,
+ ...props
+}: QueueItemFileProps) => (
+
+
+ {children}
+
+);
+
+export type QueueListProps = ComponentProps;
+
+export const QueueList = ({
+ children,
+ className,
+ ...props
+}: QueueListProps) => (
+
+
+
+);
+
+// QueueSection - collapsible section container
+export type QueueSectionProps = ComponentProps;
+
+export const QueueSection = ({
+ className,
+ defaultOpen = true,
+ ...props
+}: QueueSectionProps) => (
+
+);
+
+// QueueSectionTrigger - section header/trigger
+export type QueueSectionTriggerProps = ComponentProps<"button">;
+
+export const QueueSectionTrigger = ({
+ children,
+ className,
+ ...props
+}: QueueSectionTriggerProps) => (
+
+
+
+);
+
+// QueueSectionLabel - label content with icon and count
+export type QueueSectionLabelProps = ComponentProps<"span"> & {
+ count?: number;
+ label: string;
+ icon?: React.ReactNode;
+};
+
+export const QueueSectionLabel = ({
+ count,
+ label,
+ icon,
+ className,
+ ...props
+}: QueueSectionLabelProps) => (
+
+
+ {icon}
+
+ {count} {label}
+
+
+);
+
+// QueueSectionContent - collapsible content area
+export type QueueSectionContentProps = ComponentProps<
+ typeof CollapsibleContent
+>;
+
+export const QueueSectionContent = ({
+ className,
+ ...props
+}: QueueSectionContentProps) => (
+
+);
+
+export type QueueProps = ComponentProps<"div">;
+
+export const Queue = ({ className, ...props }: QueueProps) => (
+
+);
diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx
new file mode 100644
index 0000000..09bcec5
--- /dev/null
+++ b/components/ai-elements/reasoning.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { BrainIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+import { createContext, memo, useContext, useEffect, useRef, useState } from "react";
+import { Response } from "./response";
+import { Shimmer } from "./shimmer";
+
+type ReasoningContextValue = {
+ isStreaming: boolean;
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+ duration: number;
+};
+
+const ReasoningContext = createContext(null);
+
+const useReasoning = () => {
+ const context = useContext(ReasoningContext);
+ if (!context) {
+ throw new Error("Reasoning components must be used within Reasoning");
+ }
+ return context;
+};
+
+export type ReasoningProps = ComponentProps & {
+ isStreaming?: boolean;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ duration?: number;
+ onReasoningDurationChange?: (duration: number) => void;
+};
+
+const AUTO_CLOSE_DELAY = 1000;
+const MS_IN_S = 1000;
+
+export const Reasoning = memo(
+ ({
+ className,
+ isStreaming = false,
+ open,
+ defaultOpen = true,
+ onOpenChange,
+ duration: durationProp,
+ onReasoningDurationChange,
+ children,
+ ...props
+ }: ReasoningProps) => {
+ const [isOpen, setIsOpen] = useControllableState({
+ prop: open,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+ const [duration, setDuration] = useControllableState({
+ prop: durationProp,
+ defaultProp: 0,
+ });
+
+ const [hasAutoClosed, setHasAutoClosed] = useState(false);
+ const [startTime, setStartTime] = useState(null);
+ const onReasoningDurationChangeRef = useRef(onReasoningDurationChange);
+
+ useEffect(() => {
+ onReasoningDurationChangeRef.current = onReasoningDurationChange;
+ }, [onReasoningDurationChange]);
+
+ // Track duration when streaming starts and ends
+ useEffect(() => {
+ if (isStreaming) {
+ if (startTime === null) {
+ setStartTime(Date.now());
+ }
+ } else if (startTime !== null) {
+ const computedDuration = Math.ceil((Date.now() - startTime) / MS_IN_S);
+ setDuration(computedDuration);
+ onReasoningDurationChangeRef.current?.(computedDuration);
+ setStartTime(null);
+ }
+ }, [isStreaming, startTime, setDuration]);
+
+ // Auto-open when streaming starts, auto-close when streaming ends (once only)
+ useEffect(() => {
+ if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
+ // Add a small delay before closing to allow user to see the content
+ const timer = setTimeout(() => {
+ setIsOpen(false);
+ setHasAutoClosed(true);
+ }, AUTO_CLOSE_DELAY);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
+
+ const handleOpenChange = (newOpen: boolean) => {
+ setIsOpen(newOpen);
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ReasoningTriggerProps = ComponentProps;
+
+const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
+ if (isStreaming || duration === 0) {
+ return Thinking;
+ }
+ if (duration === undefined) {
+ return Thought for a few seconds
;
+ }
+ return Thought for {duration} seconds
;
+};
+
+export const ReasoningTrigger = memo(
+ ({ className, children, ...props }: ReasoningTriggerProps) => {
+ const { isStreaming, isOpen, duration } = useReasoning();
+
+ return (
+
+ {children ?? (
+ <>
+
+ {getThinkingMessage(isStreaming, duration)}
+
+ >
+ )}
+
+ );
+ }
+);
+
+export type ReasoningContentProps = ComponentProps<
+ typeof CollapsibleContent
+> & {
+ children: string;
+};
+
+export const ReasoningContent = memo(
+ ({ className, children, ...props }: ReasoningContentProps) => {
+ const { isStreaming } = useReasoning();
+ const contentRef = useRef(null);
+
+ useEffect(() => {
+ if (contentRef.current) {
+ contentRef.current.scrollTop = contentRef.current.scrollHeight;
+ }
+ }, [children, isStreaming]);
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";
diff --git a/components/ai-elements/response.tsx b/components/ai-elements/response.tsx
new file mode 100644
index 0000000..9614b63
--- /dev/null
+++ b/components/ai-elements/response.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { type ComponentProps, memo } from "react";
+import { Streamdown, defaultRehypePlugins } from "streamdown";
+import { rehypeSingleCharLink } from "@/lib/rehype-single-char-link";
+
+type ResponseProps = ComponentProps;
+
+export const Response = memo(
+ ({ className, ...props }: ResponseProps) => (
+ *:first-child]:mt-0 [&>*:last-child]:mb-0",
+ className
+ )}
+ rehypePlugins={[
+ defaultRehypePlugins.raw,
+ defaultRehypePlugins.katex,
+ rehypeSingleCharLink,
+ ]}
+ {...props}
+ />
+ ),
+ (prevProps, nextProps) => prevProps.children === nextProps.children
+);
+
+Response.displayName = "Response";
diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx
new file mode 100644
index 0000000..9163aac
--- /dev/null
+++ b/components/ai-elements/shimmer.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { motion } from "motion/react";
+import {
+ type CSSProperties,
+ type ElementType,
+ type JSX,
+ memo,
+ useMemo,
+} from "react";
+
+export type TextShimmerProps = {
+ children: string;
+ as?: ElementType;
+ className?: string;
+ duration?: number;
+ spread?: number;
+};
+
+const ShimmerComponent = ({
+ children,
+ as: Component = "p",
+ className,
+ duration = 2,
+ spread = 2,
+}: TextShimmerProps) => {
+ const MotionComponent = motion.create(
+ Component as keyof JSX.IntrinsicElements
+ );
+
+ const dynamicSpread = useMemo(
+ () => (children?.length ?? 0) * spread,
+ [children, spread]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const Shimmer = memo(ShimmerComponent);
diff --git a/components/ai-elements/sources.tsx b/components/ai-elements/sources.tsx
new file mode 100644
index 0000000..0756664
--- /dev/null
+++ b/components/ai-elements/sources.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { BookIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type SourcesProps = ComponentProps<"div">;
+
+export const Sources = ({ className, ...props }: SourcesProps) => (
+
+);
+
+export type SourcesTriggerProps = ComponentProps & {
+ count: number;
+};
+
+export const SourcesTrigger = ({
+ className,
+ count,
+ children,
+ ...props
+}: SourcesTriggerProps) => (
+
+ {children ?? (
+ <>
+ Used {count} sources
+
+ >
+ )}
+
+);
+
+export type SourcesContentProps = ComponentProps;
+
+export const SourcesContent = ({
+ className,
+ ...props
+}: SourcesContentProps) => (
+
+);
+
+export type SourceProps = ComponentProps<"a">;
+
+export const Source = ({ href, title, children, ...props }: SourceProps) => (
+
+ {children ?? (
+ <>
+
+ {title}
+ >
+ )}
+
+);
diff --git a/components/ai-elements/suggestion.tsx b/components/ai-elements/suggestion.tsx
new file mode 100644
index 0000000..9d76a82
--- /dev/null
+++ b/components/ai-elements/suggestion.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ ScrollArea,
+ ScrollBar,
+} from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import type { ComponentProps } from "react";
+
+export type SuggestionsProps = ComponentProps;
+
+export const Suggestions = ({
+ className,
+ children,
+ ...props
+}: SuggestionsProps) => (
+
+
+ {children}
+
+
+
+);
+
+export type SuggestionProps = Omit, "onClick"> & {
+ suggestion: string;
+ onClick?: (suggestion: string) => void;
+};
+
+export const Suggestion = ({
+ suggestion,
+ onClick,
+ className,
+ variant = "outline",
+ size = "sm",
+ children,
+ ...props
+}: SuggestionProps) => {
+ const handleClick = () => {
+ onClick?.(suggestion);
+ };
+
+ return (
+
+ );
+};
diff --git a/components/ai-elements/task.tsx b/components/ai-elements/task.tsx
new file mode 100644
index 0000000..eeb802c
--- /dev/null
+++ b/components/ai-elements/task.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon, SearchIcon } from "lucide-react";
+import type { ComponentProps } from "react";
+
+export type TaskItemFileProps = ComponentProps<"div">;
+
+export const TaskItemFile = ({
+ children,
+ className,
+ ...props
+}: TaskItemFileProps) => (
+
+ {children}
+
+);
+
+export type TaskItemProps = ComponentProps<"div">;
+
+export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
+
+ {children}
+
+);
+
+export type TaskProps = ComponentProps;
+
+export const Task = ({
+ defaultOpen = true,
+ className,
+ ...props
+}: TaskProps) => (
+
+);
+
+export type TaskTriggerProps = ComponentProps & {
+ title: string;
+};
+
+export const TaskTrigger = ({
+ children,
+ className,
+ title,
+ ...props
+}: TaskTriggerProps) => (
+
+ {children ?? (
+
+ )}
+
+);
+
+export type TaskContentProps = ComponentProps;
+
+export const TaskContent = ({
+ children,
+ className,
+ ...props
+}: TaskContentProps) => (
+
+
+ {children}
+
+
+);
diff --git a/components/ai-elements/tool.tsx b/components/ai-elements/tool.tsx
new file mode 100644
index 0000000..e47a7f4
--- /dev/null
+++ b/components/ai-elements/tool.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { ToolUIPart } from "ai";
+import {
+ CheckCircleIcon,
+ ChevronDownIcon,
+ CircleIcon,
+ ClockIcon,
+ WrenchIcon,
+ XCircleIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
+import { CodeBlock } from "./code-block";
+
+export type ToolProps = ComponentProps;
+
+export const Tool = ({ className, ...props }: ToolProps) => (
+
+);
+
+export type ToolHeaderProps = {
+ title?: string;
+ type: ToolUIPart["type"];
+ state: ToolUIPart["state"];
+ className?: string;
+};
+
+const getStatusBadge = (status: ToolUIPart["state"]) => {
+ const labels = {
+ "input-streaming": "Pending",
+ "input-available": "Running",
+ "output-available": "Completed",
+ "output-error": "Error",
+ } as const;
+
+ const icons = {
+ "input-streaming": ,
+ "input-available": ,
+ "output-available": ,
+ "output-error": ,
+ } as const;
+
+ return (
+
+ {icons[status]}
+ {labels[status]}
+
+ );
+};
+
+export const ToolHeader = ({
+ className,
+ title,
+ type,
+ state,
+ ...props
+}: ToolHeaderProps) => (
+
+
+
+
+ {title ?? type.split("-").slice(1).join("-")}
+
+ {getStatusBadge(state)}
+
+
+
+);
+
+export type ToolContentProps = ComponentProps;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+ input: ToolUIPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+
+);
+
+export type ToolOutputProps = ComponentProps<"div"> & {
+ output: ToolUIPart["output"];
+ errorText: ToolUIPart["errorText"];
+};
+
+export const ToolOutput = ({
+ className,
+ output,
+ errorText,
+ ...props
+}: ToolOutputProps) => {
+ if (!(output || errorText)) {
+ return null;
+ }
+
+ let Output = {output as ReactNode}
;
+
+ if (typeof output === "object" && !isValidElement(output)) {
+ Output = (
+
+ );
+ } else if (typeof output === "string") {
+ Output = ;
+ }
+
+ return (
+
+
+ {errorText ? "Error" : "Result"}
+
+
+ {errorText &&
{errorText}
}
+ {Output}
+
+
+ );
+};
diff --git a/components/ai-elements/toolbar.tsx b/components/ai-elements/toolbar.tsx
new file mode 100644
index 0000000..b55aa88
--- /dev/null
+++ b/components/ai-elements/toolbar.tsx
@@ -0,0 +1,16 @@
+import { cn } from "@/lib/utils";
+import { NodeToolbar, Position } from "@xyflow/react";
+import type { ComponentProps } from "react";
+
+type ToolbarProps = ComponentProps;
+
+export const Toolbar = ({ className, ...props }: ToolbarProps) => (
+
+);
diff --git a/components/ai-elements/web-preview.tsx b/components/ai-elements/web-preview.tsx
new file mode 100644
index 0000000..8f0ab5a
--- /dev/null
+++ b/components/ai-elements/web-preview.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
+
+export type WebPreviewContextValue = {
+ url: string;
+ setUrl: (url: string) => void;
+ consoleOpen: boolean;
+ setConsoleOpen: (open: boolean) => void;
+};
+
+const WebPreviewContext = createContext(null);
+
+const useWebPreview = () => {
+ const context = useContext(WebPreviewContext);
+ if (!context) {
+ throw new Error("WebPreview components must be used within a WebPreview");
+ }
+ return context;
+};
+
+export type WebPreviewProps = ComponentProps<"div"> & {
+ defaultUrl?: string;
+ onUrlChange?: (url: string) => void;
+};
+
+export const WebPreview = ({
+ className,
+ children,
+ defaultUrl = "",
+ onUrlChange,
+ ...props
+}: WebPreviewProps) => {
+ const [url, setUrl] = useState(defaultUrl);
+ const [consoleOpen, setConsoleOpen] = useState(false);
+
+ const handleUrlChange = (newUrl: string) => {
+ setUrl(newUrl);
+ onUrlChange?.(newUrl);
+ };
+
+ const contextValue: WebPreviewContextValue = {
+ url,
+ setUrl: handleUrlChange,
+ consoleOpen,
+ setConsoleOpen,
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type WebPreviewNavigationProps = ComponentProps<"div">;
+
+export const WebPreviewNavigation = ({
+ className,
+ children,
+ ...props
+}: WebPreviewNavigationProps) => (
+
+ {children}
+
+);
+
+export type WebPreviewNavigationButtonProps = ComponentProps & {
+ tooltip?: string;
+};
+
+export const WebPreviewNavigationButton = ({
+ onClick,
+ disabled,
+ tooltip,
+ children,
+ ...props
+}: WebPreviewNavigationButtonProps) => (
+
+
+
+
+
+
+ {tooltip}
+
+
+
+);
+
+export type WebPreviewUrlProps = ComponentProps;
+
+export const WebPreviewUrl = ({
+ value,
+ onChange,
+ onKeyDown,
+ ...props
+}: WebPreviewUrlProps) => {
+ const { url, setUrl } = useWebPreview();
+ const [inputValue, setInputValue] = useState(url);
+
+ // Sync input value with context URL when it changes externally
+ useEffect(() => {
+ setInputValue(url);
+ }, [url]);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setInputValue(event.target.value);
+ onChange?.(event);
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ const target = event.target as HTMLInputElement;
+ setUrl(target.value);
+ }
+ onKeyDown?.(event);
+ };
+
+ return (
+
+ );
+};
+
+export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
+ loading?: ReactNode;
+};
+
+export const WebPreviewBody = ({
+ className,
+ loading,
+ src,
+ ...props
+}: WebPreviewBodyProps) => {
+ const { url } = useWebPreview();
+
+ return (
+
+
+ {loading}
+
+ );
+};
+
+export type WebPreviewConsoleProps = ComponentProps<"div"> & {
+ logs?: Array<{
+ level: "log" | "warn" | "error";
+ message: string;
+ timestamp: Date;
+ }>;
+};
+
+export const WebPreviewConsole = ({
+ className,
+ logs = [],
+ children,
+ ...props
+}: WebPreviewConsoleProps) => {
+ const { consoleOpen, setConsoleOpen } = useWebPreview();
+
+ return (
+
+
+
+
+
+
+ {logs.length === 0 ? (
+
No console output
+ ) : (
+ logs.map((log, index) => (
+
+
+ {log.timestamp.toLocaleTimeString()}
+ {" "}
+ {log.message}
+
+ ))
+ )}
+ {children}
+
+
+
+ );
+};
diff --git a/components/messages/assistant-message.tsx b/components/messages/assistant-message.tsx
new file mode 100644
index 0000000..2f6c203
--- /dev/null
+++ b/components/messages/assistant-message.tsx
@@ -0,0 +1,51 @@
+import { UIMessage, ToolCallPart, ToolResultPart } from "ai";
+import { Response } from "@/components/ai-elements/response";
+import { ReasoningPart } from "./reasoning-part";
+import { ToolCall, ToolResult } from "./tool-call";
+
+export function AssistantMessage({ message, status, isLastMessage, durations, onDurationChange }: { message: UIMessage; status?: string; isLastMessage?: boolean; durations?: Record; onDurationChange?: (key: string, duration: number) => void }) {
+ return (
+
+
+ {message.parts.map((part, i) => {
+ const isStreaming = status === "streaming" && isLastMessage && i === message.parts.length - 1;
+ const durationKey = `${message.id}-${i}`;
+ const duration = durations?.[durationKey];
+
+ if (part.type === "text") {
+ return {part.text};
+ } else if (part.type === "reasoning") {
+ return (
+ onDurationChange(durationKey, d) : undefined}
+ />
+ );
+ } else if (
+ part.type.startsWith("tool-") || part.type === "dynamic-tool"
+ ) {
+ if ('state' in part && part.state === "output-available") {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+ return null;
+ })}
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/messages/message-wall.tsx b/components/messages/message-wall.tsx
new file mode 100644
index 0000000..5fcdd13
--- /dev/null
+++ b/components/messages/message-wall.tsx
@@ -0,0 +1,34 @@
+import { UIMessage } from "ai";
+import { useEffect, useRef } from "react";
+import { UserMessage } from "./user-message";
+import { AssistantMessage } from "./assistant-message";
+
+
+export function MessageWall({ messages, status, durations, onDurationChange }: { messages: UIMessage[]; status?: string; durations?: Record; onDurationChange?: (key: string, duration: number) => void }) {
+ const messagesEndRef = useRef(null);
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages]);
+
+ return (
+
+
+ {messages.map((message, messageIndex) => {
+ const isLastMessage = messageIndex === messages.length - 1;
+ return (
+
+ {message.role === "user" ?
:
}
+
+ );
+ })}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/messages/reasoning-part.tsx b/components/messages/reasoning-part.tsx
new file mode 100644
index 0000000..9afd60a
--- /dev/null
+++ b/components/messages/reasoning-part.tsx
@@ -0,0 +1,13 @@
+import { ReasoningUIPart } from "ai";
+import { Reasoning } from "../ai-elements/reasoning";
+import { ReasoningTrigger } from "../ai-elements/reasoning";
+import { ReasoningContent } from "../ai-elements/reasoning";
+
+export function ReasoningPart({ part, isStreaming = false, duration, onDurationChange }: { part: ReasoningUIPart; isStreaming?: boolean; duration?: number; onDurationChange?: (duration: number) => void }) {
+ return
+
+ {part.text &&
+ {part.text}
+ }
+ ;
+}
\ No newline at end of file
diff --git a/components/messages/tool-call.tsx b/components/messages/tool-call.tsx
new file mode 100644
index 0000000..4990aed
--- /dev/null
+++ b/components/messages/tool-call.tsx
@@ -0,0 +1,113 @@
+import { ToolCallPart, ToolResultPart } from "ai";
+import { Book, Globe, Search, Presentation, Wrench } from "lucide-react";
+import { Shimmer } from "../ai-elements/shimmer";
+
+export interface ToolDisplay {
+ call_label: string;
+ call_icon: React.ReactNode;
+ result_label: string;
+ result_icon: React.ReactNode;
+ formatArgs?: (toolName: string, input: unknown) => string;
+};
+
+function formatWebSearchArgs(_: string, input: unknown): string {
+ try {
+ if (typeof input !== 'object' || input === null) {
+ return "";
+ }
+ const args = input as Record;
+ return args.query ? String(args.query) : "";
+ } catch {
+ return "";
+ }
+}
+
+const TOOL_DISPLAY_MAP: Record = {
+ webSearch: {
+ call_label: "Searching the web",
+ call_icon: ,
+ result_label: "Searched the web",
+ result_icon: ,
+ formatArgs: formatWebSearchArgs,
+ },
+};
+
+const DEFAULT_TOOL_DISPLAY: ToolDisplay = { call_label: "Using tool", call_icon: , result_label: "Used tool", result_icon: };
+
+function extractToolName(part: ToolCallPart | ToolResultPart): string | undefined {
+ const partWithType = part as unknown as { type?: string; toolName?: string };
+ if (partWithType.type && partWithType.type.startsWith("tool-")) {
+ return partWithType.type.slice(5);
+ }
+ if (partWithType.toolName) {
+ return partWithType.toolName;
+ }
+ if ('toolName' in part && part.toolName) {
+ return part.toolName;
+ }
+ return undefined;
+}
+
+function formatToolArguments(toolName: string, input: unknown, toolDisplay?: ToolDisplay): string {
+ if (toolDisplay?.formatArgs) {
+ return toolDisplay.formatArgs(toolName, input);
+ }
+
+ try {
+ if (typeof input !== 'object' || input === null) {
+ return String(input);
+ }
+
+ const args = input as Record;
+ if (args.query) {
+ return String(args.query);
+ }
+ return "Arguments not available";
+ } catch {
+ return "Arguments not available";
+ }
+}
+
+export function ToolCall({ part }: { part: ToolCallPart }) {
+ const { input } = part;
+ const toolName = extractToolName(part);
+ const toolDisplay = toolName ? (TOOL_DISPLAY_MAP[toolName] || DEFAULT_TOOL_DISPLAY) : DEFAULT_TOOL_DISPLAY;
+ const formattedArgs = formatToolArguments(toolName || "", input, toolDisplay);
+
+ return (
+
+
+ {toolDisplay.call_icon}
+ {toolDisplay.call_label}
+
+ {toolDisplay.formatArgs && formattedArgs && (
+
+ {formattedArgs}
+
+ )}
+
+ );
+}
+
+export function ToolResult({ part }: { part: ToolResultPart }) {
+ const { output } = part;
+ const toolName = extractToolName(part);
+ const toolDisplay = toolName ? (TOOL_DISPLAY_MAP[toolName] || DEFAULT_TOOL_DISPLAY) : DEFAULT_TOOL_DISPLAY;
+
+ const input = 'input' in part ? part.input : undefined;
+ const formattedArgs = input !== undefined ? formatToolArguments(toolName || "", input, toolDisplay) : "";
+
+ return (
+
+
+ {toolDisplay.result_icon}
+ {toolDisplay.result_label}
+
+ {toolDisplay.formatArgs && formattedArgs && (
+
+ {formattedArgs}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/components/messages/user-message.tsx b/components/messages/user-message.tsx
new file mode 100644
index 0000000..012042e
--- /dev/null
+++ b/components/messages/user-message.tsx
@@ -0,0 +1,19 @@
+import { UIMessage } from "ai";
+import { Response } from "@/components/ai-elements/response";
+
+export function UserMessage({ message }: { message: UIMessage }) {
+ return (
+
+
+
+ {message.parts.map((part, i) => {
+ switch (part.type) {
+ case "text":
+ return {part.text};
+ }
+ })}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..4a8cca4
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..0863e40
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..3df3fd0
--- /dev/null
+++ b/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..71e428b
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..fd3a406
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..eb88f32
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
new file mode 100644
index 0000000..8600af0
--- /dev/null
+++ b/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..21409a0
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ 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",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 0000000..4d7c46a
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import * as React from "react"
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-popover inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-l-md bg-accent",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+