# Chat Thread

> Lay out a conversation that scrolls, with a place for the composer under it.

Source: https://ai.honestui.com/docs/components/chat-thread

```tsx
"use client"

import * as React from "react"

import { ChatMessage, ChatThread } from "@/components/ai/chat-thread"
import { PromptBar } from "@/components/ai/prompt-bar"

type Message = { id: number; role: "user" | "assistant"; text: string }

// Sample conversation.
const sampleMessages: Message[] = [
  { id: 1, role: "user", text: "Why are export totals off by a cent?" },
  {
    id: 2,
    role: "assistant",
    text: "Each row is rounded before the rows are summed. Rounding once, after the sum, removes the drift.",
  },
]

/** No model replies here. A sent prompt is added to the thread as a user message. */
export function ChatThreadDemo() {
  const [messages, setMessages] = React.useState(sampleMessages)

  return (
    <div className="h-96 w-full max-w-md min-w-0">
      <ChatThread
        label="Conversation with the billing agent"
        footer={
          <PromptBar
            placeholder="Write a message…"
            onSubmit={({ text }) =>
              setMessages((current) => [...current, { id: current.length + 1, role: "user", text }])
            }
          />
        }
      >
        {messages.map((message) => (
          <ChatMessage
            key={message.id}
            role={message.role}
            author={message.role === "user" ? "You" : "Billing agent"}
          >
            {message.text}
          </ChatMessage>
        ))}
      </ChatThread>
    </div>
  )
}

```

## Overview [#overview]

Chat Thread is the frame of a chat: a scrolling list of messages and a slot under it for the composer. Put a [Prompt Bar](/docs/components/prompt-bar) in the footer and render one `ChatMessage` for each message you hold.

A message can also show the files sent with it, an editor for changing and resending it, and arrows for moving between its versions. Each appears only when you pass the props for it.

It stores no messages and sends nothing. Your application owns the conversation.

Chat Thread replaces the Chat panel from Beautiful UI, which played a scripted reply.

## Anatomy [#anatomy]

<Anatomy title="ChatThread">
  <AnatomyItem name="ChatThread" description="The frame, the scrolling log, and the footer" />

  <AnatomyItem name="ChatMessage" description="One message, with its author">
    <AnatomyItem name="Attachments" description="The files sent with the message" />

    <AnatomyItem name="Editor" description="A text area with Cancel and Send, in place of the message while it is edited" />

    <AnatomyItem name="Versions" description="Arrows and a position, such as 2 of 3" />

    <AnatomyItem name="Edit" description="Opens the editor" />
  </AnatomyItem>

  <AnatomyItem name="Footer" description="Your composer" />
</Anatomy>

