- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
What should we prepare for the workshop?
Start with a short agenda and a shared notes document.
Leave time at the end for questions.
I can also help organize the materials.
Installation
$ pnpm dlx shadcn@latest add @cobalt/message
Source
"use client";
import {
RiCheckLine,
RiFileCopyLine,
RiVolumeUpLine,
RiVolumeMuteLine,
} from "@remixicon/react";
import { cn } from "cn";
import { motion } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Button } from "@/components/ui/button";
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
);
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
);
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
);
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
);
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
);
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
);
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
};
const lineHidden = { opacity: 0, filter: "blur(5px)", y: 4 };
const lineShown = { opacity: 1, filter: "blur(0px)", y: 0 };
const lineTransition = { duration: 0.42, ease: [0.22, 0.61, 0.36, 1] } as const;
const lineInstant = { duration: 0 } as const;
const MessageLine = React.memo(function MessageLine({
text,
reduce,
}: {
text: string;
reduce: boolean;
}) {
return (
<motion.p
initial={lineHidden}
animate={lineShown}
transition={reduce ? lineInstant : lineTransition}
className="text-sm leading-5 font-normal break-words text-[color:var(--foreground-strong,var(--foreground))] motion-reduce:transform-none! motion-reduce:opacity-100! motion-reduce:filter-none!"
>
{text}
</motion.p>
);
});
function StreamedText({
text,
paragraphs = false,
}: {
text: string;
paragraphs?: boolean;
}) {
const reduce = useReducedMotion();
const lines = React.useMemo(
() =>
text
.split(paragraphs ? /\n\s*\n/ : "\n")
.filter((line) => line.trim() !== ""),
[text, paragraphs]
);
return (
<div className="flex flex-col gap-3">
{lines.map((line, index) => (
<MessageLine key={index} text={line} reduce={reduce} />
))}
</div>
);
}
interface AgentMessageProps {
role: string;
text: string;
streaming?: boolean;
at?: number;
}
function AgentMessage({
role,
text,
streaming = false,
at,
}: AgentMessageProps) {
if (!text) {
return null;
}
if (role === "user") {
return (
<p className="ms-auto flex w-fit max-w-[75%] flex-col rounded-[16px] bg-[var(--surface-card,var(--background))] px-3 py-[11px] text-start text-sm leading-5 font-normal break-words whitespace-pre-wrap text-[color:var(--foreground-strong,var(--foreground))] shadow-[shadow:var(--shadow-card,0_1px_1px_0_rgb(0_0_0/0.05))]">
{text}
</p>
);
}
return (
<div className="group/message flex flex-col gap-1 px-1">
<StreamedText text={text} />
<MessageActions text={text} at={at} hidden={streaming} />
</div>
);
}
function MessageActions({
text,
at,
hidden,
}: {
text: string;
at?: number;
hidden: boolean;
}) {
const [copied, setCopied] = React.useState(false);
const [speaking, setSpeaking] = React.useState(false);
React.useEffect(() => {
if (!copied) {
return;
}
const timer = setTimeout(() => setCopied(false), 1600);
return () => clearTimeout(timer);
}, [copied]);
React.useEffect(
() => () => {
window.speechSynthesis?.cancel();
},
[]
);
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
/* Clipboard access may be denied. */
}
};
const toggleSpeech = () => {
const synth = window.speechSynthesis;
if (!synth) {
return;
}
if (speaking) {
synth.cancel();
setSpeaking(false);
return;
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.addEventListener("end", () => setSpeaking(false));
utterance.addEventListener("error", () => setSpeaking(false));
synth.cancel();
synth.speak(utterance);
setSpeaking(true);
};
return (
<div
className={cn(
"flex items-center gap-1 transition-opacity duration-150 motion-reduce:transition-none",
hidden
? "pointer-events-none opacity-0"
: "opacity-0 group-hover/message:opacity-100 focus-within:opacity-100"
)}
>
<MessageAction label={copied ? "Copied" : "Copy message"} onClick={copy}>
{copied ? <RiCheckLine aria-hidden /> : <RiFileCopyLine aria-hidden />}
</MessageAction>
<MessageAction
label={speaking ? "Stop reading aloud" : "Read aloud"}
onClick={toggleSpeech}
>
{speaking ? (
<RiVolumeMuteLine aria-hidden />
) : (
<RiVolumeUpLine aria-hidden />
)}
</MessageAction>
{at ? (
<time
dateTime={new Date(at).toISOString()}
className="ms-1 text-xs leading-4 font-normal tracking-[0.15px] text-[color:var(--foreground-tertiary,var(--muted-foreground))]"
>
{formatAgo(at)}
</time>
) : null}
</div>
);
}
function MessageAction({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Button
type="button"
variant="ghost-muted"
size="icon-xs"
onClick={onClick}
tooltip={{ children: label, side: "top" }}
>
{children}
</Button>
);
}
function formatAgo(at: number) {
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
if (seconds < 45) {
return "just now";
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
}
const days = Math.round(hours / 24);
return `${days} day${days === 1 ? "" : "s"} ago`;
}
export { AgentMessage, StreamedText };
export type { AgentMessageProps }; On this page