demo(vercel-ai-sdk): add UI components (incl. generative tool UIs)

This commit is contained in:
Musa 2026-01-08 15:21:11 -08:00
parent b7af8ab536
commit 719a5bf0af
114 changed files with 18020 additions and 0 deletions

View file

@ -0,0 +1,147 @@
"use client";
import { type LucideIcon, XIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className
)}
{...props}
/>
);
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
className
)}
{...props}
/>
);
export type ArtifactCloseProps = ComponentProps<typeof Button>;
export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
};
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon className="size-4" /> : children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
);

View file

@ -0,0 +1,22 @@
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
import type { ReactNode } from "react";
import "@xyflow/react/dist/style.css";
type CanvasProps = ReactFlowProps & {
children?: ReactNode;
};
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={["Backspace", "Delete"]}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor="var(--sidebar)" />
{children}
</ReactFlow>
);

View file

@ -0,0 +1,231 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
type LucideIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
type ChainOfThoughtContextValue = {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
};
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
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 (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div
className={cn("not-prose max-w-prose space-y-4", className)}
{...props}
>
{children}
</div>
</ChainOfThoughtContext.Provider>
);
}
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
}
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
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 (
<div
className={cn(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
);
}
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
)
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
)
);
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
}
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
)
);
ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";

View file

@ -0,0 +1,71 @@
"use client";
import { BookmarkIcon, type LucideProps } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
export const Checkpoint = ({
className,
children,
...props
}: CheckpointProps) => (
<div
className={cn(
"flex items-center gap-0.5 overflow-hidden text-muted-foreground",
className
)}
{...props}
>
{children}
<Separator />
</div>
);
export type CheckpointIconProps = LucideProps;
export const CheckpointIcon = ({
className,
children,
...props
}: CheckpointIconProps) =>
children ?? (
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
);
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const CheckpointTrigger = ({
children,
className,
variant = "ghost",
size = "sm",
tooltip,
...props
}: CheckpointTriggerProps) =>
tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<Button size={size} type="button" variant={variant} {...props}>
{children}
</Button>
</TooltipTrigger>
<TooltipContent align="start" side="bottom">
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<Button size={size} type="button" variant={variant} {...props}>
{children}
</Button>
);

View file

@ -0,0 +1,178 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers",
line(node, line) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
className: [
"inline-block",
"min-w-10",
"mr-4",
"text-right",
"select-none",
"text-muted-foreground",
],
},
children: [{ type: "text", value: String(line) }],
});
},
};
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false
) {
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: [];
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: "one-light",
transformers,
}),
codeToHtml(code, {
lang: language,
theme: "one-dark-pro",
transformers,
}),
]);
}
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>("");
const [darkHtml, setDarkHtml] = useState<string>("");
const mounted = useRef(false);
useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light);
setDarkHtml(dark);
mounted.current = true;
}
});
return () => {
mounted.current = false;
};
}, [code, language, showLineNumbers]);
return (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
<div className="relative">
<div
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
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 (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View file

@ -0,0 +1,176 @@
"use client";
import type { ToolUIPart } from "ai";
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
} from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ToolUIPartApproval =
| {
id: string;
approved?: never;
reason?: never;
}
| {
id: string;
approved: boolean;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: false;
reason?: string;
}
| undefined;
type ConfirmationContextValue = {
approval: ToolUIPartApproval;
state: ToolUIPart["state"];
};
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
null
);
const useConfirmation = () => {
const context = useContext(ConfirmationContext);
if (!context) {
throw new Error("Confirmation components must be used within Confirmation");
}
return context;
};
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ToolUIPartApproval;
state: ToolUIPart["state"];
};
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
if (!approval || state === "input-streaming" || state === "input-available") {
return null;
}
return (
<ConfirmationContext.Provider value={{ approval, state }}>
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
</ConfirmationContext.Provider>
);
};
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn("inline", className)} {...props} />
);
export type ConfirmationRequestProps = {
children?: ReactNode;
};
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return children;
};
export type ConfirmationAcceptedProps = {
children?: ReactNode;
};
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation();
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export type ConfirmationRejectedProps = {
children?: ReactNode;
};
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation();
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export type ConfirmationActionsProps = ComponentProps<"div">;
export const ConfirmationActions = ({
className,
...props
}: ConfirmationActionsProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return (
<div
className={cn("flex items-center justify-end gap-2 self-end", className)}
{...props}
/>
);
};
export type ConfirmationActionProps = ComponentProps<typeof Button>;
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
);

View file

@ -0,0 +1,28 @@
import type { ConnectionLineComponent } from "@xyflow/react";
const HALF = 0.5;
export const Connection: ConnectionLineComponent = ({
fromX,
fromY,
toX,
toY,
}) => (
<g>
<path
className="animated"
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill="none"
stroke="var(--color-ring)"
strokeWidth={1}
/>
<circle
cx={toX}
cy={toY}
fill="#fff"
r={3}
stroke="var(--color-ring)"
strokeWidth={1}
/>
</g>
);

View file

@ -0,0 +1,18 @@
"use client";
import { Controls as ControlsPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive
className={cn(
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
className
)}
{...props}
/>
);

View file

@ -0,0 +1,100 @@
"use client";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
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) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View file

@ -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 (
<BaseEdge
className="stroke-1 stroke-ring"
id={id}
path={edgePath}
style={{
strokeDasharray: "5, 5",
}}
/>
);
};
const getHandleCoordsByPosition = (
node: InternalNode<Node>,
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] as const;
}
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] as const;
};
const getEdgeParams = (
source: InternalNode<Node>,
target: InternalNode<Node>
) => {
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 (
<>
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
<circle fill="var(--primary)" r="4">
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
</circle>
</>
);
};
export const Edge = {
Temporary,
Animated,
};

View file

@ -0,0 +1,26 @@
import type { Experimental_GeneratedImage } from "ai";
import { cn } from "@/lib/utils";
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
// biome-ignore lint/nursery/useImageSize: dynamic base64 content
// biome-ignore lint/performance/noImgElement: base64 data URLs require native img
<img
{...props}
alt={props.alt}
className={cn(
"h-auto max-w-full overflow-hidden rounded-md",
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View file

@ -0,0 +1,287 @@
"use client";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
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";
export type InlineCitationProps = ComponentProps<"span">;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn("ml-1 rounded-full", className)}
variant="secondary"
{...props}
>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
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 (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn("space-y-1", className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>
{children}
</blockquote>
);

View file

@ -0,0 +1,96 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
"inline-flex animate-spin items-center justify-center",
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View file

@ -0,0 +1,446 @@
"use client";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg 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",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
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: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
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
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({
data,
className,
onRemove,
...props
}: MessageAttachmentProps) {
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className
)}
{...props}
>
{isImage ? (
<>
{/* biome-ignore lint/performance/noImgElement: dynamic user-uploaded images */}
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({
children,
className,
...props
}: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className
)}
{...props}
>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View file

@ -0,0 +1,203 @@
import Image from "next/image";
import type { ComponentProps, ReactNode } from "react";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ModelSelectorLogoProps = {
className?: string;
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
}: ModelSelectorLogoProps) => (
<Image
alt={`${provider} logo`}
className={cn("size-3 dark:invert", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
unoptimized
width={12}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className
)}
{...props}
/>
);
export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
);

View file

@ -0,0 +1,71 @@
import { Handle, Position } from "@xyflow/react";
import type { ComponentProps } from "react";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
export type NodeProps = ComponentProps<typeof Card> & {
handles: {
target: boolean;
source: boolean;
};
};
export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card
className={cn(
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
className
)}
{...props}
>
{handles.target && <Handle position={Position.Left} type="target" />}
{handles.source && <Handle position={Position.Right} type="source" />}
{props.children}
</Card>
);
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
{...props}
/>
);
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
export const NodeDescription = (props: NodeDescriptionProps) => (
<CardDescription {...props} />
);
export type NodeActionProps = ComponentProps<typeof CardAction>;
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
export type NodeContentProps = ComponentProps<typeof CardContent>;
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
<CardContent className={cn("p-3", className)} {...props} />
);
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
{...props}
/>
);

View file

@ -0,0 +1,365 @@
"use client";
import {
ChevronDownIcon,
ExternalLinkIcon,
MessageCircleIcon,
} from "lucide-react";
import { type ComponentProps, createContext, useContext } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const providers = {
github: {
title: "Open in GitHub",
createUrl: (url: string) => url,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
},
scira: {
title: "Open in Scira",
createUrl: (q: string) =>
`https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="none"
height="934"
viewBox="0 0 910 934"
width="910"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="30"
/>
</svg>
),
},
chatgpt: {
title: "Open in ChatGPT",
createUrl: (prompt: string) =>
`https://chatgpt.com/?${new URLSearchParams({
hints: "search",
prompt,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
},
claude: {
title: "Open in Claude",
createUrl: (q: string) =>
`https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg"
>
<title>Claude</title>
<path
clipRule="evenodd"
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
fillRule="evenodd"
/>
</svg>
),
},
t3: {
title: "Open in T3 Chat",
createUrl: (q: string) =>
`https://t3.chat/new?${new URLSearchParams({
q,
})}`,
icon: <MessageCircleIcon />,
},
v0: {
title: "Open in v0",
createUrl: (q: string) =>
`https://v0.app?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
viewBox="0 0 147 70"
xmlns="http://www.w3.org/2000/svg"
>
<title>v0</title>
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
</svg>
),
},
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: (
<svg
version="1.1"
viewBox="0 0 466.73 532.09"
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
fill="currentColor"
/>
</svg>
),
},
};
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<typeof DropdownMenu> & {
query: string;
};
export const OpenIn = ({ query, ...props }: OpenInProps) => (
<OpenInContext.Provider value={{ query }}>
<DropdownMenu {...props} />
</OpenInContext.Provider>
);
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
<DropdownMenuContent
align="start"
className={cn("w-[240px]", className)}
{...props}
/>
);
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInItem = (props: OpenInItemProps) => (
<DropdownMenuItem {...props} />
);
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
export const OpenInLabel = (props: OpenInLabelProps) => (
<DropdownMenuLabel {...props} />
);
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
<DropdownMenuSeparator {...props} />
);
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
<DropdownMenuTrigger {...props} asChild>
{children ?? (
<Button type="button" variant="outline">
Open in chat
<ChevronDownIcon className="size-4" />
</Button>
)}
</DropdownMenuTrigger>
);
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.chatgpt.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.chatgpt.icon}</span>
<span className="flex-1">{providers.chatgpt.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.claude.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.claude.icon}</span>
<span className="flex-1">{providers.claude.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.t3.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.t3.icon}</span>
<span className="flex-1">{providers.t3.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.scira.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.scira.icon}</span>
<span className="flex-1">{providers.scira.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.v0.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.v0.icon}</span>
<span className="flex-1">{providers.v0.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.cursor.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.cursor.icon}</span>
<span className="flex-1">{providers.cursor.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};

View file

@ -0,0 +1,15 @@
import { Panel as PanelPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
type PanelProps = ComponentProps<typeof PanelPrimitive>;
export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive
className={cn(
"m-4 overflow-hidden rounded-md border bg-card p-1",
className
)}
{...props}
/>
);

View file

@ -0,0 +1,142 @@
"use client";
import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, useContext } from "react";
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 { Shimmer } from "./shimmer";
type PlanContextValue = {
isStreaming: boolean;
};
const PlanContext = createContext<PlanContextValue | null>(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<typeof Collapsible> & {
isStreaming?: boolean;
};
export const Plan = ({
className,
isStreaming = false,
children,
...props
}: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}>
<Collapsible asChild data-slot="plan" {...props}>
<Card className={cn("shadow-none", className)}>{children}</Card>
</Collapsible>
</PlanContext.Provider>
);
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn("flex items-start justify-between", className)}
data-slot="plan-header"
{...props}
/>
);
export type PlanTitleProps = Omit<
ComponentProps<typeof CardTitle>,
"children"
> & {
children: string;
};
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan();
return (
<CardTitle data-slot="plan-title" {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle>
);
};
export type PlanDescriptionProps = Omit<
ComponentProps<typeof CardDescription>,
"children"
> & {
children: string;
};
export const PlanDescription = ({
className,
children,
...props
}: PlanDescriptionProps) => {
const { isStreaming } = usePlan();
return (
<CardDescription
className={cn("text-balance", className)}
data-slot="plan-description"
{...props}
>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription>
);
};
export type PlanActionProps = ComponentProps<typeof CardAction>;
export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} />
);
export type PlanContentProps = ComponentProps<typeof CardContent>;
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild>
<CardContent data-slot="plan-content" {...props} />
</CollapsibleContent>
);
export type PlanFooterProps = ComponentProps<"div">;
export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} />
);
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger asChild>
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
{...props}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</Button>
</CollapsibleTrigger>
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,275 @@
"use client";
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
import type { ComponentProps } from "react";
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";
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) => (
<li
className={cn(
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
className
)}
{...props}
/>
);
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
completed?: boolean;
};
export const QueueItemIndicator = ({
completed = false,
className,
...props
}: QueueItemIndicatorProps) => (
<span
className={cn(
"mt-0.5 inline-block size-2.5 rounded-full border",
completed
? "border-muted-foreground/20 bg-muted-foreground/10"
: "border-muted-foreground/50",
className
)}
{...props}
/>
);
export type QueueItemContentProps = ComponentProps<"span"> & {
completed?: boolean;
};
export const QueueItemContent = ({
completed = false,
className,
...props
}: QueueItemContentProps) => (
<span
className={cn(
"wrap-break-word line-clamp-1 grow",
completed
? "text-muted-foreground/50 line-through"
: "text-muted-foreground",
className
)}
{...props}
/>
);
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
completed?: boolean;
};
export const QueueItemDescription = ({
completed = false,
className,
...props
}: QueueItemDescriptionProps) => (
<div
className={cn(
"ml-6 text-xs",
completed
? "text-muted-foreground/40 line-through"
: "text-muted-foreground",
className
)}
{...props}
/>
);
export type QueueItemActionsProps = ComponentProps<"div">;
export const QueueItemActions = ({
className,
...props
}: QueueItemActionsProps) => (
<div className={cn("flex gap-1", className)} {...props} />
);
export type QueueItemActionProps = Omit<
ComponentProps<typeof Button>,
"variant" | "size"
>;
export const QueueItemAction = ({
className,
...props
}: QueueItemActionProps) => (
<Button
className={cn(
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
className
)}
size="icon"
type="button"
variant="ghost"
{...props}
/>
);
export type QueueItemAttachmentProps = ComponentProps<"div">;
export const QueueItemAttachment = ({
className,
...props
}: QueueItemAttachmentProps) => (
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
);
export type QueueItemImageProps = ComponentProps<"img">;
export const QueueItemImage = ({
className,
...props
}: QueueItemImageProps) => (
// biome-ignore lint/performance/noImgElement: dynamic blob/data URLs require native img
<img
alt=""
className={cn("h-8 w-8 rounded border object-cover", className)}
height={32}
width={32}
{...props}
/>
);
export type QueueItemFileProps = ComponentProps<"span">;
export const QueueItemFile = ({
children,
className,
...props
}: QueueItemFileProps) => (
<span
className={cn(
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
className
)}
{...props}
>
<PaperclipIcon size={12} />
<span className="max-w-[100px] truncate">{children}</span>
</span>
);
export type QueueListProps = ComponentProps<typeof ScrollArea>;
export const QueueList = ({
children,
className,
...props
}: QueueListProps) => (
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}>
<div className="max-h-40 pr-4">
<ul>{children}</ul>
</div>
</ScrollArea>
);
// QueueSection - collapsible section container
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
export const QueueSection = ({
className,
defaultOpen = true,
...props
}: QueueSectionProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
// QueueSectionTrigger - section header/trigger
export type QueueSectionTriggerProps = ComponentProps<"button">;
export const QueueSectionTrigger = ({
children,
className,
...props
}: QueueSectionTriggerProps) => (
<CollapsibleTrigger asChild>
<button
className={cn(
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
className
)}
type="button"
{...props}
>
{children}
</button>
</CollapsibleTrigger>
);
// 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) => (
<span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
{icon}
<span>
{count} {label}
</span>
</span>
);
// QueueSectionContent - collapsible content area
export type QueueSectionContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const QueueSectionContent = ({
className,
...props
}: QueueSectionContentProps) => (
<CollapsibleContent className={cn(className)} {...props} />
);
export type QueueProps = ComponentProps<"div">;
export const Queue = ({ className, ...props }: QueueProps) => (
<div
className={cn(
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
className
)}
{...props}
/>
);

