Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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, and onSubmit, onStop or a resolveLabel in markdownProps are 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 on use-stick-to-bottom and streamdown, 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:

terminal
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/ui would make every Comitor app install Tiptap. Folding them into /editor would 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.

PartWhat it does
ChatThreadThe 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.
ChatMessageOne 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.
ChatStepsWhat 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 · ChatCitationNumbered 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.
ChatComposerA 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.

The assistant can read the tasks of DS · Design system.

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.

Priya Raman shared this conversation with the DS team.
Leo MarchandDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
  1. Searched DS for “lockout” — Xong
    3 tasks, 1 incident
  2. Opened incident INC-204 — Lỗi
    You don’t have access to this incident.
  3. Compared the lockout timings — Chờ

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 aiGenerated on 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 aiGenerated and aiGeneratedHint keys, 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.

Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.

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:

WhenEnter does
The @ or / menu is openEnter 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, ChineseEnter commits the composed text. view.composing is checked first, so half a Vietnamese word is never sent.
An image is still uploadingChatComposer 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+EnterA line break inside the same paragraph or list item. Stored as a Markdown hard break.
⌘/Ctrl+EnterAlso 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.

tsx
// 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.

Bạn
Bạn
Trợ lýDo AI tạoNội dung do AI tạo. Hãy kiểm tra các chi tiết quan trọng trước khi dùng.
  1. Đọc 12 việc — Xong
  2. Đang xem lịch của nhóm — Đang làm
  3. Viết câu trả lời — Chờ

Đang suy nghĩ…

  • labels on each part is Partial<ChatLabels>, merged key by key: the prop wins, then the nearest provider, then English.
  • Three keys are functions — stepsSummary, sources and citation — 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.
KeyEnglishTiếng Việt
logConversationCuộc trò chuyện
scrollToLatestScroll to latest messageCuộn tới tin mới nhất
aiGeneratedAI-generatedDo AI tạo
aiGeneratedHintGenerated 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.
youYouBạn
assistantAssistantTrợ lý
thinkingThinking…Đang suy nghĩ…
stepsSummary(2, 3)2 of 3 steps2/3 bước
stepPendingPendingChờ
stepRunningIn progressĐang làm
stepDoneDoneXong
stepErrorFailedLỗi
sources(1) · sources(3)1 source · 3 sources1 nguồn · 3 nguồn
citation(2, title)Source 2: Decision logNguồn 2: Nhật ký quyết định
opensInNewTab(opens in a new tab)(mở trong thẻ mới)
composerLabelMessageTin nhắn
composerPlaceholderAsk anything… Type @ to mentionHỏi bất cứ điều gì… Gõ @ để nhắc tên
sendSendGửi
stopStop generatingDừ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

PropKiểuMặc địnhMô tả
busybooleanfalseThe 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 propsstring—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.
contentClassNamestringmax-w-3xl, centredClasses 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-labelstringlabels.logThe name of the log region — “Conversation” in English.
labelsPartial<ChatLabels>the chat namespaceThe thread reads log and scrollToLatest.
childrenReactNode—The ChatMessage elements, oldest first.

ChatMessage

PropKiểuMặc địnhMô tả
frombắt buộc"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.
contentstring""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.assistantWho 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.
metaReactNode—Next to the name — the time it was sent, as a RelativeTime, or what started an agent.
aiGeneratedbooleanfrom === '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.
streamingbooleanfalseThe 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.
stepsReactNode—A <ChatSteps />, drawn before the answer. Assistant messages only.
actionsReactNode—Under the message — copy, rate, retry. On an assistant message they wait until streaming ends.
markdownPropsOmit<MarkdownEditorProps, 'value' | 'defaultValue' | 'onChange' | 'editable' | 'variant'>—Passed to the read-only MarkdownEditor that draws content — mentions (resolveLabel, getHref), resolveImageSrc and the rest.
labelsPartial<ChatLabels>the chat namespaceOne key at a time.
childrenReactNode—After the answer: a <ChatSources />, attachments, or an answer the app draws itself.
className · article propsstring—Land on the <article>.

ChatSteps

PropKiểuMặc địnhMô tả
stepsbắt buộcChatStepItem[]—{ 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.
openboolean—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.
labelsPartial<ChatLabels>the chat namespacestepsSummary and the four step-status words.

ChatSources

PropKiểuMặc địnhMô tả
sourcesbắt buộcChatSource[]—{ 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.
labelsPartial<ChatLabels>the chat namespacesources(count) — the heading and the section’s name — and opensInNewTab.
className · section propsstring—Land on the <section>.

ChatCitation

PropKiểuMặc địnhMô tả
indexbắt buộcnumber—The number drawn in the chip, counted from 1 — match the source’s place in ChatSources.
sourcebắt buộcChatSource—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>).
labelsPartial<ChatLabels>the chat namespacecitation(index, title) — the chip’s accessible name — and opensInNewTab.
className · anchor propsstring—Land on the <a>; href and children are the part’s.

ChatComposer

PropKiểuMặc địnhMô tả
valuebắt buộcstring—The Markdown being written. Controlled: after sending, set it back to an empty string.
onChangebắt buộc(markdown: string) => void—Every change.
onSubmitbắt buộc(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.
streamingbooleanfalseThe 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.
disabledbooleanfalseLocks the composer: the editor goes to read mode and the frame to bg-muted — never opacity, so its border still reads.
placeholderstringlabels.composerPlaceholderShown while empty.
mentionsMarkdownEditorMentions—Turns @ on — the same { items, resolveLabel?, getHref? } as MarkdownEditor.
toolbarReactNode—A row of tools to the left of the send button — attach, pick a model.
editorPropsOmit<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.
editorRefRef<MarkdownEditorHandle>—The editor’s handle — focus(), clear(), isUploading().
labelsPartial<ChatLabels>the chat namespacecomposerLabel, 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 ChatCitation for it jumps to #chat-source- plus its id. That id is written once per ChatSources, 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.
  • system content 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; author only 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. While busy it carries aria-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 (tabIndex 0, with the package’s outline on focus-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 href is 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 MarkdownEditor named “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