Composites
Chat
The conversation frame for an AI assistant and its agents: a log that follows the newest text, messages drawn from Markdown with an AI-generated label, the steps the agent is taking, sources a reader can open, and a composer where Enter sends and Stop interrupts. The conversation itself — the messages, the model, the stream — stays in your app.
Messaging between people — channels, direct messages, presence — is the Chat pattern, a different screen built from Mention, Item and Relative Time.
- Import
import { ChatThread, ChatMessage, ChatSteps, ChatSources, ChatComposer } from '@comitor/ui/chat'— an entry of its own, with the same optional peers as@comitor/ui/editor.- Client
- Every file starts with
"use client". A Server Component can render a finished, read-only transcript — content, author and sources are plain data — but a live conversation needs state, andonSubmit,onStopor aresolveLabelinmarkdownPropsare functions, so it lives in a client component of yours. - Origin
- New in
3.12.0, built for the assistant in Comitor Tasks and its agents, and for the Chat app later. The layout takes after prompt-kit (MIT, © 2025 Julien Thibeaut) and Vercel’s AI Elements (Apache-2.0), with no code copied: both lean onuse-stick-to-bottomandstreamdown, a second Markdown renderer — exactly what the package avoids. See the changelog.
Its own entry, the editor’s peers
Every message is drawn by the same read-only MarkdownEditor that wrote it, and the composer is a MarkdownEditor too — so the entry needs exactly the optional peers of /editor. An app that already has the editor installs nothing; one that does not installs the Tiptap set, all at one version:
pnpm add @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/markdown \
@tiptap/extensions @tiptap/extension-list @tiptap/extension-table @tiptap/extension-image \
@tiptap/extension-mention @tiptap/suggestion @tiptap/extension-paragraph \
@tiptap/extension-code-block-lowlight lowlight highlight.js- Why not the main entry. Putting these parts behind
@comitor/uiwould make every Comitor app install Tiptap. Folding them into/editorwould cost no bytes — the package is not bundled — but would mix an input with a whole screen. - One renderer, not two. A mention written as
[@Priya Raman](mention:u1), a table, a checklist, a code block: a message shows them exactly as a task description does, because it is the same schema. A second Markdown renderer for reading is a second place to drift — a mention printed as a raw link, say. - The editor bundle comes with it — about 234 KB gzipped, the figure on the Markdown Editor page. An assistant panel that opens on demand can load with
next/dynamic.
Five parts
Each part draws one thing and holds no conversation state. ChatSteps and ChatSources slot into a ChatMessage; the messages go inside a ChatThread; the composer sits under the thread.
| Part | What it does |
|---|---|
ChatThread | The scrolling log. Follows the bottom while new text arrives and the reader is at the end; stops following when they scroll up, and offers a button back to the latest message. busy → aria-busy. |
ChatMessage | One message. A person: a bubble on the right. The assistant: avatar, name and the AI-generated label, across the column. system: a small centred line. Markdown is drawn by a read-only MarkdownEditor. |
ChatSteps | What the agent is doing: pending, running, done or failed, behind a one-line summary. Opens while a step runs or one failed; closes itself when all are done — until the reader toggles it. |
ChatSources · ChatCitation | Numbered sources under an answer, each a real link when it has an href; an inline [n] chip with a hover card, for answers the app draws itself. |
ChatComposer | A MarkdownEditor with the same @ and pasted images, plus a send button that becomes Stop while the agent answers. Enter sends, Shift+Enter breaks the line. |
An assistant in Comitor Tasks
Press Enter to send the question already in the box. The agent’s three steps run one after another, the answer arrives two words at a time — a table, a numbered list, two mentions — and the steps fold away once they are all done. Its sources come last: the tasks link inside the app, the OWASP page opens a new tab, and the calendar has no link at all. Press Stop halfway and the timer really stops; the running step turns into a failure that says why. Ask again and a second script answers with a checklist and a code block.
busy=false · streaming=none · 1 messages in the app’s state
What the package does not do
It keeps no list of messages, calls no model and assembles no stream. Everything on the line under the demo is the app’s state: it appends the messages, pushes the text that has arrived into content, and turns busy and streaming on and off. The demo’s “model” is a queue of timers; yours is a fetch reader, the AI SDK or a server action. Each message is a read-only Tiptap editor, which holds up to a few hundred messages — page the history of a longer conversation.
One state at a time
A system line; a person’s reply that the assistant drafted for them, labelled with aiGenerated; an answer whose second step failed — the list stays open, and the failure is written out, not only coloured — with meta and actions under it; and an agent’s answer the app draws itself, with ChatCitation chips in the sentence. Hover or focus a chip for its card. The second chip has no href of its own, so it jumps to its entry in the list below. A ChatMessage needs no thread around it.
- Searched DS for “lockout” — Done3 tasks, 1 incident
- Opened incident INC-204 — FailedYou don’t have access to this incident.
- Compared the lockout timings — Pending
The limit is five codes an hour per address1, and the lockout lasts fifteen minutes since the decision of 14 October2.
- DS-388 · Rate-limit the one-time codesFive codes an hour per address, then a fifteen-minute lockout.
- Decision log — 14 OctoberLockout lengthened from five to fifteen minutes after INC-198.
The AI-generated label
Every assistant message carries it by default — Comitor’s requirement FR-AI-018 is that text written by a model says so where it is read. It is a small sparkle chip next to the name, with “Generated by AI. Check important details before relying on them.” as its tooltip and as a visually hidden description, so a screen reader hears the warning too.
- On a person’s message, opt in. When the assistant drafts a reply that someone then sends as their own, set
aiGeneratedon that message — the label follows the text, not the sender. - Turning it off on an assistant message is
aiGenerated={false}. Do that only where the text is not the model’s — a canned notice the app wrote, for instance. - The wording is the
aiGeneratedandaiGeneratedHintkeys, overridable like every other string.
Following the newest text
The thread watches the size of its column of messages, not its children — streaming text grows a message without adding one, and an image that finishes loading grows it without React knowing. If the reader is within 48px of the end, it scrolls along. If they have scrolled up to reread, it leaves them there and shows a round ↓ button that takes them back and resumes following. Press the button below, then scroll up while the answer streams.
The thread opens at its last message. The scroll area is a tab stop of its own, so a keyboard-only reader can scroll the history with the arrow keys — Chrome makes scrollable regions focusable by itself, Safari does not.
Enter to send
ChatComposer turns on submitOnEnter, a prop that both editors gained in 3.12.0: bare Enter calls onSubmit, Shift+Enter breaks the line. An Enter that sends is easy to get wrong in three places, and each one is handled:
| When | Enter does |
|---|---|
| The @ or / menu is open | Enter picks the highlighted item. The menu is read from the editor’s own store, because the package’s keymap runs before the suggestion plugin. |
| An IME is composing — Telex or VNI through the system input method, Japanese, Chinese | Enter commits the composed text. view.composing is checked first, so half a Vietnamese word is never sent. |
| An image is still uploading | ChatComposer does not send: the image is not in the Markdown until its upload returns. This one lives in the composer, not in submitOnEnter. |
| The agent is answering (streaming) | Enter does nothing and the draft stays. Keep typing the next question; it goes once the answer is done or stopped. |
| Shift+Enter | A line break inside the same paragraph or list item. Stored as a Markdown hard break. |
| ⌘/Ctrl+Enter | Also sends — the comment-box shortcut of both editors still applies. Inside a checklist it ticks the item instead. |
The upload guard is the composer’s, not the prop’s. On a bare MarkdownEditor or RichTextEditor with submitOnEnter, Enter calls onSubmit even while an image uploads — check isUploading() there yourself, as the comment box on the Markdown Editor page does. Without an onSubmit the prop does nothing.
// ChatComposer turns it on for you. Any MarkdownEditor or RichTextEditor can take it too:
const ref = useRef<MarkdownEditorHandle>(null)
<MarkdownEditor
ref={ref}
value={draft}
onChange={setDraft}
submitOnEnter // bare Enter calls onSubmit; Shift+Enter breaks the line
onSubmit={() => {
// The editor yields to an open @ or / menu and to an IME composition — but NOT to an upload.
// ChatComposer checks that itself; a bare editor has to:
if (!draft.trim() || ref.current?.isUploading()) return
send(draft)
ref.current?.clear()
}}
mentions={{ items: members }}
/>Language
The strings live in the chat namespace and follow the nearest ComitorLocaleProvider — English by default, Vietnamese built in (set the language once, at the root). The demo starts in Vietnamese: the label, “Thinking…”, the step summary and statuses, the source count, the placeholder and the name of the Stop button are the provider’s. The messages are the app’s, translated by the app. Switch languages, then press Stop.
- Đọc 12 việc — Xong
- Đang xem lịch của nhóm — Đang làm
- Viết câu trả lời — Chờ
Đang suy nghĩ…
labelson each part isPartial<ChatLabels>, merged key by key: the prop wins, then the nearest provider, then English.- Three keys are functions —
stepsSummary,sourcesandcitation— because a number sits in the middle of the sentence and plurals differ by language. Functions cannot cross from a Server Component into a client one, so a table that overrides them is declared in a client component. - The provider picks strings; it does not set
lang. Keep<html lang>in step with it, as the demo does for its own region.
| Key | English | Tiếng Việt |
|---|---|---|
log | Conversation | Cuộc trò chuyện |
scrollToLatest | Scroll to latest message | Cuộn tới tin mới nhất |
aiGenerated | AI-generated | Do AI tạo |
aiGeneratedHint | Generated by AI. Check important details before relying on them. | Nội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng. |
you | You | Bạn |
assistant | Assistant | Trợ lý |
thinking | Thinking… | Đang suy nghĩ… |
stepsSummary(2, 3) | 2 of 3 steps | 2/3 bước |
stepPending | Pending | Chờ |
stepRunning | In progress | Đang làm |
stepDone | Done | Xong |
stepError | Failed | Lỗi |
sources(1) · sources(3) | 1 source · 3 sources | 1 nguồn · 3 nguồn |
citation(2, title) | Source 2: Decision log | Nguồn 2: Nhật ký quyết định |
opensInNewTab | (opens in a new tab) | (mở trong thẻ mới) |
composerLabel | Message | Tin nhắn |
composerPlaceholder | Ask anything… Type @ to mention | Hỏi bất cứ điều gì… Gõ @ để nhắc tên |
send | Send | Gửi |
stop | Stop generating | Dừng trả lời |
Read live from comitorMessagesEn.chat and comitorMessagesVi.chat, the function keys called with the arguments shown.
Props
The data types — ChatRole, ChatStepItem, ChatStepStatus, ChatSource and ChatLabels — are exported from the same entry, as types only.
ChatThread
| Prop | Type | Default | Description |
|---|---|---|---|
busy | boolean | false | The agent is answering. Sets aria-busy on the log, so a screen reader waits and reads the finished message once — instead of reading every chunk again, dozens of times a sentence. |
className · div props | string | — | Land on the outer frame, relative flex min-h-0 flex-col. Give it a bounded height — a fixed one, or flex-1 inside a flex column: the scroll area inside is flex-1 min-h-0, so an unbounded thread just grows with its content and never scrolls. |
contentClassName | string | max-w-3xl, centred | Classes for the column of messages inside the scroll area — mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6 by default, merged with cn. |
aria-label | string | labels.log | The name of the log region — “Conversation” in English. |
labels | Partial<ChatLabels> | the chat namespace | The thread reads log and scrollToLatest. |
children | ReactNode | — | The ChatMessage elements, oldest first. |
ChatMessage
| Prop | Type | Default | Description |
|---|---|---|---|
fromrequired | "user" | "assistant" | "system" | — | Who speaks. user is a bubble on the right; assistant spans the column with avatar, name and the AI-generated label; system is a small centred line. |
content | string | "" | Markdown, in MarkdownEditor’s stored format — mentions are [@Name](mention:id). While the answer streams, keep changing it: the read-only editor reloads whenever the value differs. For system it is plain text, not Markdown. |
author | { name: string; avatarUrl?: string | null } | labels.you / labels.assistant | Who said it. On an assistant message it replaces the default sparkle avatar with a LetterAvatar and shows the name. On a person’s message the name is only read by a screen reader — the bubble draws no name or avatar. |
meta | ReactNode | — | Next to the name — the time it was sent, as a RelativeTime, or what started an agent. |
aiGenerated | boolean | from === 'assistant' | The AI-generated label. On by default for the assistant, off for people. Set it on a person’s message whose text the assistant drafted for them. |
streaming | boolean | false | The answer is still arriving: “Thinking…” while content is empty, a pulsing caret once it has text. Also sets aria-busy on the message and hides actions until it ends. |
steps | ReactNode | — | A <ChatSteps />, drawn before the answer. Assistant messages only. |
actions | ReactNode | — | Under the message — copy, rate, retry. On an assistant message they wait until streaming ends. |
markdownProps | Omit<MarkdownEditorProps, 'value' | 'defaultValue' | 'onChange' | 'editable' | 'variant'> | — | Passed to the read-only MarkdownEditor that draws content — mentions (resolveLabel, getHref), resolveImageSrc and the rest. |
labels | Partial<ChatLabels> | the chat namespace | One key at a time. |
children | ReactNode | — | After the answer: a <ChatSources />, attachments, or an answer the app draws itself. |
className · article props | string | — | Land on the <article>. |
ChatSteps
| Prop | Type | Default | Description |
|---|---|---|---|
stepsrequired | ChatStepItem[] | — | { id, label, status, detail? } — label and detail are ReactNode; status is 'pending' | 'running' | 'done' | 'error'. A failed step’s detail is drawn in --destructive-ink. |
open | boolean | — | Controlled. Leave it out and the list manages itself: open while a step is pending or running, or when one failed; closed once every step is done. The first click by the reader turns the automatic part off. |
onOpenChange | (open: boolean) => void | — | Every toggle by the reader. |
labels | Partial<ChatLabels> | the chat namespace | stepsSummary and the four step-status words. |
ChatSources
| Prop | Type | Default | Description |
|---|---|---|---|
sourcesrequired | ChatSource[] | — | { id, title, href?, description? }, numbered from 1 in this order — the numbers ChatCitation uses. With an href a source is an <a>; an http(s) one opens a new tab and says so. Without, a plain card. Without a description, an external source shows its host. An empty array renders nothing. |
labels | Partial<ChatLabels> | the chat namespace | sources(count) — the heading and the section’s name — and opensInNewTab. |
className · section props | string | — | Land on the <section>. |
ChatCitation
| Prop | Type | Default | Description |
|---|---|---|---|
indexrequired | number | — | The number drawn in the chip, counted from 1 — match the source’s place in ChatSources. |
sourcerequired | ChatSource | — | Its title and description fill the hover card. Its href is the chip’s; without one the chip jumps to the matching item of ChatSources on the page (#chat-source-<id>). |
labels | Partial<ChatLabels> | the chat namespace | citation(index, title) — the chip’s accessible name — and opensInNewTab. |
className · anchor props | string | — | Land on the <a>; href and children are the part’s. |
ChatComposer
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | The Markdown being written. Controlled: after sending, set it back to an empty string. |
onChangerequired | (markdown: string) => void | — | Every change. |
onSubmitrequired | (markdown: string) => void | — | Enter or the send button. Not called while the draft is blank, while an image is still uploading, while streaming, or when disabled. |
streaming | boolean | false | The agent is answering: the send button becomes Stop, and Enter sends nothing. Typing still works. |
onStop | () => void | — | The Stop button. Without it, Stop is drawn disabled. Abort your request here. |
disabled | boolean | false | Locks the composer: the editor goes to read mode and the frame to bg-muted — never opacity, so its border still reads. |
placeholder | string | labels.composerPlaceholder | Shown while empty. |
mentions | MarkdownEditorMentions | — | Turns @ on — the same { items, resolveLabel?, getHref? } as MarkdownEditor. |
toolbar | ReactNode | — | A row of tools to the left of the send button — attach, pick a model. |
editorProps | Omit<MarkdownEditorProps, 'value' | 'onChange' | 'onSubmit' | 'submitOnEnter' | 'mentions' | 'placeholder' | 'variant' | 'ref'> | — | Everything else for the editor inside — onImageUpload, resolveImageSrc, extensions, aria-label (default labels.composerLabel). contentClassName merges over max-h-48 min-h-10 overflow-y-auto. |
editorRef | Ref<MarkdownEditorHandle> | — | The editor’s handle — focus(), clear(), isUploading(). |
labels | Partial<ChatLabels> | the chat namespace | composerLabel, composerPlaceholder, send and stop. |
Known limitations
- Enter never continues a list. In the composer, Enter sends from anywhere outside an open menu — inside a list item too. Shift+Enter is a line break within the same item, so a second bullet has to be typed as a new line starting
-. - A source without an href is a citation target, not a link. Its card is a plain box, and a
ChatCitationfor it jumps to#chat-source-plus its id. That id is written once perChatSources, so the same source listed under two answers on one page gives two elements the same id — the chip lands on the first. Give sources ids that are unique on the page. systemcontent is plain text. It is not passed through the editor, so Markdown in it is printed as typed.- A person’s message shows no name or avatar. The bubble is meant for the one person typing;
authoronly names it for a screen reader. A conversation shared among several people is the Chat pattern’s job. - A few hundred messages. Each one is a read-only editor; a longer history should be paged by the app.
Accessibility
- The thread is a
role="log"named “Conversation”, so a screen reader announces what is added to it. Whilebusyit carriesaria-busy, and so does the streaming message itself: the reader waits for the finished answer instead of hearing each chunk. - The scroll area takes focus (
tabIndex0, with the package’s outline onfocus-visible), so the history scrolls from the keyboard. The ↓ button that appears after scrolling up is named “Scroll to latest message”. - Each message is an
<article>labelled by its speaker’s name — visible for the assistant, visually hidden for a person. The AI label’s explanation is a visually hidden description, not only a tooltip. - Every step carries its status in words after its label — “— In progress”, “— Failed” — so the spinner, the tick and the cross are never the only signal. The summary line is a real button that opens and closes the list.
- A source with an
hrefis a real<a href>: it opens with the keyboard, copies and middle-clicks like any link. An external one opens a new tab and says “(opens in a new tab)” to a screen reader. A citation chip is named “Source 2: Decision log”, not “2”, and its hover card also opens on focus. - The composer is a
MarkdownEditornamed “Message”, with the editor’s keyboard menus for@and/. Send and Stop are icon buttons with names. A disabled composer keeps a readable border — it changes background, never opacity.
Related
Markdown Editor
What draws every message and what the composer is — the same schema, the same @, the same stored format.
Chat pattern
Messaging between people: channels, direct messages, presence and read state. A different screen from this one.
Mention
@-mentions in a plain textarea, for composers that need no Markdown and no editor bundle.
Relative Time
“3 minutes ago” beside a message, through meta — with the absolute time in its tooltip.