View file

@ -0,0 +1,189 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { Shimmer } from "./shimmer";
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 300;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
});
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
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 (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-2", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking</Shimmer>;
}
if (duration === undefined) {
return <span>Thought</span>;
}
return <span>{duration}s</span>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-3" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-2.5 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-1.5 text-[11px] leading-relaxed",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="max-h-48 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2.5 text-[11px] **:text-[11px] [&_li]:my-0 [&_ol]:my-1 [&_p]:my-0 [&_ul]:my-1">
<Streamdown>{children}</Streamdown>
</div>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View file

@ -0,0 +1,64 @@
"use client";
import { motion } from "motion/react";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
import { cn } from "@/lib/utils";
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 (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View file

@ -0,0 +1,77 @@
"use client";
import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger
className={cn("flex items-center gap-2", className)}
{...props}
>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);

View file

@ -0,0 +1,53 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "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 (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View file

@ -0,0 +1,87 @@
"use client";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View file

@ -0,0 +1,163 @@
"use client";
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 { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolHeaderProps = {
title?: string;
type: ToolUIPart["type"];
state: ToolUIPart["state"];
className?: string;
};
const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels: Record<ToolUIPart["state"], string> = {
"input-streaming": "Pending",
"input-available": "Running",
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"output-available": "Completed",
"output-error": "Error",
"output-denied": "Denied",
};
const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
className,
title,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
{title ?? type.split("-").slice(1).join("-")}
</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
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 = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};

View file

@ -0,0 +1,16 @@
import { NodeToolbar, Position } from "@xyflow/react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar
className={cn(
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
className
)}
position={Position.Bottom}
{...props}
/>
);

View file

@ -0,0 +1,263 @@
"use client";
import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react";
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";
export type WebPreviewContextValue = {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
};
const WebPreviewContext = createContext<WebPreviewContextValue | null>(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 (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
"flex size-full flex-col rounded-lg border bg-card",
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn("flex items-center gap-1 border-b p-2", className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
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<HTMLInputElement>) => {
setInputValue(event.target.value);
onChange?.(event);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? inputValue}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
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 (
<Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground"
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{" "}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -0,0 +1,147 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { PlusIcon, TrashIcon } from "@/components/icons";
import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter();
const { setOpenMobile } = useSidebar();
const { mutate } = useSWRConfig();
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
const handleDeleteAll = () => {
const deletePromise = fetch("/api/history", {
method: "DELETE",
});
toast.promise(deletePromise, {
loading: "Deleting all chats...",
success: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
setShowDeleteAllDialog(false);
router.replace("/");
router.refresh();
return "All chats deleted successfully";
},
error: "Failed to delete all chats",
});
};
return (
<>
<Sidebar className="group-data-[side=left]:border-r-0">
<SidebarHeader>
<SidebarMenu>
<div className="flex flex-row items-center justify-between">
<Link
className="flex flex-row items-center gap-3"
href="/"
onClick={() => {
setOpenMobile(false);
}}
>
<span className="cursor-pointer rounded-md px-2 font-semibold text-lg hover:bg-muted">
Chatbot
</span>
</Link>
<div className="flex flex-row gap-1">
{user && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 p-1 md:h-fit md:p-2"
onClick={() => setShowDeleteAllDialog(true)}
type="button"
variant="ghost"
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent align="end" className="hidden md:block">
Delete All Chats
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 p-1 md:h-fit md:p-2"
onClick={() => {
setOpenMobile(false);
router.push("/");
router.refresh();
}}
type="button"
variant="ghost"
>
<PlusIcon />
</Button>
</TooltipTrigger>
<TooltipContent align="end" className="hidden md:block">
New Chat
</TooltipContent>
</Tooltip>
</div>
</div>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
</Sidebar>
<AlertDialog
onOpenChange={setShowDeleteAllDialog}
open={showDeleteAllDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete all
your chats and remove them from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll}>
Delete All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,107 @@
import { type Dispatch, memo, type SetStateAction, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { artifactDefinitions, type UIArtifact } from "./artifact";
import type { ArtifactActionContext } from "./create-artifact";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type ArtifactActionsProps = {
artifact: UIArtifact;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
metadata: any;
setMetadata: Dispatch<SetStateAction<any>>;
};
function PureArtifactActions({
artifact,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
}: ArtifactActionsProps) {
const [isLoading, setIsLoading] = useState(false);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
const actionContext: ArtifactActionContext = {
content: artifact.content,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
};
return (
<div className="flex flex-row gap-1">
{artifactDefinition.actions.map((action) => (
<Tooltip key={action.description}>
<TooltipTrigger asChild>
<Button
className={cn("h-fit dark:hover:bg-zinc-700", {
"p-2": !action.label,
"px-2 py-1.5": action.label,
})}
disabled={
isLoading || artifact.status === "streaming"
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false
}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (_error) {
toast.error("Failed to execute action");
} finally {
setIsLoading(false);
}
}}
variant="outline"
>
{action.icon}
{action.label}
</Button>
</TooltipTrigger>
<TooltipContent>{action.description}</TooltipContent>
</Tooltip>
))}
</div>
);
}
export const ArtifactActions = memo(
PureArtifactActions,
(prevProps, nextProps) => {
if (prevProps.artifact.status !== nextProps.artifact.status) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.artifact.content !== nextProps.artifact.content) {
return false;
}
return true;
}
);

View file

@ -0,0 +1,30 @@
import { memo } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { CrossIcon } from "./icons";
import { Button } from "./ui/button";
function PureArtifactCloseButton() {
const { setArtifact } = useArtifact();
return (
<Button
className="h-fit p-2 dark:hover:bg-zinc-700"
data-testid="artifact-close-button"
onClick={() => {
setArtifact((currentArtifact) =>
currentArtifact.status === "streaming"
? {
...currentArtifact,
isVisible: false,
}
: { ...initialArtifactData, status: "idle" }
);
}}
variant="outline"
>
<CrossIcon size={18} />
</Button>
);
}
export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);

View file

@ -0,0 +1,115 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import { memo } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import type { UIArtifact } from "./artifact";
import { PreviewMessage, ThinkingMessage } from "./message";
type ArtifactMessagesProps = {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
artifactStatus: UIArtifact["status"];
};
function PureArtifactMessages({
addToolApprovalResponse,
chatId,
status,
votes,
messages,
setMessages,
regenerate,
isReadonly,
}: ArtifactMessagesProps) {
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
onViewportEnter,
onViewportLeave,
hasSentMessage,
} = useMessages({
status,
});
return (
<div
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
ref={messagesContainerRef}
>
{messages.map((message, index) => (
<PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId}
isLoading={status === "streaming" && index === messages.length - 1}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
<AnimatePresence mode="wait">
{status === "submitted" &&
!messages.some((msg) =>
msg.parts?.some(
(part) => "state" in part && part.state === "approval-responded"
)
) && <ThinkingMessage key="thinking" />}
</AnimatePresence>
<motion.div
className="min-h-[24px] min-w-[24px] shrink-0"
onViewportEnter={onViewportEnter}
onViewportLeave={onViewportLeave}
ref={messagesEndRef}
/>
</div>
);
}
function areEqual(
prevProps: ArtifactMessagesProps,
nextProps: ArtifactMessagesProps
) {
if (
prevProps.artifactStatus === "streaming" &&
nextProps.artifactStatus === "streaming"
) {
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.status && nextProps.status) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return true;
}
export const ArtifactMessages = memo(PureArtifactMessages, areEqual);

View file

@ -0,0 +1,532 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { formatDistance } from "date-fns";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import {
type Dispatch,
memo,
type SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
import { codeArtifact } from "@/artifacts/code/client";
import { imageArtifact } from "@/artifacts/image/client";
import { sheetArtifact } from "@/artifacts/sheet/client";
import { textArtifact } from "@/artifacts/text/client";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document, Vote } from "@/lib/db/schema";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { ArtifactActions } from "./artifact-actions";
import { ArtifactCloseButton } from "./artifact-close-button";
import { ArtifactMessages } from "./artifact-messages";
import { MultimodalInput } from "./multimodal-input";
import { Toolbar } from "./toolbar";
import { useSidebar } from "./ui/sidebar";
import { VersionFooter } from "./version-footer";
import type { VisibilityType } from "./visibility-selector";
export const artifactDefinitions = [
textArtifact,
codeArtifact,
imageArtifact,
sheetArtifact,
];
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
export type UIArtifact = {
title: string;
documentId: string;
kind: ArtifactKind;
content: string;
isVisible: boolean;
status: "streaming" | "idle";
boundingBox: {
top: number;
left: number;
width: number;
height: number;
};
};
function PureArtifact({
addToolApprovalResponse,
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
sendMessage,
messages,
setMessages,
regenerate,
votes,
isReadonly,
selectedVisibilityType,
selectedModelId,
}: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
votes: Vote[] | undefined;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
}) {
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
const {
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Document[]>(
artifact.documentId !== "init" && artifact.status !== "streaming"
? `/api/document?id=${artifact.documentId}`
: null,
fetcher
);
const [mode, setMode] = useState<"edit" | "diff">("edit");
const [document, setDocument] = useState<Document | null>(null);
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
const { open: isSidebarOpen } = useSidebar();
useEffect(() => {
if (documents && documents.length > 0) {
const mostRecentDocument = documents.at(-1);
if (mostRecentDocument) {
setDocument(mostRecentDocument);
setCurrentVersionIndex(documents.length - 1);
setArtifact((currentArtifact) => ({
...currentArtifact,
content: mostRecentDocument.content ?? "",
}));
}
}
}, [documents, setArtifact]);
useEffect(() => {
mutateDocuments();
}, [mutateDocuments]);
const { mutate } = useSWRConfig();
const [isContentDirty, setIsContentDirty] = useState(false);
const handleContentChange = useCallback(
(updatedContent: string) => {
if (!artifact) {
return;
}
mutate<Document[]>(
`/api/document?id=${artifact.documentId}`,
async (currentDocuments) => {
if (!currentDocuments) {
return [];
}
const currentDocument = currentDocuments.at(-1);
if (!currentDocument || !currentDocument.content) {
setIsContentDirty(false);
return currentDocuments;
}
if (currentDocument.content !== updatedContent) {
await fetch(`/api/document?id=${artifact.documentId}`, {
method: "POST",
body: JSON.stringify({
title: artifact.title,
content: updatedContent,
kind: artifact.kind,
}),
});
setIsContentDirty(false);
const newDocument = {
...currentDocument,
content: updatedContent,
createdAt: new Date(),
};
return [...currentDocuments, newDocument];
}
return currentDocuments;
},
{ revalidate: false }
);
},
[artifact, mutate]
);
const debouncedHandleContentChange = useDebounceCallback(
handleContentChange,
2000
);
const saveContent = useCallback(
(updatedContent: string, debounce: boolean) => {
if (document && updatedContent !== document.content) {
setIsContentDirty(true);
if (debounce) {
debouncedHandleContentChange(updatedContent);
} else {
handleContentChange(updatedContent);
}
}
},
[document, debouncedHandleContentChange, handleContentChange]
);
function getDocumentContentById(index: number) {
if (!documents) {
return "";
}
if (!documents[index]) {
return "";
}
return documents[index].content ?? "";
}
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
if (!documents) {
return;
}
if (type === "latest") {
setCurrentVersionIndex(documents.length - 1);
setMode("edit");
}
if (type === "toggle") {
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
}
if (type === "prev") {
if (currentVersionIndex > 0) {
setCurrentVersionIndex((index) => index - 1);
}
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
};
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
/*
* NOTE: if there are no documents, or if
* the documents are being fetched, then
* we mark it as the current version.
*/
const isCurrentVersion =
documents && documents.length > 0
? currentVersionIndex === documents.length - 1
: true;
const { width: windowWidth, height: windowHeight } = useWindowSize();
const isMobile = windowWidth ? windowWidth < 768 : false;
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
useEffect(() => {
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
}, [artifact.documentId, artifactDefinition, setMetadata]);
return (
<AnimatePresence>
{artifact.isVisible && (
<motion.div
animate={{ opacity: 1 }}
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
data-testid="artifact"
exit={{ opacity: 0, transition: { delay: 0.4 } }}
initial={{ opacity: 1 }}
>
{!isMobile && (
<motion.div
animate={{ width: windowWidth, right: 0 }}
className="fixed h-dvh bg-background"
exit={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
initial={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
/>
)}
{!isMobile && (
<motion.div
animate={{
opacity: 1,
x: 0,
scale: 1,
transition: {
delay: 0.1,
type: "spring",
stiffness: 300,
damping: 30,
},
}}
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
exit={{
opacity: 0,
x: 0,
scale: 1,
transition: { duration: 0 },
}}
initial={{ opacity: 0, x: 10, scale: 1 }}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
animate={{ opacity: 1 }}
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex h-full flex-col items-center justify-between">
<ArtifactMessages
addToolApprovalResponse={addToolApprovalResponse}
artifactStatus={artifact.status}
chatId={chatId}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
setMessages={setMessages}
status={status}
votes={votes}
/>
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
<MultimodalInput
attachments={attachments}
chatId={chatId}
className="bg-background dark:bg-muted"
input={input}
messages={messages}
selectedModelId={selectedModelId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
/>
</div>
</div>
</motion.div>
)}
<motion.div
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : "calc(100dvw)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: "calc(100dvw-400px)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: "spring",
stiffness: 600,
damping: 30,
},
}}
initial={
isMobile
? {
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}
: {
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}
}
>
<div className="flex flex-row items-start justify-between p-2">
<div className="flex flex-row items-start gap-4">
<ArtifactCloseButton />
<div className="flex flex-col">
<div className="font-medium">{artifact.title}</div>
{isContentDirty ? (
<div className="text-muted-foreground text-sm">
Saving changes...
</div>
) : document ? (
<div className="text-muted-foreground text-sm">
{`Updated ${formatDistance(
new Date(document.createdAt),
new Date(),
{
addSuffix: true,
}
)}`}
</div>
) : (
<div className="mt-2 h-3 w-32 animate-pulse rounded-md bg-muted-foreground/20" />
)}
</div>
</div>
<ArtifactActions
artifact={artifact}
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
metadata={metadata}
mode={mode}
setMetadata={setMetadata}
/>
</div>
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
<artifactDefinition.content
content={
isCurrentVersion
? artifact.content
: getDocumentContentById(currentVersionIndex)
}
currentVersionIndex={currentVersionIndex}
getDocumentContentById={getDocumentContentById}
isCurrentVersion={isCurrentVersion}
isInline={false}
isLoading={isDocumentsFetching && !artifact.content}
metadata={metadata}
mode={mode}
onSaveContent={saveContent}
setMetadata={setMetadata}
status={artifact.status}
suggestions={[]}
title={artifact.title}
/>
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
artifactKind={artifact.kind}
isToolbarVisible={isToolbarVisible}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{!isCurrentVersion && (
<VersionFooter
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.input !== nextProps.input) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages.length)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
});

View file

@ -0,0 +1,60 @@
import Form from "next/form";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export function AuthForm({
action,
children,
defaultEmail = "",
}: {
action: NonNullable<
string | ((formData: FormData) => void | Promise<void>) | undefined
>;
children: React.ReactNode;
defaultEmail?: string;
}) {
return (
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="email"
>
Email Address
</Label>
<Input
autoComplete="email"
autoFocus
className="bg-muted text-md md:text-sm"
defaultValue={defaultEmail}
id="email"
name="email"
placeholder="user@acme.com"
required
type="email"
/>
</div>
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="password"
>
Password
</Label>
<Input
className="bg-muted text-md md:text-sm"
id="password"
name="password"
required
type="password"
/>
</div>
{children}
</Form>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo } from "react";
import { useWindowSize } from "usehooks-ts";
import { SidebarToggle } from "@/components/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { PlusIcon, VercelIcon } from "./icons";
import { useSidebar } from "./ui/sidebar";
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
function PureChatHeader({
chatId,
selectedVisibilityType,
isReadonly,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
}) {
const router = useRouter();
const { open } = useSidebar();
const { width: windowWidth } = useWindowSize();
return (
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
<SidebarToggle />
{(!open || windowWidth < 768) && (
<Button
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
onClick={() => {
router.push("/");
router.refresh();
}}
variant="outline"
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
</Button>
)}
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
className="order-1 md:order-2"
selectedVisibilityType={selectedVisibilityType}
/>
)}
<Button
asChild
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<Link
href={"https://vercel.com/templates/next.js/nextjs-ai-chatbot"}
rel="noreferrer"
target="_noblank"
>
<VercelIcon size={16} />
Deploy with Vercel
</Link>
</Button>
</header>
);
}
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
return (
prevProps.chatId === nextProps.chatId &&
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
prevProps.isReadonly === nextProps.isReadonly
);
});

View file

@ -0,0 +1,291 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { ChatHeader } from "@/components/chat-header";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { useAutoResume } from "@/hooks/use-auto-resume";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { Messages } from "./messages";
import { MultimodalInput } from "./multimodal-input";
import { getChatHistoryPaginationKey } from "./sidebar-history";
import { toast } from "./toast";
import type { VisibilityType } from "./visibility-selector";
export function Chat({
id,
initialMessages,
initialChatModel,
initialVisibilityType,
isReadonly,
autoResume,
}: {
id: string;
initialMessages: ChatMessage[];
initialChatModel: string;
initialVisibilityType: VisibilityType;
isReadonly: boolean;
autoResume: boolean;
}) {
const router = useRouter();
const { visibilityType } = useChatVisibility({
chatId: id,
initialVisibilityType,
});
const { mutate } = useSWRConfig();
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
// When user navigates back/forward, refresh to sync with URL
router.refresh();
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [router]);
const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>("");
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
const currentModelIdRef = useRef(currentModelId);
useEffect(() => {
currentModelIdRef.current = currentModelId;
}, [currentModelId]);
const {
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
resumeStream,
addToolApprovalResponse,
} = useChat<ChatMessage>({
id,
messages: initialMessages,
experimental_throttle: 100,
generateId: generateUUID,
// Auto-continue after tool approval (only for APPROVED tools)
// Denied tools don't need server continuation - state is saved on next user message
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
const lastMessage = currentMessages.at(-1);
// Only continue if a tool was APPROVED (not denied)
const shouldContinue =
lastMessage?.parts?.some(
(part) =>
"state" in part &&
part.state === "approval-responded" &&
"approval" in part &&
(part.approval as { approved?: boolean })?.approved === true
) ?? false;
return shouldContinue;
},
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
const lastMessage = request.messages.at(-1);
// Check if this is a tool approval continuation:
// - Last message is NOT a user message (meaning no new user input)
// - OR any message has tool parts that were responded to (approved or denied)
const isToolApprovalContinuation =
lastMessage?.role !== "user" ||
request.messages.some((msg) =>
msg.parts?.some((part) => {
const state = (part as { state?: string }).state;
return (
state === "approval-responded" || state === "output-denied"
);
})
);
return {
body: {
id: request.id,
// Send all messages for tool approval continuation, otherwise just the last user message
...(isToolApprovalContinuation
? { messages: request.messages }
: { message: lastMessage }),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: visibilityType,
...request.body,
},
};
},
}),
onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
},
onError: (error) => {
if (error instanceof ChatSDKError) {
// Check if it's a credit card error
if (
error.message?.includes("AI Gateway requires a valid credit card")
) {
setShowCreditCardAlert(true);
} else {
toast({
type: "error",
description: error.message,
});
}
}
},
});
const searchParams = useSearchParams();
const query = searchParams.get("query");
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
useEffect(() => {
if (query && !hasAppendedQuery) {
sendMessage({
role: "user" as const,
parts: [{ type: "text", text: query }],
});
setHasAppendedQuery(true);
window.history.replaceState({}, "", `/chat/${id}`);
}
}, [query, sendMessage, hasAppendedQuery, id]);
const { data: votes } = useSWR<Vote[]>(
messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
fetcher
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
useAutoResume({
autoResume,
initialMessages,
resumeStream,
setMessages,
});
return (
<>
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
isReadonly={isReadonly}
selectedVisibilityType={initialVisibilityType}
/>
<Messages
addToolApprovalResponse={addToolApprovalResponse}
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={initialChatModel}
setMessages={setMessages}
status={status}
votes={votes}
/>
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
{!isReadonly && (
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={messages}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</div>
</div>
<Artifact
addToolApprovalResponse={addToolApprovalResponse}
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
votes={votes}
/>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
This application requires{" "}
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
activate Vercel AI Gateway.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
window.open(
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
);
window.location.href = "/";
}}
>
Activate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { python } from "@codemirror/lang-python";
import { EditorState, Transaction } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
};
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
useEffect(() => {
if (containerRef.current && !editorRef.current) {
const startState = EditorState.create({
doc: content,
extensions: [basicSetup, python(), oneDark],
});
editorRef.current = new EditorView({
state: startState,
parent: containerRef.current,
});
}
return () => {
if (editorRef.current) {
editorRef.current.destroy();
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
if (editorRef.current) {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const transaction = update.transactions.find(
(tr) => !tr.annotation(Transaction.remote)
);
if (transaction) {
const newContent = update.state.doc.toString();
onSaveContent(newContent, true);
}
}
});
const currentSelection = editorRef.current.state.selection;
const newState = EditorState.create({
doc: editorRef.current.state.doc,
extensions: [basicSetup, python(), oneDark, updateListener],
selection: currentSelection,
});
editorRef.current.setState(newState);
}
}, [onSaveContent]);
useEffect(() => {
if (editorRef.current && content) {
const currentContent = editorRef.current.state.doc.toString();
if (status === "streaming" || currentContent !== content) {
const transaction = editorRef.current.state.update({
changes: {
from: 0,
to: currentContent.length,
insert: content,
},
annotations: [Transaction.remote.of(true)],
});
editorRef.current.dispatch(transaction);
}
}
}, [content, status]);
return (
<div
className="not-prose relative w-full pb-[calc(80dvh)] text-sm"
ref={containerRef}
/>
);
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
if (prevProps.suggestions !== nextProps.suggestions) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
return false;
}
if (prevProps.content !== nextProps.content) {
return false;
}
return true;
}
export const CodeEditor = memo(PureCodeEditor, areEqual);

