Chat Message

PreviousNext

A fully composable component for displaying chat messages with rich features like timestamps, actions, and threading.

Docs
simple-aiui

Preview

Loading preview…
./src/registry/ui/chat-message.tsx
"use client";

import {
	Check,
	ChevronRight,
	Copy,
	SparklesIcon,
	UserIcon,
} from "lucide-react";
import { type ComponentProps, type ReactNode, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
	Tooltip,
	TooltipContent,
	TooltipProvider,
	TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "@/registry/ui/markdown-content";

function ChatMessage({ className, ...props }: ComponentProps<"div">) {
	return (
		<TooltipProvider>
			<div
				className={cn(
					"group/chat-message relative flex w-full gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50",
					className,
				)}
				{...props}
			/>
		</TooltipProvider>
	);
}

function ChatMessageContainer({ className, ...props }: ComponentProps<"div">) {
	return (
		<div
			className={cn("flex w-full flex-col items-start gap-1", className)}
			{...props}
		/>
	);
}

function ChatMessageHeader({ className, ...props }: ComponentProps<"div">) {
	return (
		<div
			className={cn("flex items-center gap-2 px-2 text-sm", className)}
			{...props}
		/>
	);
}

function ChatMessageAuthor({ className, ...props }: ComponentProps<"span">) {
	return (
		<span
			className={cn("font-medium text-foreground", className)}
			{...props}
		/>
	);
}

interface ChatMessageTimestampProps extends ComponentProps<"span"> {
	createdAt: number | Date | string;
	format?: Intl.DateTimeFormatOptions;
}

function ChatMessageTimestamp({
	className,
	createdAt,
	format = { hour: "numeric", minute: "numeric" },
	...props
}: ChatMessageTimestampProps) {
	const date = createdAt instanceof Date ? createdAt : new Date(createdAt);

	return (
		<Tooltip>
			<TooltipTrigger asChild>
				<span
					className={cn(
						"text-xs text-muted-foreground cursor-default",
						className,
					)}
					{...props}
				>
					{date.toLocaleTimeString("en-US", format)}
				</span>
			</TooltipTrigger>
			<TooltipContent>
				<p>{date.toLocaleString()}</p>
			</TooltipContent>
		</Tooltip>
	);
}

function ChatMessageContent({ className, ...props }: ComponentProps<"div">) {
	return (
		<div
			className={cn("w-full flex flex-col gap-2 p-2", className)}
			{...props}
		/>
	);
}

interface ChatMessageMarkdownProps {
	content: string;
	className?: string;
}

function ChatMessageMarkdown({ content, className }: ChatMessageMarkdownProps) {
	return <MarkdownContent content={content || ""} className={className} />;
}

function ChatMessageFooter({ className, ...props }: ComponentProps<"div">) {
	return (
		<div
			className={cn(
				"mt-1 flex items-center gap-2 px-2 text-xs text-muted-foreground",
				className,
			)}
			{...props}
		/>
	);
}

function ChatMessageActions({
	className,
	children,
	...props
}: ComponentProps<typeof Card>) {
	return (
		<Card
			className={cn(
				"absolute -top-5 right-5 z-20 flex flex-row gap-1 p-1 opacity-0 transition-opacity group-hover/chat-message:opacity-100",
				className,
			)}
			{...props}
		>
			<TooltipProvider>{children}</TooltipProvider>
		</Card>
	);
}

interface ChatMessageActionProps extends ComponentProps<typeof Button> {
	className?: string;
	children?: ReactNode;
	label: string;
}

function ChatMessageAction({
	className,
	children,
	label,
	...props
}: ChatMessageActionProps) {
	return (
		<Tooltip>
			<TooltipTrigger asChild>
				<Button
					variant="ghost"
					size="icon"
					className={cn("h-7 w-7", className)}
					{...props}
				>
					{children}
					<span className="sr-only">{label}</span>
				</Button>
			</TooltipTrigger>
			<TooltipContent>
				<p>{label}</p>
			</TooltipContent>
		</Tooltip>
	);
}

function ChatMessageActionCopy({
	className,
	onClick,
	...props
}: ComponentProps<typeof Button>) {
	const [hasCopied, setHasCopied] = useState(false);
	return (
		<ChatMessageAction
			label="Copy"
			onClick={(e) => {
				onClick?.(e);
				setHasCopied(true);
				setTimeout(() => setHasCopied(false), 2000);
			}}
			{...props}
		>
			{hasCopied ? (
				<Check className="size-4" />
			) : (
				<Copy className="size-4" />
			)}
		</ChatMessageAction>
	);
}

function ChatMessageAvatar(props: ComponentProps<typeof Avatar>) {
	return (
		<Avatar
			className="[&_svg]:size-4 [&:has(svg)]:border [&:has(svg)]:border-border [&:has(svg)]:items-center [&:has(svg)]:justify-center"
			{...props}
		/>
	);
}

function ChatMessageAvatarFallback(
	props: ComponentProps<typeof AvatarFallback>,
) {
	return <AvatarFallback {...props} />;
}

function ChatMessageAvatarImage(props: ComponentProps<typeof AvatarImage>) {
	return <AvatarImage {...props} />;
}

function ChatMessageAvatarUserIcon(props: ComponentProps<typeof UserIcon>) {
	return <UserIcon {...props} />;
}

function ChatMessageAvatarAssistantIcon(
	props: ComponentProps<typeof SparklesIcon>,
) {
	return <SparklesIcon {...props} />;
}

function ChatMessageThread({
	className,
	...props
}: ComponentProps<typeof Button>) {
	return (
		<Button
			variant="ghost"
			className={cn(
				"group/button flex h-auto w-full border border-none items-center justify-start gap-2 px-2 py-1.5 transition-all",
				"hover:border-input hover:bg-background hover:shadow-sm",
				className,
			)}
			{...props}
		/>
	);
}

function ChatMessageThreadReplyCount(props: ComponentProps<"span">) {
	return <span className="text-sm font-medium" {...props} />;
}

interface ChatMessageThreadTimestampProps extends ComponentProps<"span"> {
	date: Date | number | string;
}
function ChatMessageThreadTimestamp({
	date: dateProp,
	...props
}: ChatMessageThreadTimestampProps) {
	const date = new Date(dateProp).toLocaleTimeString([], {
		hour: "2-digit",
		minute: "2-digit",
	});
	return (
		<span
			className="block text-sm text-muted-foreground group-hover/button:hidden"
			{...props}
		>
			Last reply at {date}
		</span>
	);
}

function ChatMessageThreadAction(props: ComponentProps<"span">) {
	return (
		<span
			className="hidden w-full items-center gap-1 text-sm text-muted-foreground group-hover/button:flex"
			{...props}
		>
			View thread
			<ChevronRight className="ml-auto h-4 w-4" />
		</span>
	);
}

export {
	ChatMessage,
	ChatMessageAvatar,
	ChatMessageAvatarImage,
	ChatMessageAvatarFallback,
	ChatMessageAvatarUserIcon,
	ChatMessageAvatarAssistantIcon,
	ChatMessageContainer,
	ChatMessageHeader,
	ChatMessageAuthor,
	ChatMessageTimestamp,
	ChatMessageContent,
	ChatMessageMarkdown,
	ChatMessageFooter,
	ChatMessageActions,
	ChatMessageAction,
	ChatMessageActionCopy,
	ChatMessageThread,
	ChatMessageThreadReplyCount,
	ChatMessageThreadTimestamp,
	ChatMessageThreadAction,
};

Installation

npx shadcn@latest add @simple-ai/chat-message

Usage

import { ChatMessage } from "@/components/ui/chat-message"
<ChatMessage />