Does this render the active setup?
Components
ChatMessage
Composable chat message with typed role, density, and lifecycle state.
Composition
Message with actions
- <ChatMessage>
- <ChatMessageRow>
- <ChatMessageAvatar />
- <ChatMessageBody>
- <ChatMessageHeader />
- <ChatMessageContent>
- message content
- <ChatMessagePending />
- <ChatMessageActions>
- <ActionBar />
- <ChatMessageRow>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
This agent installs from the chat-message registry. Install the bundle with the command above, or inspect the source below.
npx shadcn@latest add https://control-ui.dev/r/chat-message.jsonUsage
import type { MastraDBMessage } from "@mastra/core/agent/message-list";import { type DynamicToolPart, type FilePart, MessageFactory, type MessageRoleRendererProps, type MessageRoleRenderers, type MessageStatusRenderers, type ToolInvocationPart,} from "@mastra/react";import type { ReactNode } from "react";import type { ActivityState } from "@/components/control-ui/activity";import { Activity, ActivityContent, ActivityDetail, ActivityDetailContent, ActivityDetailLabel, ActivityIcon, ActivityStatus, ActivityTitle, ActivityTrigger,} from "@/components/control-ui/activity";import { ChatMessage, ChatMessageBody, ChatMessageContent, ChatMessageRow } from "@/components/control-ui/chat-message";import type { ChatRole } from "@/components/control-ui/hooks/use-chat-message";import { InlineAttachment, InlineAttachmentContent, InlineAttachmentMedia, InlineAttachmentTitle,} from "@/components/control-ui/inline-attachment";import { SourceBadge } from "@/components/control-ui/source-badge";function renderJson(value: unknown) { return typeof value === "string" ? value : JSON.stringify(value, null, 2);}function activityState(state?: string): ActivityState { if (state === "result" || state === "output-available") return "success"; if (state === "output-error" || state === "output-denied") return "error"; if (state === "partial-call" || state === "input-streaming") return "running"; return "pending";}function renderMessage(from: ChatRole, children: ReactNode) { return ( <ChatMessage from={from}> <ChatMessageRow> <ChatMessageBody> <ChatMessageContent>{children}</ChatMessageContent> </ChatMessageBody> </ChatMessageRow> </ChatMessage> );}function isImageMediaType(mimeType: string) { return mimeType === "image" || mimeType.startsWith("image/");}// Mastra file parts carry base64 in `data`; a provider that already hands back a data URL passes straight through.function fileSrc({ mimeType, data }: FilePart) { return data.startsWith("data:") ? data : `data:${mimeType};base64,${data}`;}function renderFile(part: FilePart) { const name = "filename" in part && typeof part.filename === "string" ? part.filename : "Attachment"; return ( <InlineAttachment name={name}> {isImageMediaType(part.mimeType) ? <InlineAttachmentMedia src={fileSrc(part)} alt={name} /> : null} <InlineAttachmentContent> <InlineAttachmentTitle /> </InlineAttachmentContent> </InlineAttachment> );}function renderToolInvocation(part: ToolInvocationPart) { const invocation = part.toolInvocation; const state = activityState(invocation.state); return ( <Activity kind="tool" name={invocation.toolName} state={state}> <ActivityTrigger> <ActivityIcon /> <ActivityTitle /> <ActivityStatus className="sr-only" /> </ActivityTrigger> <ActivityContent> <ActivityDetail> <ActivityDetailLabel>Input</ActivityDetailLabel> <ActivityDetailContent format="code">{renderJson(invocation.rawInput ?? invocation.args)}</ActivityDetailContent> </ActivityDetail> {invocation.result !== undefined || invocation.errorText ? ( <ActivityDetail> <ActivityDetailLabel>Output</ActivityDetailLabel> <ActivityDetailContent className={state === "error" ? "text-destructive-text" : undefined}> {invocation.errorText ?? renderJson(invocation.result)} </ActivityDetailContent> </ActivityDetail> ) : null} </ActivityContent> </Activity> );}function renderDynamicTool(part: DynamicToolPart) { const state = activityState(part.state); const name = part.toolName ?? "tool"; return ( <Activity kind="tool" name={name} state={state}> <ActivityTrigger> <ActivityIcon /> <ActivityTitle /> <ActivityStatus className="sr-only" /> </ActivityTrigger> <ActivityContent> {part.input !== undefined ? ( <ActivityDetail> <ActivityDetailLabel>Input</ActivityDetailLabel> <ActivityDetailContent format="code">{renderJson(part.input)}</ActivityDetailContent> </ActivityDetail> ) : null} {part.output !== undefined ? ( <ActivityDetail> <ActivityDetailLabel>Output</ActivityDetailLabel> <ActivityDetailContent>{renderJson(part.output)}</ActivityDetailContent> </ActivityDetail> ) : null} </ActivityContent> </Activity> );}const messageRoles = { User: ({ children }: MessageRoleRendererProps) => renderMessage("user", children), Assistant: ({ children }: MessageRoleRendererProps) => renderMessage("assistant", children), System: ({ children }: MessageRoleRendererProps) => renderMessage("system", children), Signal: ({ children }: MessageRoleRendererProps) => renderMessage("tool", children),} satisfies MessageRoleRenderers;const messageStatus = { Tripwire: ({ text }) => <p role="alert">{text}</p>, Warning: ({ text }) => <p role="status">{text}</p>, Error: ({ text }) => <p role="alert">{text}</p>, Pending: ({ children }) => <div aria-busy="true">{children}</div>, Task: ({ passed }) => <p role="status">Task {passed ? "completed" : "needs another step"}</p>,} satisfies MessageStatusRenderers;export function Example({ message }: { message: MastraDBMessage }) { return ( <MessageFactory message={message} roles={messageRoles} status={messageStatus} Text={({ text }) => <span>{text}</span>} Reasoning={({ reasoning, state }) => ( <Activity kind="reasoning" state={state === "streaming" ? "running" : "success"}> <ActivityTrigger> <ActivityIcon /> <ActivityTitle>Reasoning</ActivityTitle> </ActivityTrigger> <ActivityContent>{reasoning}</ActivityContent> </Activity> )} File={renderFile} StepStart={() => <hr />} ToolInvocation={renderToolInvocation} DynamicTool={renderDynamicTool} SourceUrl={({ title, url }) => <SourceBadge href={url}>{title}</SourceBadge>} SourceDocument={({ title }) => <span>{title}</span>} Data={({ data }) => <pre>{renderJson(data)}</pre>} fallback={(part) => <span>Unsupported message part: {part.type}</span>} /> );}Dependencies
Behavior hook
src/registry/hooks/use-chat-message.tsHookEffects
src/registry/sources/control-ui/effects.cssSupportRaw code
This agent’s owned source and private support files
"use client";import type { ComponentProps, CSSProperties } from "react";import { createContext, useContext } from "react";import type { ChatMessageProps } from "@/components/control-ui/hooks/use-chat-message";import { useChatMessage } from "@/components/control-ui/hooks/use-chat-message";import type { ChatMessageKnobStyle } from "@/components/control-ui/knob-contracts/chat-message-knobs";import { cn } from "@/components/control-ui/lib/cn";type ChatMessageContextValue = ReturnType<typeof useChatMessage>;const ChatMessageContext = createContext<ChatMessageContextValue | null>(null);function useChatMessageContext() { const context = useContext(ChatMessageContext); if (!context) throw new Error("ChatMessage compound components must be rendered inside <ChatMessage>."); return context;}export function ChatMessage({ from, state = "idle", density = "comfortable", className, children, ...props }: ChatMessageProps) { const message = useChatMessage({ from, state, density }); return ( <ChatMessageContext.Provider value={message}> <article data-control-ui="chat-message" data-control-family="chat-message" data-slot="root" data-role={from} data-state={state} data-density={density} className={cn("w-full", className)} {...props} > {children} </article> </ChatMessageContext.Provider> );}export type ChatMessageRowProps = ComponentProps<"div"> & { style?: CSSProperties & ChatMessageKnobStyle };export function ChatMessageRow({ className, children, ...props }: ChatMessageRowProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="row" className={cn("flex w-full", message.isUser ? "justify-end" : "justify-start", className)} {...props} > {children} </div> );}export type ChatMessageAvatarProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatMessageKnobStyle;};export function ChatMessageAvatar({ className, ...props }: ChatMessageAvatarProps) { return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="avatar" className={cn("flex shrink-0 items-center justify-center", className)} {...props} /> );}export type ChatMessageBodyProps = ComponentProps<"div"> & { style?: CSSProperties & ChatMessageKnobStyle };export function ChatMessageBody({ className, ...props }: ChatMessageBodyProps) { useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="body" className={cn("min-w-0", className)} {...props} /> );}export type ChatMessageHeaderProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatMessageKnobStyle;};export function ChatMessageHeader({ className, ...props }: ChatMessageHeaderProps) { return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="header" className={cn("flex items-center", className)} {...props} /> );}export type ChatMessageContentProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatMessageKnobStyle;};export function ChatMessageContent({ className, ...props }: ChatMessageContentProps) { const message = useChatMessageContext(); return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="content" data-role={message.from} data-streaming={message.isStreaming ? "" : undefined} className={cn(message.isUser && "px-[var(--padding-x)] py-[var(--padding-y)]", className)} {...props} /> );}export type ChatMessagePendingProps = Omit<ComponentProps<"div">, "style" | "children"> & { label?: string; style?: CSSProperties & ChatMessageKnobStyle;};export function ChatMessagePending({ label = "Assistant is replying", className, ...props }: ChatMessagePendingProps) { const message = useChatMessageContext(); if (!message.isPending) return null; return ( <div role="status" aria-label={label} data-control-ui="chat-message" data-control-family="chat-message" data-slot="pending" className={cn("inline-flex items-center", className)} {...props} > <span aria-hidden="true" /> <span aria-hidden="true" /> <span aria-hidden="true" /> </div> );}export type ChatMessageActionsProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ChatMessageKnobStyle;};export function ChatMessageActions({ className, ...props }: ChatMessageActionsProps) { return ( <div data-control-ui="chat-message" data-control-family="chat-message" data-slot="actions" className={cn("flex items-center", className)} {...props} /> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-chat-message-* · 14 knobsHow the cascade resolves--cui-chat-message-avatar-radius<length-percentage>9999px--cui-chat-message-avatar-background<color>var(--card)--cui-chat-message-avatar-border-color<color>var(--border)--cui-chat-message-avatar-border-width<length>var(--control-rim-width)--cui-chat-message-radius<length-percentage>0px--cui-chat-message-corner-radius<length-percentage>0px--cui-chat-message-background<color>transparent--cui-chat-message-background-image*none--cui-chat-message-foreground<color>var(--foreground)--cui-chat-message-border-color<color>transparent--cui-chat-message-border-width<length>0px--cui-chat-message-shadow*none--cui-chat-message-pending-dot-color<color>var(--muted-foreground)--cui-chat-message-pending-dot-size<length>calc(var(--spacing) * 1.5)