View file

@ -0,0 +1,193 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { cn } from "@/lib/utils";
import { Loader } from "./elements/loader";
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
import { Button } from "./ui/button";
export type ConsoleOutputContent = {
type: "text" | "image";
value: string;
};
export type ConsoleOutput = {
id: string;
status: "in_progress" | "loading_packages" | "completed" | "failed";
contents: ConsoleOutputContent[];
};
type ConsoleProps = {
consoleOutputs: ConsoleOutput[];
setConsoleOutputs: Dispatch<SetStateAction<ConsoleOutput[]>>;
};
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
const [height, setHeight] = useState<number>(300);
const [isResizing, setIsResizing] = useState(false);
const consoleEndRef = useRef<HTMLDivElement>(null);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
const minHeight = 100;
const maxHeight = 800;
const startResizing = useCallback(() => {
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(e: MouseEvent) => {
if (isResizing) {
const newHeight = window.innerHeight - e.clientY;
if (newHeight >= minHeight && newHeight <= maxHeight) {
setHeight(newHeight);
}
}
},
[isResizing]
);
useEffect(() => {
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
useEffect(() => {
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
if (!isArtifactVisible) {
setConsoleOutputs([]);
}
}, [isArtifactVisible, setConsoleOutputs]);
return consoleOutputs.length > 0 ? (
<>
<div
aria-label="Resize console"
aria-orientation="horizontal"
aria-valuemax={maxHeight}
aria-valuemin={minHeight}
aria-valuenow={height}
className="fixed z-50 h-2 w-full cursor-ns-resize"
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
setHeight((prev) => Math.min(prev + 10, maxHeight));
} else if (e.key === "ArrowDown") {
setHeight((prev) => Math.max(prev - 10, minHeight));
}
}}
onMouseDown={startResizing}
role="slider"
style={{ bottom: height - 4 }}
tabIndex={0}
/>
<div
className={cn(
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
{
"select-none": isResizing,
}
)}
style={{ height }}
>
<div className="sticky top-0 z-50 flex h-fit w-full flex-row items-center justify-between border-zinc-200 border-b bg-muted px-2 py-1 dark:border-zinc-700">
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-zinc-800 dark:text-zinc-50">
<div className="text-muted-foreground">
<TerminalWindowIcon />
</div>
<div>Console</div>
</div>
<Button
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
onClick={() => setConsoleOutputs([])}
size="icon"
variant="ghost"
>
<CrossSmallIcon />
</Button>
</div>
<div>
{consoleOutputs.map((consoleOutput, index) => (
<div
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
key={consoleOutput.id}
>
<div
className={cn("w-12 shrink-0", {
"text-muted-foreground": [
"in_progress",
"loading_packages",
].includes(consoleOutput.status),
"text-emerald-500": consoleOutput.status === "completed",
"text-red-400": consoleOutput.status === "failed",
})}
>
[{index + 1}]
</div>
{["in_progress", "loading_packages"].includes(
consoleOutput.status
) ? (
<div className="flex flex-row gap-2">
<div className="mt-0.5 mb-auto size-fit self-center">
<Loader size={16} />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === "in_progress"
? "Initializing..."
: consoleOutput.status === "loading_packages"
? consoleOutput.contents.map((content) =>
content.type === "text" ? content.value : null
)
: null}
</div>
</div>
) : (
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
{consoleOutput.contents.map((content, contentIndex) =>
content.type === "image" ? (
<picture key={`${consoleOutput.id}-${contentIndex}`}>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
alt="output"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
src={content.value}
/>
</picture>
) : (
<div
className="w-full whitespace-pre-line break-words"
key={`${consoleOutput.id}-${contentIndex}`}
>
{content.value}
</div>
)
)}
</div>
)}
</div>
))}
<div ref={consoleEndRef} />
</div>
</div>
</>
) : null;
}

View file