Installing `chat-thread` brings [Button](https://www.honestui.com/docs/components/button) along as a registry dependency.

## Behavior [#behavior]

### Scrolling [#scrolling]

When a message is added, the thread scrolls to it. If the person has scrolled up to read, the thread leaves their position alone until they return to the end.

### Messages [#messages]

A `user` message sits in a bubble on the trailing side. An `assistant` message is plain text with its author above it. Message content is yours: text, [Streaming Text](/docs/components/streaming-text), [Tool Chips](/docs/components/tool-chips), or any other component. Set `announce` to `false` on a Streaming Text inside the thread, because the log already announces new text.

### Attachments [#attachments]

`attachments` lists the files that were sent with a message, above it. Each has a name and can have a short note, such as its size. Passing `href` makes the name a link. Chat Thread does not read, upload, or preview the files.

### Editing [#editing]

Passing `editText` and `onEdit` adds an Edit button under the message. `editText` is the message as plain text, because the message's children may be elements. Edit swaps the message for a text area holding that text, with Cancel and Send.

Send calls `onEdit` with the trimmed text and waits for it. If the promise you return rejects, or the handler throws, the editor stays open with the person's changes and an error line. When it resolves, the editor closes. Send is disabled while the text is empty.

What an edit does to the conversation is your application's decision. Most products send the edited text as a new version of the message and discard the replies that followed the old one. If yours does, say so near the editor, since `onEdit` gives no warning of it.

The Edit button is always visible, so it does not depend on hover.

### Versions [#versions]

Editing a message, or asking for a reply again, leaves more than one version of it. Pass `version`, counting from 1, and `versionCount`. When there is more than one version, the message shows arrows and its position, such as "2 of 3". The arrows call `onVersionChange` with the version to show. Rendering that version's content is your application's work.

### Empty [#empty]

With no messages, `emptyMessage` is centered in the thread.

### Height [#height]

The thread fills its parent's height. Give the parent a height, or the thread will grow with its content and never scroll.

## Accessibility [#accessibility]

The message list is a log named by `label`, so screen readers announce messages as they are added, in order. It can take focus, so it can be scrolled from the keyboard.

Every message has an author. On user messages the author is read but not shown. Set `hideAuthor` to do the same for an assistant message.

Attachments are a list named "Attachments". Edit and the two version arrows are buttons with names, and an arrow that has nowhere to go is marked disabled but keeps focus. The editor is a text area named "Message". Opening it moves focus into it, and closing it returns focus to Edit. A failed send is an alert.

Every string in a message comes from its `labels`.

The previews have been checked with a keyboard and with axe. They have not been checked with a screen reader yet. The editor opens inside the log, and screen readers differ in how they announce a change there.

| Key                                 | Action                                           |
| ----------------------------------- | ------------------------------------------------ |
| <kbd>Tab</kbd>                      | Move to the message list, then into the composer |
| Arrow keys                          | Scroll the focused message list                  |
| <kbd>Enter</kbd>                    | Send the edit, from the editor                   |
| <kbd>Shift</kbd> + <kbd>Enter</kbd> | Add a line in the editor                         |
| <kbd>Escape</kbd>                   | Close the editor and discard the changes         |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/chat-thread&#x22;]" />
  

  
    
      
        Add the component it is built from.
      

      <CliBlock commands="[&#x22;button&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/chat-thread.tsx

```tsx
"use client"

import * as React from "react"
import {
  ChevronLeft as ChevronLeftIcon,
  ChevronRight as ChevronRightIcon,
  CircleAlert as CircleAlertIcon,
  File as FileIcon,
  Pencil as PencilIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

export type ChatThreadProps = React.ComponentProps<"div"> & {
  /** Names the conversation for screen readers. */
  label: string
  /** Shown while there are no messages. */
  emptyMessage?: React.ReactNode
  /** Fixed under the messages. Put your Prompt Bar here. */
  footer?: React.ReactNode
}

/**
 * A scrolling conversation with a slot for the composer under it. It keeps
 * the newest message in view unless the person has scrolled up to read.
 */
function ChatThread({
  label,
  emptyMessage,
  footer,
  className,
  children,
  ...props
}: ChatThreadProps) {
  const scrollRef = React.useRef<HTMLDivElement>(null)
  const isPinnedToEnd = React.useRef(true)
  const messageCount = React.Children.count(children)

  React.useEffect(() => {
    const scroller = scrollRef.current
    if (scroller && isPinnedToEnd.current) scroller.scrollTop = scroller.scrollHeight
  })

  return (
    <div
      data-slot="chat-thread"
      className={cn(
        "flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden rounded-[var(--hui-radius-5)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-base-primary)]",
        className
      )}
      {...props}
    >
      {/* A log announces messages as they are added, in order. Focusable so it can be scrolled by keyboard. */}
      <div
        ref={scrollRef}
        role="log"
        aria-label={label}
        tabIndex={0}
        onScroll={(event) => {
          const scroller = event.currentTarget
          isPinnedToEnd.current =
            scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 24
        }}
        // `relative` keeps absolutely positioned content, such as visually hidden
        // labels, inside this scroller. Without it they extend the page instead.
        className="relative flex min-h-0 flex-1 flex-col gap-[var(--hui-space-5)] overflow-y-auto p-[var(--hui-space-5)] outline-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset)]"
      >
        {messageCount === 0 && emptyMessage ? (
          <div className="m-auto text-center text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [line-height:var(--hui-line-height-regular)]">
            {emptyMessage}
          </div>
        ) : (
          children
        )}
      </div>
      {footer && (
        <div data-slot="chat-thread-footer" className="shrink-0 p-[var(--hui-space-3)] pt-0">
          {footer}
        </div>
      )}
    </div>
  )
}

export type ChatAttachment = {
  name: string
  /** Makes the name a link to the file. */
  href?: string
  /** A short note after the name, such as "48 KB". */
  meta?: string
}

export type ChatMessageLabels = {
  attachments: string
  edit: string
  editField: string
  cancel: string
  resend: string
  editError: string
  previousVersion: string
  nextVersion: string
  version: (current: number, total: number) => string
}

export const defaultChatMessageLabels: ChatMessageLabels = {
  attachments: "Attachments",
  edit: "Edit message",
  editField: "Message",
  cancel: "Cancel",
  resend: "Send",
  editError: "The edit was not sent. Your changes are still here.",
  previousVersion: "Previous version",
  nextVersion: "Next version",
  version: (current, total) => `${current} of ${total}`,
}

const MAX_EDITOR_HEIGHT = 240

const smallText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

export type ChatMessageProps = React.ComponentProps<"div"> & {
  /** "user" sets the message in a bubble on the trailing side. */
  role: "user" | "assistant"
  /** Who is speaking, read before the message. Shown above assistant messages. */
  author: string
  /**
   * Hides the author and `meta` line from view. It is still read aloud, so a
   * screen reader can tell who is speaking. A user message always hides it.
   */
  hideAuthor?: boolean
  /** A short note beside the author, such as a time or a duration. */
  meta?: string
  /** The files sent with the message, listed above it. */
  attachments?: ChatAttachment[]
  /** The message as plain text, which fills the editor. Edit needs it and `onEdit`. */
  editText?: string
  /** Receives the edited text. Throw, or reject, to report that it was not sent. */
  onEdit?: (text: string) => void | Promise<void>
  /** Which version of the message is shown, counting from 1. */
  version?: number
  /** How many versions exist. More than one shows the buttons to move between them. */
  versionCount?: number
  onVersionChange?: (version: number) => void
  labels?: Partial<ChatMessageLabels>
}

/**
 * One message in a `ChatThread`, with its attachments, an editor, and a way
 * to move between its versions. Each of those appears only when you pass it.
 */
function ChatMessage({
  role,
  author,
  hideAuthor = false,
  meta,
  attachments = [],
  editText,
  onEdit,
  version,
  versionCount = 1,
  onVersionChange,
  labels: labelsProp,
  className,
  children,
  ...props
}: ChatMessageProps) {
  const labels = { ...defaultChatMessageLabels, ...labelsProp }
  const isUser = role === "user"
  const canEdit = editText !== undefined && onEdit !== undefined
  const hasVersions = version !== undefined && versionCount > 1

  const [isEditing, setIsEditing] = React.useState(false)
  const [draft, setDraft] = React.useState("")
  const [phase, setPhase] = React.useState<"idle" | "sending" | "error">("idle")
  const editorRef = React.useRef<HTMLTextAreaElement>(null)
  const editButtonRef = React.useRef<HTMLButtonElement>(null)
  const hasEdited = React.useRef(false)

  // Opening the editor replaces the Edit button, and closing it brings the
  // button back. Focus follows, so it is never left on a removed control.
  React.useEffect(() => {
    if (isEditing) {
      const editor = editorRef.current
      editor?.focus()
      editor?.setSelectionRange(editor.value.length, editor.value.length)
      hasEdited.current = true
    } else if (hasEdited.current) {
      editButtonRef.current?.focus()
    }
  }, [isEditing])

  React.useLayoutEffect(() => {
    const editor = editorRef.current
    if (!editor) return

    // Grow with the text up to a limit, then scroll.
    editor.style.height = "0px"
    editor.style.height = `${Math.min(editor.scrollHeight, MAX_EDITOR_HEIGHT)}px`
    editor.style.overflowY = editor.scrollHeight > MAX_EDITOR_HEIGHT ? "auto" : "hidden"
  }, [draft, isEditing])

  const startEditing = () => {
    setDraft(editText ?? "")
    setPhase("idle")
    setIsEditing(true)
  }

  const text = draft.trim()
  const isSending = phase === "sending"

  const sendEdit = async () => {
    if (!onEdit || text === "" || isSending) return

    setPhase("sending")
    try {
      await onEdit(text)
      setPhase("idle")
      setIsEditing(false)
    } catch {
      setPhase("error")
    }
  }

  return (
    <div
      data-slot="chat-message"
      data-role={role}
      className={cn("flex min-w-0 flex-col gap-[var(--hui-space-2)]", isUser && "items-end ps-[var(--hui-space-10)]", className)}
      {...props}
    >
      <p
        className={cn(
          "m-0 flex items-baseline gap-[var(--hui-space-2)] [font-size:var(--hui-font-size-small)] [line-height:var(--hui-line-height-small)]",
          (isUser || hideAuthor) && "sr-only"
        )}
      >
        <span className="[font-weight:var(--hui-font-weight-medium)]">{author}</span>
        {meta && <span className="text-[var(--hui-color-foreground-base-secondary)]">{meta}</span>}
      </p>

      {attachments.length > 0 && (
        <ul
          role="list"
          aria-label={labels.attachments}
          data-slot="chat-message-attachments"
          className={cn(
            "m-0 flex max-w-full list-none flex-wrap gap-[var(--hui-space-2)] p-0",
            isUser && "justify-end"
          )}
        >
          {attachments.map((file, index) => (
            <li
              key={`${file.name}-${index}`}
              className={cn(
                "flex min-w-0 max-w-full items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-base-secondary)] px-[var(--hui-space-3)] py-[var(--hui-space-1)] text-[var(--hui-color-foreground-base-secondary)]",
                smallText
              )}
            >
              <FileIcon aria-hidden="true" className="size-3.5 shrink-0" />
              {file.href ? (
                <a
                  href={file.href}
                  className="min-w-0 truncate rounded-[var(--hui-radius-1)] text-[var(--hui-color-foreground-base-primary)] underline underline-offset-2 outline-none focus-visible:[outline:var(--hui-focus-ring)]"
                >
                  {file.name}
                </a>
              ) : (
                <span className="min-w-0 truncate text-[var(--hui-color-foreground-base-primary)]">{file.name}</span>
              )}
              {file.meta && <span className="shrink-0">{file.meta}</span>}
            </li>
          ))}
        </ul>
      )}

      {isEditing ? (
        <form
          data-slot="chat-message-editor"
          className="flex w-full min-w-0 flex-col gap-[var(--hui-space-2)]"
          onSubmit={(event) => {
            event.preventDefault()
            void sendEdit()
          }}
        >
          <textarea
            ref={editorRef}
            rows={1}
            value={draft}
            disabled={isSending}
            aria-label={labels.editField}
            onChange={(event) => {
              setDraft(event.target.value)
              if (phase === "error") setPhase("idle")
            }}
            onKeyDown={(event) => {
              // Enter confirms an in-progress IME composition; it must not send.
              if (event.nativeEvent.isComposing) return
              if (event.key === "Escape") {
                event.preventDefault()
                setIsEditing(false)
              } else if (event.key === "Enter" && !event.shiftKey) {
                event.preventDefault()
                void sendEdit()
              }
            }}
            className="m-0 block w-full min-w-0 resize-none rounded-[var(--hui-radius-4)] border-[0.5px] border-[var(--hui-color-border-base-tertiary)] bg-[var(--hui-color-background-base-primary)] px-[var(--hui-space-4)] py-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] outline-none [font-size:var(--hui-font-size-regular)] [letter-spacing:var(--hui-letter-spacing-regular)] [line-height:var(--hui-line-height-regular)] [overflow-wrap:anywhere] focus-visible:border-[var(--hui-color-border-accent-emphasis)] disabled:cursor-not-allowed disabled:opacity-60"
          />
          {phase === "error" && (
            <p role="alert" className={cn("m-0 flex items-start gap-[var(--hui-space-2)]", smallText)}>
              <CircleAlertIcon
                strokeWidth={1.5}
                strokeLinecap="round"
                aria-hidden="true"
                className="mt-px size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]"
              />
              <span className="min-w-0 break-words">{labels.editError}</span>
            </p>
          )}
          <div className="flex items-center justify-end gap-[var(--hui-space-2)]">
            <Button variant="link" size="sm" disabled={isSending} onClick={() => setIsEditing(false)}>
              {labels.cancel}
            </Button>
            <Button type="submit" size="sm" disabled={isSending || text === ""}>
              {labels.resend}
            </Button>
          </div>
        </form>
      ) : (
        <div
          className={cn(
            "min-w-0 max-w-full break-words [font-size:var(--hui-font-size-regular)] [letter-spacing:var(--hui-letter-spacing-regular)] [line-height:1.55]",
            isUser &&
              "rounded-[var(--hui-radius-5)] bg-[var(--hui-color-background-neutral-secondary)] px-[var(--hui-space-4)] py-[var(--hui-space-3)]"
          )}
        >
          {children}
        </div>
      )}

      {!isEditing && (canEdit || hasVersions) && (
        <div
          data-slot="chat-message-footer"
          className={cn("flex min-w-0 items-center gap-[var(--hui-space-2)]", smallText)}
        >
          {hasVersions && (
            <div data-slot="chat-message-versions" className="flex items-center gap-[var(--hui-space-1)]">
              <Button
                variant="link"
                size="icon-sm"
                aria-label={labels.previousVersion}
                // aria-disabled keeps focus on the button at either end.
                aria-disabled={version <= 1}
                className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
                onClick={() => {
                  if (version > 1) onVersionChange?.(version - 1)
                }}
              >
                <ChevronLeftIcon aria-hidden="true" />
              </Button>
              <span className="tabular-nums text-[var(--hui-color-foreground-base-secondary)]">
                {labels.version(version, versionCount)}
              </span>
              <Button
                variant="link"
                size="icon-sm"
                aria-label={labels.nextVersion}
                aria-disabled={version >= versionCount}
                className="aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
                onClick={() => {
                  if (version < versionCount) onVersionChange?.(version + 1)
                }}
              >
                <ChevronRightIcon aria-hidden="true" />
              </Button>
            </div>
          )}
          {canEdit && (
            <Button
              ref={editButtonRef}
              variant="link"
              size="icon-sm"
              aria-label={labels.edit}
              title={labels.edit}
              onClick={startEditing}
            >
              <PencilIcon aria-hidden="true" />
            </Button>
          )}
        </div>
      )}
    </div>
  )
}

export { ChatThread, ChatMessage }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { ChatMessage, ChatThread } from "@/components/ai/chat-thread"
import { PromptBar } from "@/components/ai/prompt-bar"

<div className="h-[32rem]">
  <ChatThread
    label="Conversation with the billing agent"
    footer={<PromptBar disabled={chat.isStreaming} onSubmit={({ text }) => chat.send(text)} />}
  >
    {chat.messages.map((message) => (
      <ChatMessage key={message.id} role={message.role} author={message.author}>
        {message.text}
      </ChatMessage>
    ))}
  </ChatThread>
</div>
```

A message with its files, an editor, and its versions:

```tsx
<ChatMessage
  role="user"
  author="You"
  attachments={message.files.map((file) => ({ name: file.name, meta: formatSize(file.size) }))}
  editText={message.text}
  onEdit={(text) => chat.editAndResend(message.id, text)}
  version={message.version}
  versionCount={message.versionCount}
  onVersionChange={(version) => chat.showVersion(message.id, version)}
>
  {message.text}
</ChatMessage>
```

## Examples [#examples]

### Attachments, editing, and versions [#attachments-editing-and-versions]

A question with two files and two versions. Sending an edit adds a third version. No model replies.

```tsx
"use client"

import * as React from "react"

import { ChatMessage, ChatThread } from "@/components/ai/chat-thread"

// Sample conversation. Each edit of the question is kept as a version.
const sampleVersions = ["Why are export totals off?", "Why are export totals off by a cent?"]

const sampleAttachments = [
  { name: "q3-invoices.csv", meta: "48 KB" },
  { name: "export-settings.json", meta: "2 KB" },
]

/**
 * No model replies here. Sending an edit adds it as a new version of the
 * question, and the arrows move between the versions.
 */
export function ChatThreadEditingExample() {
  const [versions, setVersions] = React.useState(sampleVersions)
  const [version, setVersion] = React.useState(sampleVersions.length)

  return (
    <div className="h-80 w-full max-w-md min-w-0">
      <ChatThread label="Conversation with the billing agent">
        <ChatMessage
          role="user"
          author="You"
          attachments={sampleAttachments}
          editText={versions[version - 1]}
          onEdit={(text) => {
            setVersions([...versions, text])
            setVersion(versions.length + 1)
          }}
          version={version}
          versionCount={versions.length}
          onVersionChange={setVersion}
        >
          {versions[version - 1]}
        </ChatMessage>
        <ChatMessage role="assistant" author="Billing agent">
          This preview has no model, so no reply follows an edit.
        </ChatMessage>
      </ChatThread>
    </div>
  )
}

```

### An edit that fails to send [#an-edit-that-fails-to-send]

The first Send is rejected, as a failed request would be. The editor stays open with the changes. The second Send goes through.

```tsx
"use client"

import * as React from "react"

import { ChatMessage, ChatThread } from "@/components/ai/chat-thread"

const SEND_MILLISECONDS = 600

/**
 * Nothing is sent. The first Send is rejected after a pause, as a failed
 * request would be, and the second one goes through.
 */
export function ChatThreadEditFailureExample() {
  const [text, setText] = React.useState("Summarize the Q3 invoices.")
  const attempts = React.useRef(0)

  return (
    <div className="h-64 w-full max-w-md min-w-0">
      <ChatThread label="Conversation with the billing agent">
        <ChatMessage
          role="user"
          author="You"
          editText={text}
          onEdit={(edited) =>
            new Promise<void>((resolve, reject) => {
              window.setTimeout(() => {
                attempts.current += 1
                if (attempts.current === 1) {
                  reject(new Error("Sample failure"))
                  return
                }
                setText(edited)
                resolve()
              }, SEND_MILLISECONDS)
            })
          }
        >
          {text}
        </ChatMessage>
      </ChatThread>
    </div>
  )
}

```

### Empty thread [#empty-thread]

A new conversation, with a line that says what to ask.

```tsx
import { ChatThread } from "@/components/ai/chat-thread"

export function ChatThreadEmptyExample() {
  return (
    <div className="h-56 w-full max-w-md min-w-0">
      <ChatThread
        label="New conversation"
        emptyMessage="Ask about an invoice, an export, or a failed run."
      />
    </div>
  )
}

```

## API reference [#api-reference]

### ChatThread [#chatthread]

Accepts native div props beyond those listed. Its children are the messages.

| Prop           | Type      | Default  | Description                        |
| -------------- | --------- | -------- | ---------------------------------- |
| `label`        | string    | Required | Names the conversation.            |
| `emptyMessage` | ReactNode | None     | Shown while there are no messages. |
| `footer`       | ReactNode | None     | Fixed under the messages.          |

### ChatMessage [#chatmessage]

Accepts native div props beyond those listed.

| Prop              | Type                                      | Default  | Description                                                       |
| ----------------- | ----------------------------------------- | -------- | ----------------------------------------------------------------- |
| `role`            | `"user" \| "assistant"`                   | Required | Sets the message's layout.                                        |
| `author`          | string                                    | Required | Who is speaking.                                                  |
| `hideAuthor`      | boolean                                   | `false`  | Hides the author and `meta` from view. Both are still read aloud. |
| `meta`            | string                                    | None     | A short note beside the author.                                   |
| `attachments`     | `ChatAttachment[]`                        | None     | The files sent with the message.                                  |
| `editText`        | string                                    | None     | The message as plain text. With `onEdit`, it shows Edit.          |
| `onEdit`          | `(text: string) => void \| Promise<void>` | None     | Receives the edited text. Reject to report that it was not sent.  |
| `version`         | number                                    | None     | Which version is shown, counting from 1.                          |
| `versionCount`    | number                                    | `1`      | How many versions exist.                                          |
| `onVersionChange` | `(version: number) => void`               | None     | Runs when the person presses an arrow.                            |
| `labels`          | `Partial<ChatMessageLabels>`              | English  | Control names, the editor's name, the error, and the position.    |

### Types [#types]

| Export              | Shape                                                                                                            |
| ------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `ChatAttachment`    | `name`, and optional `href`, `meta`                                                                              |
| `ChatMessageLabels` | `attachments`, `edit`, `editField`, `cancel`, `resend`, `editError`, `previousVersion`, `nextVersion`, `version` |
