Skip to content
66 changes: 63 additions & 3 deletions apps/ui/content/docs/(root)/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,14 @@ Import components directly from their files. There is no required global chat pr
| Area | Components | Responsibility |
| --- | --- | --- |
| Conversation | `ChatConversation`, `useChatScroll` | Scroll viewport, end following, jump to latest, overlay slot |
| Message | `ChatMessage` | Incoming/outgoing row, avatar, author, grouping, actions, reactions, footer |
| Message | `ChatMessage`, `ChatMessageTimestamp` | Incoming/outgoing row, avatar, author, grouping, actions, reactions, footer, timestamp |
| Surfaces | `ChatMessageBubble`, `ChatBubble`, `ChatMessageLayout` | Standalone previews and custom row compositions |
| Content | `ChatMessageText`, `ChatCollapsibleMessage`, `ChatReplyReference` | Mention emphasis, long text, quoted message navigation |
| Content | `ChatMessageText`, `ChatMessageMarkdown`, `ChatCollapsibleMessage`, `ChatReplyReference` | Mention emphasis, Markdown prose, long text, quoted message navigation |
| Composer | `Composer`, `ComposerInput`, `ComposerPopup`, `ComposerList`, `ComposerItem`, `ComposerSubmit` | Shared coss Composer: structured draft, send, adaptive layout, and mentions |
| Actions | `ChatMessageActionToolbar`, `ChatReactionPicker`, `ChatReactionChip` | Reply/menu controls, emoji selection, reactor list |
| Focused reply | `ChatReplyPreview` | Move the selected message above the composer, dim the timeline, cancel/exit |
| Timeline | `ChatDatePill`, `ChatUnreadDivider`, `ChatJumpToLatestButton` | Sticky date markers, unread boundary, latest navigation |
| Activity | `ChatTypingIndicator`, `ChatDeliveryIndicator` | Typing and delivery presentation |
| Activity | `ChatTypingIndicator`, `ChatDeliveryIndicator`, `ChatMessageCaret` | Typing, delivery, and streaming presentation |
| People | `ChatMemberList`, `ChatMemberSection`, `ChatMessageInfo` | Member rows and receipt groups with supplied actions |
| States | `ChatEmptyState`, `ChatLoadingState`, `ChatStatusBar` | Joining, unavailable history, loading, connection feedback |

Expand Down Expand Up @@ -220,6 +220,66 @@ The toolbar reserves its width beside the bubble and appears on hover, keyboard

`ChatReactionChip` is controlled through `selected`, `count`, and `onClick`; it does not update your reaction data. Supply `emoji` and an accessible `label` describing the action and count. Clicking or pressing Enter/Space toggles the reaction. With `reactorContent`, delayed hover, keyboard focus, or a long touch opens the list; also provide `reactorLabel`. The caller renders names, avatars, and a “You” marker, with a bounded scroll area for long lists. Set `animateEntrance` only for newly added chips.

## Timestamps, Markdown, and streaming

These parts cover replies written by an assistant or agent as well as by people. They render what you pass them: your application formats dates, parses Markdown, and owns the stream.

### Timestamps

<ComponentPreview name="p-chat-1" />

`ChatMessageTimestamp` renders a `<time>` element. Pass the machine value as `dateTime`, either a `Date` (serialized to an ISO 8601 instant) or a string you already hold, and the visible label as children. It doesn’t format or localize the label; format it with `Intl.DateTimeFormat` or your date library. Place it in the `footer` slot, where it takes the footer’s size and color, or in a header. Add a `title` with the full date if the label is relative or short.

```tsx
<ChatMessage
side="incoming"
footer={
<ChatMessageTimestamp dateTime={message.createdAt}>
{timeFormatter.format(message.createdAt)}
</ChatMessageTimestamp>
}
>
{message.text}
</ChatMessage>
```

### Markdown

<ComponentPreview name="p-chat-2" />

`ChatMessageMarkdown` styles the elements your Markdown parser renders: headings, paragraphs, lists, links, inline code, code blocks, tables, block quotes, and rules. Its text uses the same size and line height as `ChatMessageText`. Chat doesn’t include a parser. Pass the parser’s React output as children, for example with `react-markdown` and `remark-gfm` for tables:

```tsx
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";

<ChatMessage side="incoming" bubble={false}>
<ChatMessageMarkdown>
<Markdown remarkPlugins={[remarkGfm]}>{reply.text}</Markdown>
</ChatMessageMarkdown>
</ChatMessage>
```

If your parser returns an HTML string, sanitize it first, then pass it through `dangerouslySetInnerHTML`. Code blocks scroll horizontally. Tables keep their table semantics for screen readers, so they don’t scroll by themselves: wrap each table in an element through your renderer (for example a `table` component that returns `<div><table {...props} /></div>`) and that wrapper scrolls. An unwrapped wide table scrolls the whole Markdown block instead.