@ -0,0 +1,93 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import type { DataUIPart } from "ai";
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react";
import type { Suggestion } from "@/lib/db/schema";
import type { ChatMessage, CustomUIDataTypes } from "@/lib/types";
import type { UIArtifact } from "./artifact";
export type ArtifactActionContext<M = any> = {
content: string;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
type ArtifactAction<M = any> = {
icon: ReactNode;
label?: string;
description: string;
onClick: (context: ArtifactActionContext<M>) => Promise<void> | void;
isDisabled?: (context: ArtifactActionContext<M>) => boolean;
};
export type ArtifactToolbarContext = {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
};
export type ArtifactToolbarItem = {
description: string;
icon: ReactNode;
onClick: (context: ArtifactToolbarContext) => void;
};
type ArtifactContent<M = any> = {
title: string;
content: string;
mode: "edit" | "diff";
isCurrentVersion: boolean;
currentVersionIndex: number;
status: "streaming" | "idle";
suggestions: Suggestion[];
onSaveContent: (updatedContent: string, debounce: boolean) => void;
isInline: boolean;
getDocumentContentById: (index: number) => string;
isLoading: boolean;
metadata: M;
setMetadata: Dispatch<SetStateAction<M>>;
};
type InitializeParameters<M = any> = {
documentId: string;
setMetadata: Dispatch<SetStateAction<M>>;
};
type ArtifactConfig<T extends string, M = any> = {
kind: T;
description: string;
content: ComponentType<ArtifactContent<M>>;
actions: ArtifactAction<M>[];
toolbar: ArtifactToolbarItem[];
initialize?: (parameters: InitializeParameters<M>) => void;
onStreamPart: (args: {
setMetadata: Dispatch<SetStateAction<M>>;
setArtifact: Dispatch<SetStateAction<UIArtifact>>;
streamPart: DataUIPart<CustomUIDataTypes>;
}) => void;
};
export class Artifact<T extends string, M = any> {
readonly kind: T;
readonly description: string;
readonly content: ComponentType<ArtifactContent<M>>;
readonly actions: ArtifactAction<M>[];
readonly toolbar: ArtifactToolbarItem[];
readonly initialize?: (parameters: InitializeParameters) => void;
readonly onStreamPart: (args: {
setMetadata: Dispatch<SetStateAction<M>>;
setArtifact: Dispatch<SetStateAction<UIArtifact>>;
streamPart: DataUIPart<CustomUIDataTypes>;
}) => void;
constructor(config: ArtifactConfig<T, M>) {
this.kind = config.kind;
this.description = config.description;
this.content = config.content;
this.actions = config.actions || [];
this.toolbar = config.toolbar || [];
this.initialize = config.initialize || (async () => ({}));
this.onStreamPart = config.onStreamPart;
}
}

View file

@ -0,0 +1,191 @@
"use client";
import { format } from "date-fns";
import { ArrowRight, TrendingUp } from "lucide-react";
const CurrencyIcon = ({ currency }: { currency: string }) => {
const symbols: Record<string, string> = {
USD: "$",
EUR: "€",
GBP: "£",
JPY: "¥",
CHF: "₣",
CAD: "$",
AUD: "$",
CNY: "¥",
INR: "₹",
BRL: "R$",
KRW: "₩",
MXN: "$",
RUB: "₽",
ZAR: "R",
TRY: "₺",
SEK: "kr",
NOK: "kr",
DKK: "kr",
PLN: "zł",
};
return (
<div className="flex size-10 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 font-bold text-lg text-white shadow-md">
{symbols[currency] || currency.substring(0, 1)}
</div>
);
};
type CurrencyExchangeData = {
base: string;
date: string;
amount: number;
rates: Record<string, number>;
convertedRates: Record<string, number>;
};
const SAMPLE: CurrencyExchangeData = {
base: "USD",
date: "2026-01-08",
amount: 100,
rates: {
EUR: 0.8565,
GBP: 0.7874,
JPY: 111.5,
CHF: 0.8542,
CAD: 1.3456,
},
convertedRates: {
EUR: 85.65,
GBP: 78.74,
JPY: 11150,
CHF: 85.42,
CAD: 134.56,
},
};
function formatCurrencyValue(value: number): string {
if (value >= 1000) {
return value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
return value.toFixed(2);
}
export function CurrencyExchange({
exchangeData = SAMPLE,
}: {
exchangeData?: CurrencyExchangeData;
}) {
const currencies = Object.keys(exchangeData.rates).slice(0, 6);
const isSingleConversion = currencies.length === 1;
return (
<div className="relative flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 p-4 shadow-lg dark:from-emerald-950/40 dark:via-teal-950/40 dark:to-cyan-950/40">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-teal-500/5" />
<div className="relative z-10">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="size-4 text-emerald-600 dark:text-emerald-400" />
<div className="font-semibold text-emerald-900 text-sm dark:text-emerald-100">
Currency Exchange
</div>
</div>
<div className="text-emerald-700 text-xs dark:text-emerald-300">
{format(new Date(exchangeData.date), "MMM d, yyyy")}
</div>
</div>
{isSingleConversion ? (
<div className="rounded-xl bg-white/80 p-4 shadow-sm backdrop-blur-sm dark:bg-gray-900/80">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<CurrencyIcon currency={exchangeData.base} />
<div>
<div className="font-semibold text-gray-900 text-sm dark:text-gray-100">
{exchangeData.base}
</div>
<div className="font-light text-2xl text-gray-900 dark:text-gray-100">
{formatCurrencyValue(exchangeData.amount)}
</div>
</div>
</div>
<ArrowRight className="size-5 text-emerald-600 dark:text-emerald-400" />
<div className="flex items-center gap-3">
<CurrencyIcon currency={currencies[0]} />
<div className="text-right">
<div className="font-semibold text-gray-900 text-sm dark:text-gray-100">
{currencies[0]}
</div>
<div className="font-light text-2xl text-emerald-600 dark:text-emerald-400">
{formatCurrencyValue(
exchangeData.convertedRates[currencies[0]]
)}
</div>
</div>
</div>
</div>
<div className="border-t pt-3 text-center text-gray-600 text-xs dark:text-gray-400">
Exchange Rate: 1 {exchangeData.base} ={" "}
{exchangeData.rates[currencies[0]].toFixed(4)} {currencies[0]}
</div>
</div>
) : (
<>
<div className="mb-3 flex items-center gap-3">
<CurrencyIcon currency={exchangeData.base} />
<div>
<div className="font-medium text-gray-700 text-xs dark:text-gray-300">
Base Currency
</div>
<div className="font-light text-2xl text-gray-900 dark:text-gray-100">
{formatCurrencyValue(exchangeData.amount)}{" "}
<span className="font-semibold text-lg">
{exchangeData.base}
</span>
</div>
</div>
</div>
<div className="rounded-xl bg-white/80 p-3 backdrop-blur-sm dark:bg-gray-900/80">
<div className="mb-2 font-medium text-gray-700 text-xs dark:text-gray-300">
Exchange Rates
</div>
<div className="grid gap-2 sm:grid-cols-2">
{currencies.map((currency) => (
<div
className="flex items-center justify-between rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50 p-2.5 dark:from-emerald-950/50 dark:to-teal-950/50"
key={currency}
>
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 font-semibold text-white text-xs">
{currency.substring(0, 1)}
</div>
<span className="font-medium text-gray-900 text-sm dark:text-gray-100">
{currency}
</span>
</div>
<div className="text-right">
<div className="font-semibold text-emerald-600 text-sm dark:text-emerald-400">
{formatCurrencyValue(
exchangeData.convertedRates[currency]
)}
</div>
<div className="text-gray-500 text-xs dark:text-gray-400">
{exchangeData.rates[currency].toFixed(4)}
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
<div className="mt-2 text-center text-emerald-700 text-xs dark:text-emerald-300">
Powered by Frankfurter API
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,92 @@
"use client";
import { useEffect } from "react";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { getChatHistoryPaginationKey } from "./sidebar-history";
export function DataStreamHandler() {
const { dataStream, setDataStream } = useDataStream();
const { mutate } = useSWRConfig();
const { artifact, setArtifact, setMetadata } = useArtifact();
useEffect(() => {
if (!dataStream?.length) {
return;
}
const newDeltas = dataStream.slice();
setDataStream([]);
for (const delta of newDeltas) {
// Handle chat title updates
if (delta.type === "data-chat-title") {
mutate(unstable_serialize(getChatHistoryPaginationKey));
continue;
}
const artifactDefinition = artifactDefinitions.find(
(currentArtifactDefinition) =>
currentArtifactDefinition.kind === artifact.kind
);
if (artifactDefinition?.onStreamPart) {
artifactDefinition.onStreamPart({
streamPart: delta,
setArtifact,
setMetadata,
});
}
setArtifact((draftArtifact) => {
if (!draftArtifact) {
return { ...initialArtifactData, status: "streaming" };
}
switch (delta.type) {
case "data-id":
return {
...draftArtifact,
documentId: delta.data,
status: "streaming",
};
case "data-title":
return {
...draftArtifact,
title: delta.data,
status: "streaming",
};
case "data-kind":
return {
...draftArtifact,
kind: delta.data,
status: "streaming",
};
case "data-clear":
return {
...draftArtifact,
content: "",
status: "streaming",
};
case "data-finish":
return {
...draftArtifact,
status: "idle",
};
default:
return draftArtifact;
}
});
}
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream, mutate]);
return null;
}

View file

@ -0,0 +1,41 @@
"use client";
import type { DataUIPart } from "ai";
import type React from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { CustomUIDataTypes } from "@/lib/types";
type DataStreamContextValue = {
dataStream: DataUIPart<CustomUIDataTypes>[];
setDataStream: React.Dispatch<
React.SetStateAction<DataUIPart<CustomUIDataTypes>[]>
>;
};
const DataStreamContext = createContext<DataStreamContextValue | null>(null);
export function DataStreamProvider({
children,
}: {
children: React.ReactNode;
}) {
const [dataStream, setDataStream] = useState<DataUIPart<CustomUIDataTypes>[]>(
[]
);
const value = useMemo(() => ({ dataStream, setDataStream }), [dataStream]);
return (
<DataStreamContext.Provider value={value}>
{children}
</DataStreamContext.Provider>
);
}
export function useDataStream() {
const context = useContext(DataStreamContext);
if (!context) {
throw new Error("useDataStream must be used within a DataStreamProvider");
}
return context;
}

View file

@ -0,0 +1,100 @@
import OrderedMap from "orderedmap";
import {
DOMParser,
type MarkSpec,
type Node as ProsemirrorNode,
Schema,
} from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useEffect, useRef } from "react";
import { renderToString } from "react-dom/server";
import { Streamdown } from "streamdown";
import { DiffType, diffEditor } from "@/lib/editor/diff";
const diffSchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: OrderedMap.from({
...schema.spec.marks.toObject(),
diffMark: {
attrs: { type: { default: "" } },
toDOM(mark) {
let className = "";
switch (mark.attrs.type) {
case DiffType.Inserted:
className =
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
break;
case DiffType.Deleted:
className =
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
break;
default:
className = "";
}
return ["span", { class: className }, 0];
},
} as MarkSpec,
}),
});
function computeDiff(oldDoc: ProsemirrorNode, newDoc: ProsemirrorNode) {
return diffEditor(diffSchema, oldDoc.toJSON(), newDoc.toJSON());
}
type DiffEditorProps = {
oldContent: string;
newContent: string;
};
export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (editorRef.current && !viewRef.current) {
const parser = DOMParser.fromSchema(diffSchema);
const oldHtmlContent = renderToString(
<Streamdown>{oldContent}</Streamdown>
);
const newHtmlContent = renderToString(
<Streamdown>{newContent}</Streamdown>
);
const oldContainer = document.createElement("div");
oldContainer.innerHTML = oldHtmlContent;
const newContainer = document.createElement("div");
newContainer.innerHTML = newHtmlContent;
const oldDoc = parser.parse(oldContainer);
const newDoc = parser.parse(newContainer);
const diffedDoc = computeDiff(oldDoc, newDoc);
const state = EditorState.create({
doc: diffedDoc,
plugins: [],
});
viewRef.current = new EditorView(editorRef.current, {
state,
editable: () => false,
});
}
return () => {
if (viewRef.current) {
viewRef.current.destroy();
viewRef.current = null;
}
};
}, [oldContent, newContent]);
return <div className="diff-editor" ref={editorRef} />;
};

View file

@ -0,0 +1,295 @@
"use client";
import equal from "fast-deep-equal";
import {
type MouseEvent,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import useSWR from "swr";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { cn, fetcher } from "@/lib/utils";
import type { ArtifactKind, UIArtifact } from "./artifact";
import { CodeEditor } from "./code-editor";
import { DocumentToolCall, DocumentToolResult } from "./document";
import { InlineDocumentSkeleton } from "./document-skeleton";
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
import { ImageEditor } from "./image-editor";
import { SpreadsheetEditor } from "./sheet-editor";
import { Editor } from "./text-editor";
type DocumentPreviewProps = {
isReadonly: boolean;
result?: any;
args?: any;
};
export function DocumentPreview({
isReadonly,
result,
args,
}: DocumentPreviewProps) {
const { artifact, setArtifact } = useArtifact();
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
Document[]
>(result ? `/api/document?id=${result.id}` : null, fetcher);
const previewDocument = useMemo(() => documents?.[0], [documents]);
const hitboxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const boundingBox = hitboxRef.current?.getBoundingClientRect();
if (artifact.documentId && boundingBox) {
setArtifact((currentArtifact) => ({
...currentArtifact,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}));
}
}, [artifact.documentId, setArtifact]);
if (artifact.isVisible) {
if (result) {
return (
<DocumentToolResult
isReadonly={isReadonly}
result={{ id: result.id, title: result.title, kind: result.kind }}
type="create"
/>
);
}
if (args) {
return (
<DocumentToolCall
args={{ title: args.title, kind: args.kind }}
isReadonly={isReadonly}
type="create"
/>
);
}
}
if (isDocumentsFetching) {
return <LoadingSkeleton artifactKind={result.kind ?? args.kind} />;
}
const document: Document | null = previewDocument
? previewDocument
: artifact.status === "streaming"
? {
title: artifact.title,
kind: artifact.kind,
content: artifact.content,
id: artifact.documentId,
createdAt: new Date(),
userId: "noop",
}
: null;
if (!document) {
return <LoadingSkeleton artifactKind={artifact.kind} />;
}
return (
<div className="relative w-full max-w-[450px] cursor-pointer">
<HitboxLayer
hitboxRef={hitboxRef}
result={result}
setArtifact={setArtifact}
/>
<DocumentHeader
isStreaming={artifact.status === "streaming"}
kind={document.kind}
title={document.title}
/>
<DocumentContent document={document} />
</div>
);
}
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
<div className="w-full max-w-[450px]">
<div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-center gap-3">
<div className="text-muted-foreground">
<div className="size-4 animate-pulse rounded-md bg-muted-foreground/20" />
</div>
<div className="h-4 w-24 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
<div>
<FullscreenIcon />
</div>
</div>
{artifactKind === "image" ? (
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
</div>
) : (
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted p-8 pt-4 dark:border-zinc-700">
<InlineDocumentSkeleton />
</div>
)}
</div>
);
const PureHitboxLayer = ({
hitboxRef,
result,
setArtifact,
}: {
hitboxRef: React.RefObject<HTMLDivElement>;
result: any;
setArtifact: (
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
) => void;
}) => {
const handleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
const boundingBox = event.currentTarget.getBoundingClientRect();
setArtifact((artifact) =>
artifact.status === "streaming"
? { ...artifact, isVisible: true }
: {
...artifact,
title: result.title,
documentId: result.id,
kind: result.kind,
isVisible: true,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}
);
},
[setArtifact, result]
);
return (
<div
aria-hidden="true"
className="absolute top-0 left-0 z-10 size-full rounded-xl"
onClick={handleClick}
ref={hitboxRef}
role="presentation"
>
<div className="flex w-full items-center justify-end p-4">
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<FullscreenIcon />
</div>
</div>
</div>
);
};
const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
if (!equal(prevProps.result, nextProps.result)) {
return false;
}
return true;
});
const PureDocumentHeader = ({
title,
kind,
isStreaming,
}: {
title: string;
kind: ArtifactKind;
isStreaming: boolean;
}) => (
<div className="flex flex-row items-start justify-between gap-2 rounded-t-2xl border border-b-0 p-4 sm:items-center dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-start gap-3 sm:items-center">
<div className="text-muted-foreground">
{isStreaming ? (
<div className="animate-spin">
<LoaderIcon />
</div>
) : kind === "image" ? (
<ImageIcon />
) : (
<FileIcon />
)}
</div>
<div className="-translate-y-1 font-medium sm:translate-y-0">{title}</div>
</div>
<div className="w-8" />
</div>
);
const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) {
return false;
}
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false;
}
return true;
});
const DocumentContent = ({ document }: { document: Document }) => {
const { artifact } = useArtifact();
const containerClassName = cn(
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
{
"p-4 sm:px-14 sm:py-16": document.kind === "text",
"p-0": document.kind === "code",
}
);
const commonProps = {
content: document.content ?? "",
isCurrentVersion: true,
currentVersionIndex: 0,
status: artifact.status,
saveContent: () => null,
suggestions: [],
};
const handleSaveContent = () => null;
return (
<div className={containerClassName}>
{document.kind === "text" ? (
<Editor {...commonProps} onSaveContent={handleSaveContent} />
) : document.kind === "code" ? (
<div className="relative flex w-full flex-1">
<div className="absolute inset-0">
<CodeEditor {...commonProps} onSaveContent={handleSaveContent} />
</div>
</div>
) : document.kind === "sheet" ? (
<div className="relative flex size-full flex-1 p-4">
<div className="absolute inset-0">
<SpreadsheetEditor {...commonProps} />
</div>
</div>
) : document.kind === "image" ? (
<ImageEditor
content={document.content ?? ""}
currentVersionIndex={0}
isCurrentVersion={true}
isInline={true}
status={artifact.status}
title={document.title}
/>
) : null}
</div>
);
};

