Page actions

ChatMessage

This experimental, private React source-build component presents one message. It renders a named article, a visually hidden speaker, before/after content around a body with dir="auto", and optional controls last. It creates no session or content owner.

import type { MessageRow } from '@threadplane/content/messages';
import {
  ChatMessage,
  MessageActions,
  MessageList,
  Reasoning,
  Citations,
  type MessageListRowContext,
} from '@threadplane/react/chat';
import { Markdown } from '@threadplane/react/markdown';
import '@threadplane/react/chat/styles.css';
 
export function StandaloneMessage({ onCopy, copyPending = false }: {
  readonly onCopy: () => void;
  readonly copyPending?: boolean;
}) {
  return (
    <ChatMessage
      role="assistant"
      speaker="Guide"
      label="Guide answer"
      beforeContent={<p>Message context</p>}
      afterContent={<aside aria-label="Sources">Source details</aside>}
      controls={
        <MessageActions
          disabled={copyPending}
          actions={[{
            id: 'copy',
            label: 'Copy answer',
            onSelect: onCopy,
            iconOnly: true,
            icon: (
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
                stroke="currentColor" strokeWidth="2" aria-hidden="true" focusable="false">
                <rect x="9" y="9" width="11" height="11" rx="2" />
                <path d="M5 15V5a2 2 0 0 1 2-2h10" />
              </svg>
            ),
          }]}
        />
      }
    >
      <p>שלום עולם — content chooses its own direction.</p>
    </ChatMessage>
  );
}
 
// Stable renderer: the parent retains content parsing and action ownership.
const renderMessage = (row: MessageRow, context: MessageListRowContext) => (
  <ChatMessage
    role={row.role}
    prevRole={context.prevRole}
    current={context.current}
    streaming={row.message.delivery.phase === 'streaming'}
    speaker={row.role === 'system' ? '' : undefined}
    beforeContent={
      row.role === 'assistant' && row.reasoning ? (
        <Reasoning snapshot={row.reasoning} />
      ) : undefined
    }
    afterContent={
      row.role === 'assistant' ? (
        <Citations
          snapshot={row.markdown}
          citations={row.message.citations ?? []}
        />
      ) : undefined
    }
  >
    {row.role === 'tool' ? (
      <pre>{row.message.content}</pre>
    ) : (
      <Markdown
        snapshot={row.markdown}
        citations={row.role === 'assistant' ? row.message.citations : undefined}
      />
    )}
  </ChatMessage>
);
 
export function CustomTranscript({
  rows,
}: {
  readonly rows: readonly MessageRow[];
}) {
  return <MessageList rows={rows} renderMessage={renderMessage} />;
}

ChatMessageProps has exactly eleven readonly fields: required role, and optional prevRole, current, streaming, speaker, label, children, beforeContent, afterContent, controls, and className. ChatMessageRole aliases core Role: user, assistant, system, or tool. The default accessible article name is ROLE message. A custom label is literal text, including an empty string.

prevRole describes the immediately preceding supplied projected row, including user, assistant, system and tool rows. It is undefined for the first row; omitted or explicitly undefined values omit data-prev-role. With the chat stylesheet, a user message with prevRole="assistant" receives 1.5rem block-start spacing even through wrappers. Explicit other roles keep ordinary 0.5rem spacing and override physical assistant adjacency. Without context, adjacent assistant and user articles retain the existing 1.5rem fallback. This fixes that transition only: existing assistant :first-child rules still depend on DOM structure.

current and streaming default to false and appear as literal data-current and data-streaming article attributes. Only an assistant with both flags true renders one empty, aria-hidden caret span after children inside the independently directed body, including when the body has no text. The 8px dot pulses, stays visible with reduced motion, and uses solid system text color in forced colors. It adds no live announcement or session subscription. Default MessageList derives streaming from delivery.phase === 'streaming'; every complete outcome removes the caret.

The default speaker is You: for user messages and Assistant: for assistant messages. System and tool messages have no default speaker. A custom speaker is literal text followed by a colon; speaker="" suppresses it. CSS hides the speaker visually while keeping it available to assistive technology. The article contains its positioning, and speaker text remains readable without CSS.

Children render once in .tp-chat-message__body with dir="auto", independently of an outer RTL transcript and the speaker label. Pass Arabic, Hebrew or mixed text normally. The primitive does not parse Markdown, derive citations, or render tool results. Default MessageList and the native custom renderer retain their existing Markdown, reasoning, citation, A2UI and tool content ownership.