### Streaming replies

<ComponentPreview name="p-chat-3" />

Set `streaming` on `ChatMessage` (or `ChatMessageContent`) while text is still arriving. It adds `data-streaming` to the content so you can style the in-progress state. Append text to the same message rather than replacing it, and keep a stable key so the row isn’t remounted.

`ChatMessageCaret` is an optional end-of-text caret. Place it inside the last text block: as `ChatMessageText` children, or inside the final element your Markdown renderer produces. If it sits after a block, it lands on a line of its own, so leave it out when your renderer can’t place it. The caret takes no inline space, so showing or removing it never rewraps the text. It pulses unless the reader prefers reduced motion. Remove it when the stream ends.

```tsx
<ChatMessage side="incoming" streaming={streaming}>
<ChatMessageText text={reply.text}>
{streaming && <ChatMessageCaret />}
</ChatMessageText>
</ChatMessage>
```

To keep the row from jumping when the stream finishes, render the footer from the start, for example with a “Writing…” label that becomes the timestamp. These parts don’t announce anything. `ChatConversation` uses `live="polite"` by default, which can read every appended chunk, so set `live="off"` for streamed replies and announce phase changes, such as a finished or failed reply, from one application-owned status region.

## Composer and mentions

Use the shared [Composer](/ui/docs/components/composer) with a controlled `ComposerValue`, `onValueChange`, and `onSubmit`. Convert accepted drafts with `getComposerText(value).trim()` and clear them with `[]`. The minimal example uses `ComposerInput render={<textarea />}` for plain text. Mentions require the default contenteditable input.
Expand Down
30 changes: 27 additions & 3 deletions apps/ui/public/r/chat.json

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions apps/ui/public/r/p-chat-1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "p-chat-1",
"description": "Chat messages with machine-readable timestamps in the footer",
"registryDependencies": [
"@coss/chat"
],
"files": [
{
"path": "registry/default/particles/p-chat-1.tsx",
"content": "import { ChatMessage } from \"@/registry/default/chat/chat-message\";\nimport { ChatMessageTimestamp } from \"@/registry/default/chat/chat-message-timestamp\";\n\nexport default function Particle() {\n return (\n <div className=\"flex w-full max-w-sm flex-col gap-4\">\n <ChatMessage\n side=\"incoming\"\n author=\"Riley\"\n footer={\n <ChatMessageTimestamp\n dateTime=\"2026-10-17T09:41:00Z\"\n title=\"17 October 2026, 09:41\"\n >\n 09:41\n </ChatMessageTimestamp>\n }\n >\n Can we move the design review to Thursday?\n </ChatMessage>\n <ChatMessage\n side=\"outgoing\"\n footer={\n <ChatMessageTimestamp\n dateTime=\"2026-10-17T09:43:00Z\"\n title=\"17 October 2026, 09:43\"\n >\n 09:43\n </ChatMessageTimestamp>\n }\n >\n Thursday works. I’ll send the new invite.\n </ChatMessage>\n </div>\n );\n}\n",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"
},
"categories": [
"chat",
"time"
],
"type": "registry:block"
}
23 changes: 23 additions & 0 deletions apps/ui/public/r/p-chat-2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "p-chat-2",
"description": "Assistant reply with styled Markdown headings, lists, tables, and code",
"registryDependencies": [
"@coss/chat"
],
"files": [
{
"path": "registry/default/particles/p-chat-2.tsx",
"content": "import { ChatMessage } from \"@/registry/default/chat/chat-message\";\nimport { ChatMessageMarkdown } from \"@/registry/default/chat/chat-message-markdown\";\n\nexport default function Particle() {\n return (\n <ChatMessage\n side=\"incoming\"\n author=\"Assistant\"\n bubble={false}\n className=\"w-full max-w-md\"\n >\n <ChatMessageMarkdown className=\"px-1\">\n <h3>Interview schedule</h3>\n <p>\n The earliest window that works for every interviewer is{\" \"}\n <strong>Tuesday at 10:30</strong>. Check the{\" \"}\n <a href=\"#availability\">shared availability</a> before you send\n invites.\n </p>\n <ol>\n <li>Confirm each interviewer’s calendar</li>\n <li>\n Send booking links with a <code>15m</code> buffer\n </li>\n <li>Hold a short debrief after each call</li>\n </ol>\n <table>\n <thead>\n <tr>\n <th>Team</th>\n <th>Open slots</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Design</td>\n <td>4</td>\n </tr>\n <tr>\n <td>Engineering</td>\n <td>7</td>\n </tr>\n </tbody>\n </table>\n <pre>\n <code>{\"buffer: 15\\nminimumNotice: 24h\"}</code>\n </pre>\n <blockquote>\n <p>Keep Tuesday morning free for focus time.</p>\n </blockquote>\n </ChatMessageMarkdown>\n </ChatMessage>\n );\n}\n",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md"
},
"categories": [
"chat",
"text editor"
],
"type": "registry:block"
}
27 changes: 27 additions & 0 deletions apps/ui/public/r/p-chat-3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "p-chat-3",
"description": "Streaming reply with a trailing caret that never shifts layout",
"dependencies": [
"lucide-react"
],
"registryDependencies": [
"@coss/button",
"@coss/chat"
],
"files": [
{
"path": "registry/default/particles/p-chat-3.tsx",
"content": "\"use client\";\n\nimport { RotateCcwIcon } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { ChatMessage } from \"@/registry/default/chat/chat-message\";\nimport { ChatMessageCaret } from \"@/registry/default/chat/chat-message-caret\";\nimport { ChatMessageText } from \"@/registry/default/chat/chat-message-text\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nconst reply =\n \"Tuesday at 10:30 works for every interviewer. I’ve held the slot and added a 15-minute buffer before the next call.\";\nconst chunks = reply.match(/\\S+\\s*/g) ?? [];\n\nexport default function Particle() {\n const [count, setCount] = useState(0);\n const streaming = count < chunks.length;\n\n useEffect(() => {\n if (count >= chunks.length) return;\n const timeout = window.setTimeout(() => setCount(count + 1), 90);\n return () => window.clearTimeout(timeout);\n }, [count]);\n\n return (\n <div className=\"flex w-full max-w-sm flex-col items-start gap-4\">\n <ChatMessage\n side=\"incoming\"\n author=\"Assistant\"\n className=\"w-full\"\n streaming={streaming}\n footer={streaming ? \"Writing…\" : \"Just now\"}\n >\n <ChatMessageText text={chunks.slice(0, count).join(\"\")}>\n {streaming && <ChatMessageCaret />}\n </ChatMessageText>\n </ChatMessage>\n <Button\n disabled={streaming}\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setCount(0)}\n >\n <RotateCcwIcon aria-hidden=\"true\" />\n Replay\n </Button>\n </div>\n );\n}\n",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"
},
"categories": [
"chat",
"loading"
],
"type": "registry:block"
}
87 changes: 87 additions & 0 deletions apps/ui/public/r/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,11 @@
"type": "registry:component",
"target": "@components/chat/chat-message-bubble.tsx"
},
{
"path": "registry/default/chat/chat-message-caret.tsx",
"type": "registry:component",
"target": "@components/chat/chat-message-caret.tsx"
},
{
"path": "registry/default/chat/chat-message-info.tsx",
"type": "registry:component",
Expand All @@ -1068,11 +1073,21 @@
"type": "registry:component",
"target": "@components/chat/chat-message-layout.tsx"
},
{
"path": "registry/default/chat/chat-message-markdown.tsx",
"type": "registry:component",
"target": "@components/chat/chat-message-markdown.tsx"
},
{
"path": "registry/default/chat/chat-message-text.tsx",
"type": "registry:component",
"target": "@components/chat/chat-message-text.tsx"
},
{
"path": "registry/default/chat/chat-message-timestamp.tsx",
"type": "registry:component",
"target": "@components/chat/chat-message-timestamp.tsx"
},
{
"path": "registry/default/chat/chat-message.tsx",
"type": "registry:component",
Expand Down Expand Up @@ -1123,6 +1138,11 @@
"type": "registry:component",
"target": "@components/chat/hooks/use-chat-scroll.ts"
},
{
"path": "registry/default/chat/lib/get-chat-date-time.ts",
"type": "registry:component",
"target": "@components/chat/lib/get-chat-date-time.ts"
},
{
"path": "registry/default/chat/lib/split-chat-message-preview.ts",
"type": "registry:component",
Expand Down Expand Up @@ -4429,6 +4449,73 @@
],
"type": "registry:block"
},
{
"categories": [
"chat",
"time"
],
"description": "Chat messages with machine-readable timestamps in the footer",
"files": [
{
"path": "registry/default/particles/p-chat-1.tsx",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"
},
"name": "p-chat-1",
"registryDependencies": [
"@coss/chat"
],
"type": "registry:block"
},
{
"categories": [
"chat",
"text editor"
],
"description": "Assistant reply with styled Markdown headings, lists, tables, and code",
"files": [
{
"path": "registry/default/particles/p-chat-2.tsx",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md"
},
"name": "p-chat-2",
"registryDependencies": [
"@coss/chat"
],
"type": "registry:block"
},
{
"categories": [
"chat",
"loading"
],
"dependencies": [
"lucide-react"
],
"description": "Streaming reply with a trailing caret that never shifts layout",
"files": [
{
"path": "registry/default/particles/p-chat-3.tsx",
"type": "registry:block"
}
],
"meta": {
"className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"
},
"name": "p-chat-3",
"registryDependencies": [
"@coss/button",
"@coss/chat"
],
"type": "registry:block"
},
{
"categories": [
"checkbox"
Expand Down
Loading
Loading