View file

@ -0,0 +1,39 @@
"use client";
import type { ArtifactKind } from "./artifact";
export const DocumentSkeleton = ({
artifactKind,
}: {
artifactKind: ArtifactKind;
}) => {
return artifactKind === "image" ? (
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="h-12 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-1/3 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-52 animate-pulse rounded-lg bg-transparent" />
<div className="h-8 w-52 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-2/3 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
);
};
export const InlineDocumentSkeleton = () => {
return (
<div className="flex w-full flex-col gap-4">
<div className="h-4 w-48 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-3/4 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-40 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-36 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
);
};

View file

@ -0,0 +1,161 @@
import { memo } from "react";
import { toast } from "sonner";
import { useArtifact } from "@/hooks/use-artifact";
import type { ArtifactKind } from "./artifact";
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from "./icons";
const getActionText = (
type: "create" | "update" | "request-suggestions",
tense: "present" | "past"
) => {
switch (type) {
case "create":
return tense === "present" ? "Creating" : "Created";
case "update":
return tense === "present" ? "Updating" : "Updated";
case "request-suggestions":
return tense === "present"
? "Adding suggestions"
: "Added suggestions to";
default:
return null;
}
};
type DocumentToolResultProps = {
type: "create" | "update" | "request-suggestions";
result: { id: string; title: string; kind: ArtifactKind };
isReadonly: boolean;
};
function PureDocumentToolResult({
type,
result,
isReadonly,
}: DocumentToolResultProps) {
const { setArtifact } = useArtifact();
return (
<button
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
"Viewing files in shared chats is currently not supported."
);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const boundingBox = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
setArtifact((currentArtifact) => ({
documentId: result.id,
kind: result.kind,
content: currentArtifact.content,
title: result.title,
isVisible: true,
status: "idle",
boundingBox,
}));
}}
type="button"
>
<div className="mt-1 text-muted-foreground">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (
<PencilEditIcon />
) : type === "request-suggestions" ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, "past")} "${result.title}"`}
</div>
</button>
);
}
export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
type DocumentToolCallProps = {
type: "create" | "update" | "request-suggestions";
args:
| { title: string; kind: ArtifactKind } // for create
| { id: string; description: string } // for update
| { documentId: string }; // for request-suggestions
isReadonly: boolean;
};
function PureDocumentToolCall({
type,
args,
isReadonly,
}: DocumentToolCallProps) {
const { setArtifact } = useArtifact();
return (
<button
className="cursor pointer flex w-fit flex-row items-start justify-between gap-3 rounded-xl border px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
"Viewing files in shared chats is currently not supported."
);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const boundingBox = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
setArtifact((currentArtifact) => ({
...currentArtifact,
isVisible: true,
boundingBox,
}));
}}
type="button"
>
<div className="flex flex-row items-start gap-3">
<div className="mt-1 text-zinc-500">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (
<PencilEditIcon />
) : type === "request-suggestions" ? (
<MessageIcon />
) : null}
</div>
<div className="text-left">
{`${getActionText(type, "present")} ${
type === "create" && "title" in args && args.title
? `"${args.title}"`
: type === "update" && "description" in args
? `"${args.description}"`
: type === "request-suggestions"
? "for document"
: ""
}`}
</div>
</div>
<div className="mt-1 animate-spin">{<LoaderIcon />}</div>
</button>
);
}
export const DocumentToolCall = memo(PureDocumentToolCall, () => true);

View file

@ -0,0 +1,65 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type ActionsProps = ComponentProps<"div">;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View file

@ -0,0 +1,215 @@
"use client";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type BranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const BranchContext = createContext<BranchContextType | null>(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<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
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 (
<BranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? 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
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
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 (
<div
className={cn(
"flex items-center gap-2 self-end px-10",
from === "assistant" ? "justify-start" : "justify-end",
className
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
"font-medium text-muted-foreground text-xs tabular-nums",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View file

@ -0,0 +1,154 @@
"use client";
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";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: "font-mono text-sm",
}}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
overflowX: "auto",
overflowWrap: "break-word",
wordBreak: "break-all",
}}
language={language}
lineNumberStyle={{
color: "hsl(var(--muted-foreground))",
paddingRight: "1rem",
minWidth: "2.5rem",
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
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 (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View file

@ -0,0 +1,65 @@
"use client";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn(
"relative flex-1 touch-pan-y overflow-y-auto will-change-scroll",
className
)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content className={cn("p-4", className)} {...props} />
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View file

@ -0,0 +1,26 @@
import type { Experimental_GeneratedImage } from "ai";
import { cn } from "@/lib/utils";
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
// biome-ignore lint/nursery/useImageSize: "Generated image without explicit size"
// biome-ignore lint/performance/noImgElement: "Generated image without explicit size"
<img
{...props}
alt={props.alt}
className={cn(
"h-auto max-w-full overflow-hidden rounded-md",
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View file

@ -0,0 +1,287 @@
"use client";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
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";
export type InlineCitationProps = ComponentProps<"span">;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn("ml-1 rounded-full", className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
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 (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn("space-y-1", className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>
{children}
</blockquote>
);

View file

@ -0,0 +1,96 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
"inline-flex animate-spin items-center justify-center",
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View file

@ -0,0 +1,58 @@
import type { UIMessage } from "ai";
import type { ComponentProps, HTMLAttributes } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full items-end justify-end gap-2 py-4",
from === "user" ? "is-user" : "is-assistant flex-row-reverse justify-end",
"[&>div]:max-w-[80%]",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm",
"group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground",
"group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
"is-user:dark",
className
)}
{...props}
>
{children}
</div>
);
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
src: string;
name?: string;
};
export const MessageAvatar = ({
src,
name,
className,
...props
}: MessageAvatarProps) => (
<Avatar className={cn("size-8 ring-1 ring-border", className)} {...props}>
<AvatarImage alt="" className="my-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || "ME"}</AvatarFallback>
</Avatar>
);

View file

@ -0,0 +1,240 @@
"use client";
import type { ChatStatus } from "ai";
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from "lucide-react";
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from "react";
import { Children } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
"w-full overflow-hidden rounded-xl border bg-background shadow-xs",
className
)}
{...props}
/>
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
minHeight?: number;
maxHeight?: number;
disableAutoResize?: boolean;
resizeOnNewLinesOnly?: boolean;
};
export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
minHeight = 48,
maxHeight = 164,
disableAutoResize = false,
resizeOnNewLinesOnly = false,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
// Allow newline
return;
}
// Submit on Enter (without Shift)
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
}
}
};
return (
<Textarea
className={cn(
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0",
disableAutoResize
? "field-sizing-fixed"
: resizeOnNewLinesOnly
? "field-sizing-fixed"
: "field-sizing-content max-h-[6lh]",
"bg-transparent dark:bg-transparent",
"focus-visible:ring-0",
className
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
className={cn("flex items-center justify-between p-1", className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn(
"flex items-center gap-1",
"[&_button:first-child]:rounded-bl-xl",
className
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = "ghost",
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? "default" : "icon";
return (
<Button
className={cn(
"shrink-0 gap-1.5 rounded-lg",
variant === "ghost" && "text-muted-foreground",
newSize === "default" && "px-3",
className
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = "default",
size = "icon",
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === "submitted") {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn("gap-1.5 rounded-lg", className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
"h-auto px-2 py-1.5",
className
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);

View file

@ -0,0 +1,175 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { Response } from "./response";
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(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<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 500;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
});
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosedRef(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-3" />
{isStreaming || duration === 0 ? (
<span>Thinking</span>
) : (
<span>{duration}s</span>
)}
<ChevronDownIcon
className={cn(
"size-2.5 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-1.5 text-[11px] text-muted-foreground leading-relaxed",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="max-h-48 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2.5">
<Response className="grid gap-1 text-[11px] **:text-[11px] [&_li]:my-0 [&_ol]:my-1 [&_p]:my-0 [&_ul]:my-1">
{children}
</Response>
</div>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View file

@ -0,0 +1,22 @@
"use client";
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = "Response";

View file

@ -0,0 +1,74 @@
"use client";
import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger className="flex items-center gap-2" {...props}>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="size-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="size-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);

View file

@ -0,0 +1,53 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "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 (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View file

@ -0,0 +1,94 @@
"use client";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
defaultOpen={defaultOpen}
{...props}
/>
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View file

@ -0,0 +1,153 @@
"use client";
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart["type"];
state: ToolUIPart["state"];
className?: string;
};
const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels: Record<ToolUIPart["state"], string> = {
"input-streaming": "Pending",
"input-available": "Running",
"approval-requested": "Pending",
"approval-responded": "Approved",
"output-available": "Completed",
"output-error": "Error",
"output-denied": "Denied",
};
const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge
className="flex items-center gap-1 rounded-full text-xs"
variant="secondary"
>
{icons[status]}
<span>{labels[status]}</span>
</Badge>
);
};
export const ToolHeader = ({
className,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
"flex w-full min-w-0 items-center justify-between gap-2 p-3",
className
)}
{...props}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-sm">{type}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{getStatusBadge(state)}
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ReactNode;
errorText: ToolUIPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{output && <div>{output}</div>}
</div>
</div>
);
};

View file

@ -0,0 +1,252 @@
"use client";
import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
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";
export type WebPreviewContextValue = {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
};
const WebPreviewContext = createContext<WebPreviewContextValue | null>(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 (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
"flex size-full flex-col rounded-lg border bg-card",
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn("flex items-center gap-1 border-b p-2", className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? url}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
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 (
<Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground"
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{" "}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -0,0 +1,29 @@
import { motion } from "framer-motion";
export const Greeting = () => {
return (
<div
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
key="overview"
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="font-semibold text-xl md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5 }}
>
Hello there!
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="text-xl text-zinc-500 md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }}
>
How can I help you today?
</motion.div>
</div>
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
import cn from "classnames";
import { LoaderIcon } from "./icons";
type ImageEditorProps = {
title: string;
content: string;
isCurrentVersion: boolean;
currentVersionIndex: number;
status: string;
isInline: boolean;
};
export function ImageEditor({
title,
content,
status,
isInline,
}: ImageEditorProps) {
return (
<div
className={cn("flex w-full flex-row items-center justify-center", {
"h-[calc(100dvh-60px)]": !isInline,
"h-[200px]": isInline,
})}
>
{status === "streaming" ? (
<div className="flex flex-row items-center gap-4">
{!isInline && (
<div className="animate-spin">
<LoaderIcon />
</div>
)}
<div>Generating Image...</div>
</div>
) : (
<picture>
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
<img
alt={title}
className={cn("h-fit w-full max-w-[800px]", {
"p-0 md:p-20": !isInline,
})}
src={`data:image/png;base64,${content}`}
/>
</picture>
)}
</div>
);
}

View file

@ -0,0 +1,189 @@
import equal from "fast-deep-equal";
import { memo } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { useCopyToClipboard } from "usehooks-ts";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { Action, Actions } from "./elements/actions";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
export function PureMessageActions({
chatId,
message,
vote,
isLoading,
setMode,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMode?: (mode: "view" | "edit") => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) {
return null;
}
const textFromParts = message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
const handleCopy = async () => {
if (!textFromParts) {
toast.error("There's no text to copy!");
return;
}
await copyToClipboard(textFromParts);
toast.success("Copied to clipboard!");
};
// User messages get edit (on hover) and copy actions
if (message.role === "user") {
return (
<Actions className="-mr-0.5 justify-end">
<div className="relative">
{setMode && (
<Action
className="-left-10 absolute top-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
data-testid="message-edit-button"
onClick={() => setMode("edit")}
tooltip="Edit"
>
<PencilEditIcon />
</Action>
)}
<Action onClick={handleCopy} tooltip="Copy">
<CopyIcon />
</Action>
</div>
</Actions>
);
}
return (
<Actions className="-ml-0.5">
<Action onClick={handleCopy} tooltip="Copy">
<CopyIcon />
</Action>
<Action
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
const upvote = fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: "up",
}),
});
toast.promise(upvote, {
loading: "Upvoting Response...",
success: () => {
mutate<Vote[]>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: true,
},
];
},
{ revalidate: false }
);
return "Upvoted Response!";
},
error: "Failed to upvote response.",
});
}}
tooltip="Upvote Response"
>
<ThumbUpIcon />
</Action>
<Action
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={() => {
const downvote = fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: "down",
}),
});
toast.promise(downvote, {
loading: "Downvoting Response...",
success: () => {
mutate<Vote[]>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: false,
},
];
},
{ revalidate: false }
);
return "Downvoted Response!";
},
error: "Failed to downvote response.",
});
}}
tooltip="Downvote Response"
>
<ThumbDownIcon />
</Action>
</Actions>
);
}
export const MessageActions = memo(
PureMessageActions,
(prevProps, nextProps) => {
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
return true;
}
);

View file

@ -0,0 +1,112 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { deleteTrailingMessages } from "@/app/(chat)/actions";
import type { ChatMessage } from "@/lib/types";
import { getTextFromMessage } from "@/lib/utils";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
export type MessageEditorProps = {
message: ChatMessage;
setMode: Dispatch<SetStateAction<"view" | "edit">>;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
};
export function MessageEditor({
message,
setMode,
setMessages,
regenerate,
}: MessageEditorProps) {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [draftContent, setDraftContent] = useState<string>(
getTextFromMessage(message)
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraftContent(event.target.value);
adjustHeight();
};
return (
<div className="flex w-full flex-col gap-2">
<Textarea
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
data-testid="message-editor"
onChange={handleInput}
ref={textareaRef}
value={draftContent}
/>
<div className="flex flex-row justify-end gap-2">
<Button
className="h-fit px-3 py-2"
onClick={() => {
setMode("view");
}}
variant="outline"
>
Cancel
</Button>
<Button
className="h-fit px-3 py-2"
data-testid="message-editor-send-button"
disabled={isSubmitting}
onClick={async () => {
setIsSubmitting(true);
await deleteTrailingMessages({
id: message.id,
});
setMessages((messages) => {
const index = messages.findIndex((m) => m.id === message.id);
if (index !== -1) {
const updatedMessage: ChatMessage = {
...message,
parts: [{ type: "text", text: draftContent }],
};
return [...messages.slice(0, index), updatedMessage];
}
return messages;
});
setMode("view");
regenerate();
}}
variant="default"
>
{isSubmitting ? "Sending..." : "Send"}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "./elements/reasoning";
type MessageReasoningProps = {
isLoading: boolean;
reasoning: string;
};
export function MessageReasoning({
isLoading,
reasoning,
}: MessageReasoningProps) {
const [hasBeenStreaming, setHasBeenStreaming] = useState(isLoading);
useEffect(() => {
if (isLoading) {
setHasBeenStreaming(true);
}
}, [isLoading]);
return (
<Reasoning
data-testid="message-reasoning"
defaultOpen={hasBeenStreaming}
isStreaming={isLoading}
>
<ReasoningTrigger />
<ReasoningContent>{reasoning}</ReasoningContent>
</Reasoning>
);
}

View file

@ -0,0 +1,419 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { memo, useState } from "react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { useDataStream } from "./data-stream-provider";
import { MessageContent } from "./elements/message";
import { Response } from "./elements/response";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
} from "./elements/tool";
import { SparklesIcon } from "./icons";
import { MessageActions } from "./message-actions";
import { MessageEditor } from "./message-editor";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
import { CurrencyExchange } from "./currency-exchange";
const PurePreviewMessage = ({
addToolApprovalResponse,
chatId,
message,
vote,
isLoading,
setMessages,
regenerate,
isReadonly,
requiresScrollPadding: _requiresScrollPadding,
}: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
requiresScrollPadding: boolean;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
const attachmentsFromMessage = message.parts.filter(
(part) => part.type === "file"
);
useDataStream();
return (
<div
className="group/message fade-in w-full animate-in duration-200"
data-role={message.role}
data-testid={`message-${message.role}`}
>
<div
className={cn("flex w-full items-start gap-2 md:gap-3", {
"justify-end": message.role === "user" && mode !== "edit",
"justify-start": message.role === "assistant",
})}
>
{message.role === "assistant" && (
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} />
</div>
)}
<div
className={cn("flex flex-col", {
"gap-2 md:gap-4": message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
),
"w-full":
(message.role === "assistant" &&
(message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
) ||
message.parts?.some((p) => p.type.startsWith("tool-")))) ||
mode === "edit",
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
message.role === "user" && mode !== "edit",
})}
>
{attachmentsFromMessage.length > 0 && (
<div
className="flex flex-row justify-end gap-2"
data-testid={"message-attachments"}
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
attachment={{
name: attachment.filename ?? "file",
contentType: attachment.mediaType,
url: attachment.url,
}}
key={attachment.url}
/>
))}
</div>
)}
{message.parts?.map((part, index) => {
const { type } = part;
const key = `message-${message.id}-part-${index}`;
if (type === "reasoning" && part.text?.trim().length > 0) {
return (
<MessageReasoning
isLoading={isLoading}
key={key}
reasoning={part.text}
/>
);
}
if (type === "text") {
if (mode === "view") {
return (
<div key={key}>
<MessageContent
className={cn({
"wrap-break-word w-fit rounded-2xl px-3 py-2 text-right text-white":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
})}
data-testid="message-content"
style={
message.role === "user"
? { backgroundColor: "#006cff" }
: undefined
}
>
<Response>{sanitizeText(part.text)}</Response>
</MessageContent>
</div>
);
}
if (mode === "edit") {
return (
<div
className="flex w-full flex-row items-start gap-3"
key={key}
>
<div className="size-8" />
<div className="min-w-0 flex-1">
<MessageEditor
key={message.id}
message={message}
regenerate={regenerate}
setMessages={setMessages}
setMode={setMode}
/>
</div>
</div>
);
}
}
if (type === "tool-getWeather") {
const { toolCallId, state } = part;
const approvalId = (part as { approval?: { id: string } })
.approval?.id;
const isDenied =
state === "output-denied" ||
(state === "approval-responded" &&
(part as { approval?: { approved?: boolean } }).approval
?.approved === false);
const widthClass = "w-[min(100%,450px)]";
if (state === "output-available") {
return (
<div className={widthClass} key={toolCallId}>
<Weather weatherAtLocation={part.output} />
</div>
);
}
if (isDenied) {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader
state="output-denied"
type="tool-getWeather"
/>
<ToolContent>
<div className="px-4 py-3 text-muted-foreground text-sm">
Weather lookup was denied.
</div>
</ToolContent>
</Tool>
</div>
);
}
if (state === "approval-responded") {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
<ToolInput input={part.input} />
</ToolContent>
</Tool>
</div>
);
}
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
{(state === "input-available" ||
state === "approval-requested") && (
<ToolInput input={part.input} />
)}
{state === "approval-requested" && approvalId && (
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
<button
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: false,
reason: "User denied weather lookup",
});
}}
type="button"
>
Deny
</button>
<button
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: true,
});
}}
type="button"
>
Allow
</button>
</div>
)}
</ToolContent>
</Tool>
</div>
);
}
if (type === "tool-getCurrencyExchange") {
const { toolCallId, state } = part;
const approvalId = (part as { approval?: { id: string } })
.approval?.id;
const isDenied =
state === "output-denied" ||
(state === "approval-responded" &&
(part as { approval?: { approved?: boolean } }).approval
?.approved === false);
const widthClass = "w-[min(100%,450px)]";
if (state === "output-available") {
return (
<div className={widthClass} key={toolCallId}>
<CurrencyExchange exchangeData={part.output} />
</div>
);
}
if (isDenied) {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader
state="output-denied"
type="tool-getCurrencyExchange"
/>
<ToolContent>
<div className="px-4 py-3 text-muted-foreground text-sm">
Currency exchange lookup was denied.
</div>
</ToolContent>
</Tool>
</div>
);
}
if (state === "approval-responded") {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader
state={state}
type="tool-getCurrencyExchange"
/>
<ToolContent>
<ToolInput input={part.input} />
</ToolContent>
</Tool>
</div>
);
}
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getCurrencyExchange" />
<ToolContent>
{(state === "input-available" ||
state === "approval-requested") && (
<ToolInput input={part.input} />
)}
{state === "approval-requested" && approvalId && (
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
<button
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: false,
reason: "User denied currency exchange lookup",
});
}}
type="button"
>
Deny
</button>
<button
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: true,
});
}}
type="button"
>
Allow
</button>
</div>
)}
</ToolContent>
</Tool>
</div>
);
}
return null;
})}
{!isReadonly && (
<MessageActions
chatId={chatId}
isLoading={isLoading}
key={`action-${message.id}`}
message={message}
setMode={setMode}
vote={vote}
/>
)}
</div>
</div>
</div>
);
};
export const PreviewMessage = memo(
PurePreviewMessage,
(prevProps, nextProps) => {
if (
prevProps.isLoading === nextProps.isLoading &&
prevProps.message.id === nextProps.message.id &&
prevProps.requiresScrollPadding === nextProps.requiresScrollPadding &&
equal(prevProps.message.parts, nextProps.message.parts) &&
equal(prevProps.vote, nextProps.vote)
) {
return true;
}
return false;
}
);
export const ThinkingMessage = () => {
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-loading"
>
<div className="flex items-start justify-start gap-3">
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<div className="animate-pulse">
<SparklesIcon size={14} />
</div>
</div>
<div className="flex w-full flex-col gap-2 md:gap-4">
<div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
<span className="animate-pulse">Thinking</span>
<span className="inline-flex">
<span className="animate-bounce [animation-delay:0ms]">.</span>
<span className="animate-bounce [animation-delay:150ms]">.</span>
<span className="animate-bounce [animation-delay:300ms]">.</span>
</span>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,132 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal";
import { ArrowDownIcon } from "lucide-react";
import { memo } from "react";
import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
type MessagesProps = {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
isArtifactVisible: boolean;
selectedModelId: string;
};
function PureMessages({
addToolApprovalResponse,
chatId,
status,
votes,
messages,
setMessages,
regenerate,
isReadonly,
selectedModelId: _selectedModelId,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
} = useMessages({
status,
});
useDataStream();
return (
<div className="relative flex-1">
<div
className="absolute inset-0 touch-pan-y overflow-y-auto"
ref={messagesContainerRef}
>
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => (
<PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={message}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
{status === "submitted" &&
!messages.some((msg) =>
msg.parts?.some(
(part) => "state" in part && part.state === "approval-responded"
)
) && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<button
aria-label="Scroll to bottom"
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom
? "pointer-events-none scale-0 opacity-0"
: "pointer-events-auto scale-100 opacity-100"
}`}
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
</div>
);
}
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages)) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
return false;
});