beforeContent and afterContent render directly as article children around that body, with no added wrappers or child inspection. They accept normal ReactNodes, including null, booleans, zero and arrays. Reasoning belongs before the message text; Sources, A2UI widgets and tool activity belong after it. These slots inherit the surrounding presentation direction, so RTL cards and preview controls keep their direction while English message text remains LTR. Markdown keeps its own existing dir="auto" text root; with explicitly directed children, the outer body's computed direction may differ from the actual text root.

controls accepts a ReactNode, rendered once after afterContent in .tp-chat-message__controls. Nullish and boolean values omit the wrapper; values such as 0 and arrays follow normal React semantics. Controls wrap and keep their layout space and keyboard tab access. With the opt-in chat stylesheet, a historical assistant's direct controls wrapper is transparent and ignores pointer input until its owning article is hovered or contains focus. Current assistant controls stay visible. At widths up to 768px or on devices with no hover support, assistant controls always stay visible. The opacity transition takes 200ms; reduced motion disables it. Without the stylesheet, controls use normal DOM presentation.

This wrapper does not suppress streaming controls: current streaming assistant controls stay usable, and historical streaming controls follow the same reveal conditions. Non-assistant controls, standalone MessageActions and nested rich-content controls retain their existing behavior. The consumer owns each handler, status, disabled state and clipboard operation. Rendering, updating and unmounting do not activate controls, move focus or announce a live status.

The component exposes no session, message snapshot, render callback or raw element props. MessageList keeps its existing custom renderer bypass and scroll policy. Its readonly MessageListRowContext.current identifies the final supplied row, even when that row is user, system or tool; optional readonly prevRole comes from rows[index - 1]?.role, rather than the message payload or DOM. The ten positional combinations reuse frozen context objects. Replacing a preceding immutable row with a different projected role updates the following row even if its own reference is unchanged; unrelated historical rows remain memoized. Appending also rerenders the former final row because current changes. Stable one-argument renderers remain compatible, and Chat forwards the same two-argument contract. Full ChatMessage/T23 parity remains deferred.

MessageRating supplies a controlled thumbs up and down for one response. Pass the row's stable messageId, the current value ('up', 'down' or null) and onValueChange(next, messageId). aria-pressed reflects only value; pressing the current rating requests null. The component keeps no state and sends nothing, so your application decides whether to accept, store or send a rating. disabled, label, upLabel, downLabel and className are optional. With the opt-in chat stylesheet the two options are compact 36px targets and the pressed option is visibly distinct, including in forced-colors mode.

<ChatMessage
  role="assistant"
  controls={
    <MessageRating
      messageId={row.id}
      value={ratings.get(row.id) ?? null}
      onValueChange={(next, id) => saveRating(id, next)}
    />
  }
>
  {children}
</ChatMessage>

MessageActions can supply the controls, as in the local copy SVG above. MessageAction has exactly six readonly fields: required unique id, literal string label and argumentless onSelect, plus optional disabled, icon and iconOnly. MessageActionsProps keeps four readonly fields: required actions and optional disabled, label and className. The default group label is Message actions; empty actions render nothing. Both group and per-action disabled states default to false; either disables the native button.

icon accepts app-authored decorative, noninteractive ReactNode content inside an aria-hidden wrapper. iconOnly defaults to false; only true with a supplied icon enables compact presentation. Exactly null, undefined and booleans omit the icon and keep a visible label even with iconOnly: true. Zero, strings (including empty strings), arrays (including empty arrays), fragments and components that render nothing follow normal ReactNode semantics. There is no child inspection; compact callers must supply a meaningful visible, noninteractive glyph.

The literal string label always provides the button's accessible name. The opt-in chat stylesheet visually hides only the effective compact label, sets a minimum 36px square target and sizes direct SVG glyphs to 16px. Compact markup supplies the same literal label as a native title tooltip independently of CSS. Without the stylesheet, both icon and label remain visible in normal DOM presentation. Omit iconOnly for an icon beside a visible label; omit icon for a text button.

Same-ID compatible updates and reordering preserve the native button and its focus where native disabled behavior permits. Pointer, Enter and Space activation call the current argumentless callback. Rendering, updating and unmounting do not activate actions. The application owns clipboard admission and writes, pending state, feedback, async completion and errors; it must handle promise rejections itself. No automatic ratings or regeneration commands are provided. This bounded private presentation feature does not complete T28 or establish public release parity.

Looking for something specific?