View file

@ -0,0 +1,558 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import equal from "fast-deep-equal";
import { CheckIcon } from "lucide-react";
import {
type ChangeEvent,
type Dispatch,
memo,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorName,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import {
chatModels,
DEFAULT_CHAT_MODEL,
modelsByProvider,
} from "@/lib/ai/models";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from "./elements/prompt-input";
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import { SuggestedActions } from "./suggested-actions";
import { Button } from "./ui/button";
import type { VisibilityType } from "./visibility-selector";
function setCookie(name: string, value: string) {
const maxAge = 60 * 60 * 24 * 365; // 1 year
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
}
function PureMultimodalInput({
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
messages,
setMessages,
sendMessage,
className,
selectedVisibilityType,
selectedModelId,
onModelChange,
}: {
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: () => void;
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
className?: string;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
onModelChange?: (modelId: string) => void;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);
const hasAutoFocused = useRef(false);
useEffect(() => {
if (!hasAutoFocused.current && width) {
const timer = setTimeout(() => {
textareaRef.current?.focus();
hasAutoFocused.current = true;
}, 100);
return () => clearTimeout(timer);
}
}, [width]);
const resetHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
"input",
""
);
useEffect(() => {
if (textareaRef.current) {
const domValue = textareaRef.current.value;
// Prefer DOM value over localStorage to handle hydration
const finalValue = domValue || localStorageInput || "";
setInput(finalValue);
adjustHeight();
}
// Only run once after hydration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adjustHeight, localStorageInput, setInput]);
useEffect(() => {
setLocalStorageInput(input);
}, [input, setLocalStorageInput]);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value);
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const submitForm = useCallback(() => {
window.history.pushState({}, "", `/chat/${chatId}`);
sendMessage({
role: "user",
parts: [
...attachments.map((attachment) => ({
type: "file" as const,
url: attachment.url,
name: attachment.name,
mediaType: attachment.contentType,
})),
{
type: "text",
text: input,
},
],
});
setAttachments([]);
setLocalStorageInput("");
resetHeight();
setInput("");
if (width && width > 768) {
textareaRef.current?.focus();
}
}, [
input,
setInput,
attachments,
sendMessage,
setAttachments,
setLocalStorageInput,
width,
chatId,
resetHeight,
]);
const uploadFile = useCallback(async (file: File) => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
const { url, pathname, contentType } = data;
return {
url,
name: pathname,
contentType,
};
}
const { error } = await response.json();
toast.error(error);
} catch (_error) {
toast.error("Failed to upload file, please try again!");
}
}, []);
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
setUploadQueue(files.map((file) => file.name));
try {
const uploadPromises = files.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => attachment !== undefined
);
setAttachments((currentAttachments) => [
...currentAttachments,
...successfullyUploadedAttachments,
]);
} catch (error) {
console.error("Error uploading files!", error);
} finally {
setUploadQueue([]);
}
},
[setAttachments, uploadFile]
);
const handlePaste = useCallback(
async (event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/")
);
if (imageItems.length === 0) {
return;
}
// Prevent default paste behavior for images
event.preventDefault();
setUploadQueue((prev) => [...prev, "Pasted image"]);
try {
const uploadPromises = imageItems
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) =>
attachment !== undefined &&
attachment.url !== undefined &&
attachment.contentType !== undefined
);
setAttachments((curr) => [
...curr,
...(successfullyUploadedAttachments as Attachment[]),
]);
} catch (error) {
console.error("Error uploading pasted images:", error);
toast.error("Failed to upload pasted image(s)");
} finally {
setUploadQueue([]);
}
},
[setAttachments, uploadFile]
);
// Add paste event listener to textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.addEventListener("paste", handlePaste);
return () => textarea.removeEventListener("paste", handlePaste);
}, [handlePaste]);
return (
<div className={cn("relative flex w-full flex-col gap-4", className)}>
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
/>
)}
<input
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0"
multiple
onChange={handleFileChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<PromptInput
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
onSubmit={(event) => {
event.preventDefault();
// Only block submission when actively streaming a response
// All other states (ready, awaiting-message, etc.) should allow submission
if (status === "streaming") {
toast.error("Please wait for the model to finish its response!");
} else {
submitForm();
}
}}
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
className="flex flex-row items-end gap-2 overflow-x-scroll"
data-testid="attachments-preview"
>
{attachments.map((attachment) => (
<PreviewAttachment
attachment={attachment}
key={attachment.url}
onRemove={() => {
setAttachments((currentAttachments) =>
currentAttachments.filter((a) => a.url !== attachment.url)
);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}}
/>
))}
{uploadQueue.map((filename) => (
<PreviewAttachment
attachment={{
url: "",
name: filename,
contentType: "",
}}
isUploading={true}
key={filename}
/>
))}
</div>
)}
<div className="flex flex-row items-start gap-1 sm:gap-2">
<PromptInputTextarea
className="grow resize-none border-0! border-none! bg-transparent p-2 text-base outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden"
data-testid="multimodal-input"
disableAutoResize={true}
maxHeight={200}
minHeight={44}
onChange={handleInput}
placeholder="Send a message..."
ref={textareaRef}
rows={1}
value={input}
/>
</div>
<PromptInputToolbar className="border-top-0! border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
<PromptInputTools className="gap-0 sm:gap-0.5">
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
/>
</PromptInputTools>
{status === "submitted" ? (
<StopButton setMessages={setMessages} stop={stop} />
) : (
<PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0}
status={status}
>
<ArrowUpIcon size={14} />
</PromptInputSubmit>
)}
</PromptInputToolbar>
</PromptInput>
</div>
);
}
export const MultimodalInput = memo(
PureMultimodalInput,
(prevProps, nextProps) => {
if (prevProps.input !== nextProps.input) {
return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.attachments, nextProps.attachments)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
return true;
}
);
function PureAttachmentsButton({
fileInputRef,
status,
selectedModelId,
}: {
fileInputRef: React.MutableRefObject<HTMLInputElement | null>;
status: UseChatHelpers<ChatMessage>["status"];
selectedModelId: string;
}) {
const isReasoningModel =
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
return (
<Button
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
data-testid="attachments-button"
disabled={status !== "ready" || isReasoningModel}
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
variant="ghost"
>
<PaperclipIcon size={14} style={{ width: 14, height: 14 }} />
</Button>
);
}
const AttachmentsButton = memo(PureAttachmentsButton);
function PureModelSelectorCompact({
selectedModelId,
onModelChange,
}: {
selectedModelId: string;
onModelChange?: (modelId: string) => void;
}) {
const [open, setOpen] = useState(false);
const selectedModel =
chatModels.find((m) => m.id === selectedModelId) ??
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
chatModels[0];
const [provider] = selectedModel.id.split("/");
// Provider display names
const providerNames: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
xai: "xAI",
reasoning: "Reasoning",
};
return (
<ModelSelector onOpenChange={setOpen} open={open}>
<ModelSelectorTrigger asChild>
<Button className="h-8 w-[200px] justify-between px-2" variant="ghost">
{provider && <ModelSelectorLogo provider={provider} />}
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
{Object.entries(modelsByProvider).map(
([providerKey, providerModels]) => (
<ModelSelectorGroup
heading={providerNames[providerKey] ?? providerKey}
key={providerKey}
>
{providerModels.map((model) => {
const logoProvider = model.id.split("/")[0];
return (
<ModelSelectorItem
key={model.id}
onSelect={() => {
onModelChange?.(model.id);
setCookie("chat-model", model.id);
setOpen(false);
}}
value={model.id}
>
<ModelSelectorLogo provider={logoProvider} />
<ModelSelectorName>{model.name}</ModelSelectorName>
{model.id === selectedModel.id && (
<CheckIcon className="ml-auto size-4" />
)}
</ModelSelectorItem>
);
})}
</ModelSelectorGroup>
)
)}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
);
}
const ModelSelectorCompact = memo(PureModelSelectorCompact);
function PureStopButton({
stop,
setMessages,
}: {
stop: () => void;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
}) {
return (
<Button
className="size-7 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="stop-button"
onClick={(event) => {
event.preventDefault();
stop();
setMessages((messages) => messages);
}}
>
<StopIcon size={14} />
</Button>
);
}
const StopButton = memo(PureStopButton);

View file

@ -0,0 +1,62 @@
import Image from "next/image";
import type { Attachment } from "@/lib/types";
import { Loader } from "./elements/loader";
import { CrossSmallIcon } from "./icons";
import { Button } from "./ui/button";
export const PreviewAttachment = ({
attachment,
isUploading = false,
onRemove,
}: {
attachment: Attachment;
isUploading?: boolean;
onRemove?: () => void;
}) => {
const { name, url, contentType } = attachment;
return (
<div
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
data-testid="input-attachment-preview"
>
{contentType?.startsWith("image") ? (
<Image
alt={name ?? "An image attachment"}
className="size-full object-cover"
height={64}
src={url}
width={64}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
File
</div>
)}
{isUploading && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/50"
data-testid="input-attachment-loader"
>
<Loader size={16} />
</div>
)}
{onRemove && !isUploading && (
<Button
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={onRemove}
size="sm"
variant="destructive"
>
<CrossSmallIcon size={8} />
</Button>
)}
<div className="absolute inset-x-0 bottom-0 truncate bg-linear-to-t from-black/80 to-transparent px-1 py-0.5 text-[10px] text-white">
{name}
</div>
</div>
);
};

View file

@ -0,0 +1,140 @@
"use client";
import { useTheme } from "next-themes";
import { parse, unparse } from "papaparse";
import { memo, useEffect, useMemo, useState } from "react";
import DataGrid, { textEditor } from "react-data-grid";
import { cn } from "@/lib/utils";
import "react-data-grid/lib/styles.css";
type SheetEditorProps = {
content: string;
saveContent: (content: string, isCurrentVersion: boolean) => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
status: string;
};
const MIN_ROWS = 50;
const MIN_COLS = 26;
const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
const { resolvedTheme } = useTheme();
const parseData = useMemo(() => {
if (!content) {
return new Array(MIN_ROWS).fill(new Array(MIN_COLS).fill(""));
}
const result = parse<string[]>(content, { skipEmptyLines: true });
const paddedData = result.data.map((row) => {
const paddedRow = [...row];
while (paddedRow.length < MIN_COLS) {
paddedRow.push("");
}
return paddedRow;
});
while (paddedData.length < MIN_ROWS) {
paddedData.push(new Array(MIN_COLS).fill(""));
}
return paddedData;
}, [content]);
const columns = useMemo(() => {
const rowNumberColumn = {
key: "rowNumber",
name: "",
frozen: true,
width: 50,
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
};
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
key: i.toString(),
name: String.fromCharCode(65 + i),
renderEditCell: textEditor,
width: 120,
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
"border-l": i !== 0,
}),
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
"border-l": i !== 0,
}),
}));
return [rowNumberColumn, ...dataColumns];
}, []);
const initialRows = useMemo(() => {
return parseData.map((row, rowIndex) => {
const rowData: any = {
id: rowIndex,
rowNumber: rowIndex + 1,
};
columns.slice(1).forEach((col, colIndex) => {
rowData[col.key] = row[colIndex] || "";
});
return rowData;
});
}, [parseData, columns]);
const [localRows, setLocalRows] = useState(initialRows);
useEffect(() => {
setLocalRows(initialRows);
}, [initialRows]);
const generateCsv = (data: any[][]) => {
return unparse(data);
};
const handleRowsChange = (newRows: any[]) => {
setLocalRows(newRows);
const updatedData = newRows.map((row) => {
return columns.slice(1).map((col) => row[col.key] || "");
});
const newCsvContent = generateCsv(updatedData);
saveContent(newCsvContent, true);
};
return (
<DataGrid
className={resolvedTheme === "dark" ? "rdg-dark" : "rdg-light"}
columns={columns}
defaultColumnOptions={{
resizable: true,
sortable: true,
}}
enableVirtualization
onCellClick={(args) => {
if (args.column.key !== "rowNumber") {
args.selectCell(true);
}
}}
onRowsChange={handleRowsChange}
rows={localRows}
style={{ height: "100%" }}
/>
);
};
function areEqual(prevProps: SheetEditorProps, nextProps: SheetEditorProps) {
return (
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
prevProps.content === nextProps.content &&
prevProps.saveContent === nextProps.saveContent
);
}
export const SpreadsheetEditor = memo(PureSpreadsheetEditor, areEqual);

View file

@ -0,0 +1,120 @@
import Link from "next/link";
import { memo } from "react";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Chat } from "@/lib/db/schema";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "./ui/sidebar";
const PureChatItem = ({
chat,
isActive,
onDelete,
setOpenMobile,
}: {
chat: Chat;
isActive: boolean;
onDelete: (chatId: string) => void;
setOpenMobile: (open: boolean) => void;
}) => {
const { visibilityType, setVisibilityType } = useChatVisibility({
chatId: chat.id,
initialVisibilityType: chat.visibility,
});
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
<span>{chat.title}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu modal={true}>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
showOnHover={!isActive}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ShareIcon />
<span>Share</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("private");
}}
>
<div className="flex flex-row items-center gap-2">
<LockIcon size={12} />
<span>Private</span>
</div>
{visibilityType === "private" ? (
<CheckCircleFillIcon />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("public");
}}
>
<div className="flex flex-row items-center gap-2">
<GlobeIcon />
<span>Public</span>
</div>
{visibilityType === "public" ? <CheckCircleFillIcon /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
onSelect={() => onDelete(chat.id)}
>
<TrashIcon />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
};
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
if (prevProps.isActive !== nextProps.isActive) {
return false;
}
return true;
});

View file

@ -0,0 +1,376 @@
"use client";
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion";
import { usePathname, useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import useSWRInfinite from "swr/infinite";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { ChatItem } from "./sidebar-history-item";
type GroupedChats = {
today: Chat[];
yesterday: Chat[];
lastWeek: Chat[];
lastMonth: Chat[];
older: Chat[];
};
export type ChatHistory = {
chats: Chat[];
hasMore: boolean;
};
const PAGE_SIZE = 20;
const groupChatsByDate = (chats: Chat[]): GroupedChats => {
const now = new Date();
const oneWeekAgo = subWeeks(now, 1);
const oneMonthAgo = subMonths(now, 1);
return chats.reduce(
(groups, chat) => {
const chatDate = new Date(chat.createdAt);
if (isToday(chatDate)) {
groups.today.push(chat);
} else if (isYesterday(chatDate)) {
groups.yesterday.push(chat);
} else if (chatDate > oneWeekAgo) {
groups.lastWeek.push(chat);
} else if (chatDate > oneMonthAgo) {
groups.lastMonth.push(chat);
} else {
groups.older.push(chat);
}
return groups;
},
{
today: [],
yesterday: [],
lastWeek: [],
lastMonth: [],
older: [],
} as GroupedChats
);
};
export function getChatHistoryPaginationKey(
pageIndex: number,
previousPageData: ChatHistory
) {
if (previousPageData && previousPageData.hasMore === false) {
return null;
}
if (pageIndex === 0) {
return `/api/history?limit=${PAGE_SIZE}`;
}
const firstChatFromPage = previousPageData.chats.at(-1);
if (!firstChatFromPage) {
return null;
}
return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
}
export function SidebarHistory({ user }: { user: User | undefined }) {
const { setOpenMobile } = useSidebar();
const pathname = usePathname();
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
const {
data: paginatedChatHistories,
setSize,
isValidating,
isLoading,
mutate,
} = useSWRInfinite<ChatHistory>(getChatHistoryPaginationKey, fetcher, {
fallbackData: [],
});
const router = useRouter();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const hasReachedEnd = paginatedChatHistories
? paginatedChatHistories.some((page) => page.hasMore === false)
: false;
const hasEmptyChatHistory = paginatedChatHistories
? paginatedChatHistories.every((page) => page.chats.length === 0)
: false;
const handleDelete = () => {
const chatToDelete = deleteId;
const isCurrentChat = pathname === `/chat/${chatToDelete}`;
setShowDeleteDialog(false);
const deletePromise = fetch(`/api/chat?id=${chatToDelete}`, {
method: "DELETE",
});
toast.promise(deletePromise, {
loading: "Deleting chat...",
success: () => {
mutate((chatHistories) => {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter(
(chat) => chat.id !== chatToDelete
),
}));
}
});
if (isCurrentChat) {
router.replace("/");
router.refresh();
}
return "Chat deleted successfully";
},
error: "Failed to delete chat",
});
};
if (!user) {
return (
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
Login to save and revisit previous chats!
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (isLoading) {
return (
<SidebarGroup>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Today
</div>
<SidebarGroupContent>
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-md px-2"
key={item}
>
<div
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
style={
{
"--skeleton-width": `${item}%`,
} as React.CSSProperties
}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (hasEmptyChatHistory) {
return (
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
Your conversations will appear here once you start chatting!
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
return (
<>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
(() => {
const chatsFromHistory = paginatedChatHistories.flatMap(
(paginatedChatHistory) => paginatedChatHistory.chats
);
const groupedChats = groupChatsByDate(chatsFromHistory);
return (
<div className="flex flex-col gap-6">
{groupedChats.today.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Today
</div>
{groupedChats.today.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.yesterday.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Yesterday
</div>
{groupedChats.yesterday.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastWeek.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Last 7 days
</div>
{groupedChats.lastWeek.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastMonth.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Last 30 days
</div>
{groupedChats.lastMonth.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.older.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Older than last month
</div>
{groupedChats.older.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
</div>
);
})()}
</SidebarMenu>
<motion.div
onViewportEnter={() => {
if (!isValidating && !hasReachedEnd) {
setSize((size) => size + 1);
}
}}
/>
{hasReachedEnd ? (
<div className="mt-8 flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
You have reached the end of your chat history.
</div>
) : (
<div className="mt-8 flex flex-row items-center gap-2 p-2 text-zinc-500 dark:text-zinc-400">
<div className="animate-spin">
<LoaderIcon />
</div>
<div>Loading Chats...</div>
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
chat and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,35 @@
import type { ComponentProps } from "react";
import { type SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { SidebarLeftIcon } from "./icons";
import { Button } from "./ui/button";
export function SidebarToggle({
className,
}: ComponentProps<typeof SidebarTrigger>) {
const { toggleSidebar } = useSidebar();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn("h-8 px-2 md:h-fit md:px-2", className)}
data-testid="sidebar-toggle-button"
onClick={toggleSidebar}
variant="outline"
>
<SidebarLeftIcon size={16} />
</Button>
</TooltipTrigger>
<TooltipContent align="start" className="hidden md:block">
Toggle Sidebar
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { ChevronUp } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { signOut, useSession } from "next-auth/react";
import { useTheme } from "next-themes";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { guestRegex } from "@/lib/constants";
import { LoaderIcon } from "./icons";
import { toast } from "./toast";
export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, status } = useSession();
const { setTheme, resolvedTheme } = useTheme();
const isGuest = guestRegex.test(data?.user?.email ?? "");
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{status === "loading" ? (
<SidebarMenuButton className="h-10 justify-between bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex flex-row gap-2">
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
<span className="animate-pulse rounded-md bg-zinc-500/30 text-transparent">
Loading auth status
</span>
</div>
<div className="animate-spin text-zinc-500">
<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-nav-button"
>
<Image
alt={user.email ?? "User Avatar"}
className="rounded-full"
height={24}
src={`https://avatar.vercel.sh/${user.email}`}
width={24}
/>
<span className="truncate" data-testid="user-email">
{isGuest ? "Guest" : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-popper-anchor-width)"
data-testid="user-nav-menu"
side="top"
>
<DropdownMenuItem
className="cursor-pointer"
data-testid="user-nav-item-theme"
onSelect={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
>
{`Toggle ${resolvedTheme === "light" ? "dark" : "light"} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
className="w-full cursor-pointer"
onClick={() => {
if (status === "loading") {
toast({
type: "error",
description:
"Checking authentication status, please try again!",
});
return;
}
if (isGuest) {
router.push("/login");
} else {
signOut({
redirectTo: "/",
});
}
}}
type="button"
>
{isGuest ? "Login to your account" : "Sign out"}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,25 @@
import Form from "next/form";
import { signOut } from "@/app/(auth)/auth";
export const SignOutForm = () => {
return (
<Form
action={async () => {
"use server";
await signOut({
redirectTo: "/",
});
}}
className="w-full"
>
<button
className="w-full px-1 py-0.5 text-left text-red-500"
type="submit"
>
Sign out
</button>
</Form>
);
};

View file

@ -0,0 +1,38 @@
"use client";
import { useFormStatus } from "react-dom";
import { LoaderIcon } from "@/components/icons";
import { Button } from "./ui/button";
export function SubmitButton({
children,
isSuccessful,
}: {
children: React.ReactNode;
isSuccessful: boolean;
}) {
const { pending } = useFormStatus();
return (
<Button
aria-disabled={pending || isSuccessful}
className="relative"
disabled={pending || isSuccessful}
type={pending ? "button" : "submit"}
>
{children}
{(pending || isSuccessful) && (
<span className="absolute right-4 animate-spin">
<LoaderIcon />
</span>
)}
<output aria-live="polite" className="sr-only">
{pending || isSuccessful ? "Loading" : "Submit form"}
</output>
</Button>
);
}

View file

@ -0,0 +1,66 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import { motion } from "framer-motion";
import { memo } from "react";
import type { ChatMessage } from "@/lib/types";
import { Suggestion } from "./elements/suggestion";
import type { VisibilityType } from "./visibility-selector";
type SuggestedActionsProps = {
chatId: string;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
selectedVisibilityType: VisibilityType;
};
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
const suggestedActions = [
"Get the currency exchange rate for USD to EUR",
"What is the weather in San Francisco?",
];
return (
<div
className="grid w-full gap-2 sm:grid-cols-2"
data-testid="suggested-actions"
>
{suggestedActions.map((suggestedAction, index) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }}
key={suggestedAction}
transition={{ delay: 0.05 * index }}
>
<Suggestion
className="h-auto w-full whitespace-normal p-3 text-left"
onClick={(suggestion) => {
window.history.pushState({}, "", `/chat/${chatId}`);
sendMessage({
role: "user",
parts: [{ type: "text", text: suggestion }],
});
}}
suggestion={suggestedAction}
>
{suggestedAction}
</Suggestion>
</motion.div>
))}
</div>
);
}
export const SuggestedActions = memo(
PureSuggestedActions,
(prevProps, nextProps) => {
if (prevProps.chatId !== nextProps.chatId) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
}
);

View file

@ -0,0 +1,77 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useWindowSize } from "usehooks-ts";
import type { UISuggestion } from "@/lib/editor/suggestions";
import { cn } from "@/lib/utils";
import type { ArtifactKind } from "./artifact";
import { CrossIcon, MessageIcon } from "./icons";
import { Button } from "./ui/button";
export const Suggestion = ({
suggestion,
onApply,
artifactKind,
}: {
suggestion: UISuggestion;
onApply: () => void;
artifactKind: ArtifactKind;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const { width: windowWidth } = useWindowSize();
return (
<AnimatePresence>
{isExpanded ? (
<motion.div
animate={{ opacity: 1, y: -20 }}
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key={suggestion.id}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="size-4 rounded-full bg-muted-foreground/25" />
<div className="font-medium">Assistant</div>
</div>
<button
className="cursor-pointer text-gray-500 text-xs"
onClick={() => {
setIsExpanded(false);
}}
type="button"
>
<CrossIcon size={12} />
</button>
</div>
<div>{suggestion.description}</div>
<Button
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
variant="outline"
>
Apply
</Button>
</motion.div>
) : (
<motion.div
className={cn("cursor-pointer p-1 text-muted-foreground", {
"-right-8 absolute": artifactKind === "text",
"sticky top-0 right-4": artifactKind === "code",
})}
onClick={() => {
setIsExpanded(true);
}}
whileHover={{ scale: 1.1 }}
>
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
</motion.div>
)}
</AnimatePresence>
);
};

View file

@ -0,0 +1,164 @@
"use client";
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
import {
documentSchema,
handleTransaction,
headingRule,
} from "@/lib/editor/config";
import {
buildContentFromDocument,
buildDocumentFromContent,
createDecorations,
} from "@/lib/editor/functions";
import {
projectWithPositions,
suggestionsPlugin,
suggestionsPluginKey,
} from "@/lib/editor/suggestions";
type EditorProps = {
content: string;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
};
function PureEditor({
content,
onSaveContent,
suggestions,
status,
}: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
useEffect(() => {
if (containerRef.current && !editorRef.current) {
const state = EditorState.create({
doc: buildDocumentFromContent(content),
plugins: [
...exampleSetup({ schema: documentSchema, menuBar: false }),
inputRules({
rules: [
headingRule(1),
headingRule(2),
headingRule(3),
headingRule(4),
headingRule(5),
headingRule(6),
],
}),
suggestionsPlugin,
],
});
editorRef.current = new EditorView(containerRef.current, {
state,
});
}
return () => {
if (editorRef.current) {
editorRef.current.destroy();
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
if (editorRef.current) {
editorRef.current.setProps({
dispatchTransaction: (transaction) => {
handleTransaction({
transaction,
editorRef,
onSaveContent,
});
},
});
}
}, [onSaveContent]);
useEffect(() => {
if (editorRef.current && content) {
const currentContent = buildContentFromDocument(
editorRef.current.state.doc
);
if (status === "streaming") {
const newDocument = buildDocumentFromContent(content);
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
return;
}
if (currentContent !== content) {
const newDocument = buildDocumentFromContent(content);
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
}
}
}, [content, status]);
useEffect(() => {
if (editorRef.current?.state.doc && content) {
const projectedSuggestions = projectWithPositions(
editorRef.current.state.doc,
suggestions
).filter(
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
);
const decorations = createDecorations(
projectedSuggestions,
editorRef.current
);
const transaction = editorRef.current.state.tr;
transaction.setMeta(suggestionsPluginKey, { decorations });
editorRef.current.dispatch(transaction);
}
}, [suggestions, content]);
return (
<div className="prose dark:prose-invert relative" ref={containerRef} />
);
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
return (
prevProps.suggestions === nextProps.suggestions &&
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
prevProps.content === nextProps.content &&
prevProps.onSaveContent === nextProps.onSaveContent
);
}
export const Editor = memo(PureEditor, areEqual);

View file

@ -0,0 +1,8 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,75 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { toast as sonnerToast } from "sonner";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, WarningIcon } from "./icons";
const iconsByType: Record<"success" | "error", ReactNode> = {
success: <CheckCircleFillIcon />,
error: <WarningIcon />,
};
export function toast(props: Omit<ToastProps, "id">) {
return sonnerToast.custom((id) => (
<Toast description={props.description} id={id} type={props.type} />
));
}
function Toast(props: ToastProps) {
const { id, type, description } = props;
const descriptionRef = useRef<HTMLDivElement>(null);
const [multiLine, setMultiLine] = useState(false);
useEffect(() => {
const el = descriptionRef.current;
if (!el) {
return;
}
const update = () => {
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
const lines = Math.round(el.scrollHeight / lineHeight);
setMultiLine(lines > 1);
};
update(); // initial check
const ro = new ResizeObserver(update); // re-check on width changes
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div className="flex toast-mobile:w-[356px] w-full justify-center">
<div
className={cn(
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
multiLine ? "items-start" : "items-center"
)}
data-testid="toast"
key={id}
>
<div
className={cn(
"data-[type=error]:text-red-600 data-[type=success]:text-green-600",
{ "pt-1": multiLine }
)}
data-type={type}
>
{iconsByType[type]}
</div>
<div className="text-sm text-zinc-950" ref={descriptionRef}>
{description}
</div>
</div>
</div>
);
}
type ToastProps = {
id: string | number;
type: "success" | "error";
description: string;
};

View file

@ -0,0 +1,476 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from "framer-motion";
import { nanoid } from "nanoid";
import {
type Dispatch,
memo,
type ReactNode,
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import { useOnClickOutside } from "usehooks-ts";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ChatMessage } from "@/lib/types";
import { type ArtifactKind, artifactDefinitions } from "./artifact";
import type { ArtifactToolbarItem } from "./create-artifact";
import { ArrowUpIcon, StopIcon, SummarizeIcon } from "./icons";
type ToolProps = {
description: string;
icon: ReactNode;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isToolbarVisible?: boolean;
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
onClick: ({
sendMessage,
}: {
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
}) => void;
};
const Tool = ({
description,
icon,
selectedTool,
setSelectedTool,
isToolbarVisible,
setIsToolbarVisible,
isAnimating,
sendMessage,
onClick,
}: ToolProps) => {
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}, [selectedTool, description]);
const handleSelect = () => {
if (!isToolbarVisible && setIsToolbarVisible) {
setIsToolbarVisible(true);
return;
}
if (!selectedTool) {
setIsHovered(true);
setSelectedTool(description);
return;
}
if (selectedTool !== description) {
setSelectedTool(description);
} else {
setSelectedTool(null);
onClick({ sendMessage });
}
};
return (
<Tooltip open={isHovered && !isAnimating}>
<TooltipTrigger asChild>
<motion.div
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className={cx("rounded-full p-3", {
"bg-primary text-primary-foreground!": selectedTool === description,
})}
exit={{
scale: 0.9,
opacity: 0,
transition: { duration: 0.1 },
}}
initial={{ scale: 1, opacity: 0 }}
onClick={() => {
handleSelect();
}}
onHoverEnd={() => {
if (selectedTool !== description) {
setIsHovered(false);
}
}}
onHoverStart={() => {
setIsHovered(true);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleSelect();
}
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
{selectedTool === description ? <ArrowUpIcon /> : icon}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background"
side="left"
sideOffset={16}
>
{description}
</TooltipContent>
</Tooltip>
);
};
const randomArr = [...new Array(6)].map((_x) => nanoid(5));
const ReadingLevelSelector = ({
setSelectedTool,
sendMessage,
isAnimating,
}: {
setSelectedTool: Dispatch<SetStateAction<string | null>>;
isAnimating: boolean;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
}) => {
const LEVELS = [
"Elementary",
"Middle School",
"Keep current level",
"High School",
"College",
"Graduate",
];
const y = useMotionValue(-40 * 2);
const dragConstraints = 5 * 40 + 2;
const yToLevel = useTransform(y, [0, -dragConstraints], [0, 5]);
const [currentLevel, setCurrentLevel] = useState(2);
const [hasUserSelectedLevel, setHasUserSelectedLevel] =
useState<boolean>(false);
useEffect(() => {
const unsubscribe = yToLevel.on("change", (latest) => {
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
setCurrentLevel(level);
});
return () => unsubscribe();
}, [yToLevel]);
return (
<div className="relative flex flex-col items-center justify-end">
{randomArr.map((id) => (
<motion.div
animate={{ opacity: 1 }}
className="flex size-[40px] flex-row items-center justify-center"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={id}
transition={{ delay: 0.1 }}
>
<div className="size-2 rounded-full bg-muted-foreground/40" />
</motion.div>
))}
<TooltipProvider>
<Tooltip open={!isAnimating}>
<TooltipTrigger asChild>
<motion.div
className={cx(
"absolute flex flex-row items-center rounded-full border bg-background p-3",
{
"bg-primary text-primary-foreground": currentLevel !== 2,
"bg-background text-foreground": currentLevel === 2,
}
)}
drag="y"
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
dragElastic={0}
dragMomentum={false}
onClick={() => {
if (currentLevel !== 2 && hasUserSelectedLevel) {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
},
],
});
setSelectedTool(null);
}
}}
onDragEnd={() => {
if (currentLevel === 2) {
setSelectedTool(null);
} else {
setHasUserSelectedLevel(true);
}
}}
onDragStart={() => {
setHasUserSelectedLevel(false);
}}
style={{ y }}
transition={{ duration: 0.1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
</motion.div>
</TooltipTrigger>
<TooltipContent
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
side="left"
sideOffset={16}
>
{LEVELS[currentLevel]}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
export const Tools = ({
isToolbarVisible,
selectedTool,
setSelectedTool,
sendMessage,
isAnimating,
setIsToolbarVisible,
tools,
}: {
isToolbarVisible: boolean;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isAnimating: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
tools: ArtifactToolbarItem[];
}) => {
const [primaryTool, ...secondaryTools] = tools;
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-1.5"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<AnimatePresence>
{isToolbarVisible &&
secondaryTools.map((secondaryTool) => (
<Tool
description={secondaryTool.description}
icon={secondaryTool.icon}
isAnimating={isAnimating}
key={secondaryTool.description}
onClick={secondaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
))}
</AnimatePresence>
<Tool
description={primaryTool.description}
icon={primaryTool.icon}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
onClick={primaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
/>
</motion.div>
);
};
const PureToolbar = ({
isToolbarVisible,
setIsToolbarVisible,
sendMessage,
status,
stop,
setMessages,
artifactKind,
}: {
isToolbarVisible: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
status: UseChatHelpers<ChatMessage>["status"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
stop: UseChatHelpers<ChatMessage>["stop"];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
artifactKind: ArtifactKind;
}) => {
const toolbarRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
useOnClickOutside(toolbarRef, () => {
setIsToolbarVisible(false);
setSelectedTool(null);
});
const startCloseTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setSelectedTool(null);
setIsToolbarVisible(false);
}, 2000);
};
const cancelCloseTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
if (status === "streaming") {
setIsToolbarVisible(false);
}
}, [status, setIsToolbarVisible]);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifactKind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
const toolsByArtifactKind = artifactDefinition.toolbar;
if (toolsByArtifactKind.length === 0) {
return null;
}
return (
<TooltipProvider delayDuration={0}>
<motion.div
animate={
isToolbarVisible
? selectedTool === "adjust-reading-level"
? {
opacity: 1,
y: 0,
height: 6 * 43,
transition: { delay: 0 },
scale: 0.95,
}
: {
opacity: 1,
y: 0,
height: toolsByArtifactKind.length * 50,
transition: { delay: 0 },
scale: 1,
}
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
}
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
setIsAnimating(false);
}}
onAnimationStart={() => {
setIsAnimating(true);
}}
onHoverEnd={() => {
if (status === "streaming") {
return;
}
startCloseTimer();
}}
onHoverStart={() => {
if (status === "streaming") {
return;
}
cancelCloseTimer();
setIsToolbarVisible(true);
}}
ref={toolbarRef}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{status === "streaming" ? (
<motion.div
animate={{ scale: 1.4 }}
className="p-3"
exit={{ scale: 1 }}
initial={{ scale: 1 }}
key="stop-icon"
onClick={() => {
stop();
setMessages((messages) => messages);
}}
>
<StopIcon />
</motion.div>
) : selectedTool === "adjust-reading-level" ? (
<ReadingLevelSelector
isAnimating={isAnimating}
key="reading-level-selector"
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
) : (
<Tools
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
/>
)}
</motion.div>
</TooltipProvider>
);
};
export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) {
return false;
}
if (prevProps.artifactKind !== nextProps.artifactKind) {
return false;
}
return true;
});

View file

@ -0,0 +1,140 @@
"use client";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import * as React from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg",
className
)}
ref={ref}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
className={cn("font-semibold text-lg", className)}
ref={ref}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
ref={ref}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
ref={ref}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
ref={ref}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,59 @@
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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View file

@ -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 has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
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<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View file

@ -0,0 +1,57 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
"icon-sm": "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -0,0 +1,99 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
ref={ref}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
ref={ref}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
className={cn(
"font-semibold text-2xl leading-none tracking-tight",
className
)}
ref={ref}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
className={cn("text-muted-foreground text-sm", className)}
ref={ref}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div className={cn("p-6 pt-0", className)} ref={ref} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
className={cn("flex items-center p-6 pt-0", className)}
ref={ref}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
const CardAction = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
className={cn("flex items-center gap-2", className)}
ref={ref}
{...props}
/>
));
CardAction.displayName = "CardAction";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
CardAction,
};

View file

@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View file

@ -0,0 +1,11 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View file

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
"h-9 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

Some files were not shown because too many files have changed in this diff Show more