Page actions

React chat messages preview

Render a conversation from stable message rows, then choose how each row looks. Message identity, parsed Markdown, reasoning, and tool observations can remain structured while your product controls the layout.

The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.

Project messages before rendering rows

createMessageContent turns session messages into reusable MessageRow values. The application advances this projection when it handles a session observation, then publishes the prepared rows in its own immutable snapshot. MessageList consumes those rows and provides default presentation; renderMessage replaces that presentation when your application needs a custom row. Keep the projection owner for the conversation lifetime and dispose it with that owner, not each time React renders.

import type { MessageRow } from '@threadplane/content/messages';
import { MessageList } from '@threadplane/react/chat';
 
export function Transcript({ rows }: { readonly rows: readonly MessageRow[] }) {
  return <MessageList rows={rows} label="Trip conversation" />;
}

The runnable example below shows the full owner-to-view connection. Its application.ts owns projection and cleanup; its app.tsx observes the application and renders published rows. Do not call a shared content.project from your custom row renderer or use React rendering to advance the application's content owner.

The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.

Ask and continue

Choose Run and ask an aviation question. The assistant's answer arrives as text and Markdown. Wait for Response saved before sending another question in the same conversation. New conversation clears the page; a new server thread is created only when you send a message.

Stop cancels active work. A failed, stopped, uncertain or unexpectedly paused conversation requires New conversation. Resetting never retries an earlier question. Reloading starts an empty page-local conversation.

Compose message presentation

A stable row renderer labels user, assistant and system articles. It presents application-owned Markdown snapshots and preserves assistant reasoning and citations when supplied. Tool rows are deliberately hidden in this text-only example; observing a tool, interrupt or child blocks further submission.

app.tsx
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
  ChatInput,
  MessageList,
  Reasoning,
  Citations,
} from '@threadplane/react/chat';
import { Markdown } from '@threadplane/react/markdown';
import type { MessageRow } from '@threadplane/content/messages';
import {
  createConnectedApplication,
  type messagesConnection,
} from './connection';
 
/** Stable row renderer; the application owns Markdown snapshot lifetimes. */
export function renderMessage(row: MessageRow) {
  if (row.role === 'tool') return null;
  const label =
    row.role === 'user'
      ? 'You'
      : row.role === 'assistant'
      ? 'Assistant'
      : 'System';
  return (
    <article
      aria-label={label}
      className={`tp-chat-message tp-chat-message--${row.role}`}
    >
      <p className="tp-chat-message__role">{label}</p>
      {row.role === 'assistant' && row.reasoning && (
        <Reasoning snapshot={row.reasoning} />
      )}
      <Markdown snapshot={row.markdown} />
      {row.role === 'assistant' && row.message.citations && (
        <Citations citations={row.message.citations} />
      )}
    </article>
  );
}
export function MessagesDemo({
  connection,
  onReady,
}: {
  readonly connection: ReturnType<typeof messagesConnection>;
  readonly onReady: () => void;
}) {
  const [application] = useState(() => createConnectedApplication(connection));
  const snapshot = useAgent(application);
  useEffect(() => {
    onReady();
  }, [onReady]);
  useEffect(
    () => () => {
      void application.dispose();
    },
    [application]
  );
  const status = snapshot.busy
    ? snapshot.activity === 'creating'
      ? 'Creating conversation…'
      : snapshot.activity === 'confirming'
      ? 'Confirming saved response…'
      : snapshot.activity === 'replacing'
      ? 'Starting a new conversation…'
      : snapshot.rows.at(-1)?.role === 'assistant'
      ? 'Receiving response…'
      : 'Waiting for the first response…'
    : snapshot.outcome === 'success'
    ? 'Response complete.'
    : snapshot.outcome === 'aborted'
    ? 'Stopped. Start a new conversation to continue.'
    : !snapshot.canSubmit
    ? 'Start a new conversation to continue.'
    : 'Ready.';
  return (
    <ExampleLayout className="messages-demo" about={<>
      <h3>Primitives used</h3>
      <ul><li>Message list</li><li>Message input</li><li>Response status</li></ul>
    </>}>
      <header>
        <div>
          <h1>Chat messages</h1>
        </div>
        <button
          type="button"
          disabled={snapshot.busy}
          onClick={() => void application.newConversation()}
        >
          New conversation
        </button>
      </header>
      <p>
        Ask a question and watch the answer arrive. Continue after the response
        is saved.
      </p>
      <MessageList
        rows={snapshot.rows}
        renderMessage={renderMessage}
        label="Conversation"
      />
      <ExampleFooter>
      {snapshot.error && <p role="alert">{snapshot.error}</p>}
      <ChatInput
        key={snapshot.viewGeneration}
        label="Message"
        placeholder="Ask an aviation question…"
        busy={snapshot.busy}
        disabled={!snapshot.canSubmit && !snapshot.busy}
        onStop={snapshot.busy ? () => void application.stop() : undefined}
        onSubmit={(text) => {
          if (!application.getSnapshot().canSubmit) return false;
          void application.submit(text);
          return true;
        }}
      />
      <p role="status">{status}</p>
      </ExampleFooter>
    </ExampleLayout>
  );
}

The owner holds the composer disabled through one explicit server history read after a successful response. The saved terminal transcript must preserve every confirmed message and the exact current question and answer. Streaming success alone, changed history and fulfilled reads after Stop do not authorize continuation. Title generation updates thread metadata without entering the visible answer stream.

application.ts
import type {
  AgentSession,
  AgentSnapshot,
  CompleteOutcome,
  Message,
} from '@threadplane/core';
import {
  createMessageContent,
  type MessageRow,
} from '@threadplane/content/messages';
 
export interface MessagesSession extends Omit<AgentSession, 'getSnapshot'> {
  getSnapshot(): AgentSnapshot & {
    readonly interrupts: readonly unknown[];
    readonly subgraphs: readonly unknown[];
    readonly history:
      | readonly { readonly next: readonly string[] }[]
      | undefined;
  };
  load(options?: { readonly signal?: AbortSignal }): Promise<void>;
}
export interface MessagesSnapshot {
  readonly threadId: string | null;
  readonly rows: readonly MessageRow[];
  readonly activity:
    | 'idle'
    | 'creating'
    | 'running'
    | 'confirming'
    | 'replacing';
  readonly busy: boolean;
  readonly canSubmit: boolean;
  readonly outcome: CompleteOutcome | null;
  readonly error: string | null;
  readonly viewGeneration: number;
}
const equal = (a: Message, b: Message) =>
  a.id === b.id && a.role === b.role && a.content === b.content;
const complete = (m: Message) =>
  m.delivery.phase === 'complete' && m.delivery.outcome === 'success';
 
/** One local conversation owner; streaming completion alone grants no continuation. */
export function createMessagesApplication(options: {
  readonly createThread: (signal: AbortSignal) => Promise<string>;
  readonly sessionFactory: (id: string) => MessagesSession;
}) {
  type Owner = {
    session: MessagesSession;
    content: ReturnType<typeof createMessageContent>;
    release?: () => void;
    closed: boolean;
    unsafe: boolean;
    confirmed: readonly Message[];
    generations: Set<string>;
    loading?: boolean;
    turn?: { text: string; human?: Message; answer?: Message };
  };
  let owner: Owner | undefined, operation: AbortController | undefined;
  let disposed = false,
    disposal: Promise<void> | undefined;
  const listeners = new Set<() => void>(),
    cleanups = new Set<Promise<void>>();
  let snapshot: MessagesSnapshot = Object.freeze({
    threadId: null,
    rows: Object.freeze([]),
    activity: 'idle',
    busy: false,
    canSubmit: true,
    outcome: null,
    error: null,
    viewGeneration: 0,
  });
  function publish(update: Partial<MessagesSnapshot>) {
    if (disposed) return;
    snapshot = Object.freeze({ ...snapshot, ...update });
    for (const notify of listeners) notify();
  }
  function close(selected: Owner) {
    if (selected.closed) return Promise.resolve();
    selected.closed = true;
    selected.release?.();
    selected.content.dispose();
    const cleanup = Promise.resolve()
      .then(() => selected.session.dispose())
      .then(
        () => undefined,
        () => undefined
      );
    cleanups.add(cleanup);
    void cleanup.then(() => cleanups.delete(cleanup));
    return cleanup;
  }
  function detach() {
    const previous = owner;
    owner = undefined;
    return previous ? close(previous) : Promise.resolve();
  }
  function unsafe(state: ReturnType<MessagesSession['getSnapshot']>) {
    return (
      !state ||
      !Array.isArray(state.messages) ||
      !Array.isArray(state.toolCalls) ||
      !Array.isArray(state.interrupts) ||
      !Array.isArray(state.subgraphs) ||
      !!state.error ||
      state.status === 'error' ||
      !!state.toolCalls.length ||
      !!state.interrupts.length ||
      !!state.subgraphs.length ||
      new Set(state.messages.map((m) => m.id)).size !== state.messages.length ||
      state.messages.some(
        (m) =>
          m.role === 'tool' ||
          m.toolCallId !== undefined ||
          !!m.toolCallIds?.length
      )
    );
  }
  function capture(
    selected: Owner,
    state: ReturnType<MessagesSession['getSnapshot']>
  ) {
    const prefix = selected.confirmed,
      messages = state.messages;
    if (
      messages.length < prefix.length ||
      prefix.some((m, i) => !equal(m, messages[i]))
    )
      selected.unsafe = true;
    const turn = selected.turn;
    if (!turn || selected.unsafe) return;
    if (snapshot.activity === 'confirming') {
      const expected = [turn.human, turn.answer];
      const suffix = messages.slice(prefix.length);
      if (
        suffix.length !== 2 ||
        expected.some(
          (message, index) =>
            !message ||
            !equal(message, suffix[index]) ||
            !complete(suffix[index]) ||
            (suffix[index].delivery.generation !==
              message.delivery.generation &&
              (!selected.loading ||
                suffix[index].delivery.generation !== suffix[index].id))
        )
      )
        selected.unsafe = true;
      return;
    }
    if (snapshot.activity !== 'running') return;
    const suffix = messages.slice(prefix.length);
    if (!suffix.length) return;
    const human = suffix[0];
    if (
      human.role !== 'user' ||
      human.content !== turn.text ||
      !complete(human) ||
      selected.generations.has(human.delivery.generation) ||
      prefix.some((m) => m.id === human.id) ||
      (turn.human &&
        (!equal(turn.human, human) ||
          turn.human.delivery.generation !== human.delivery.generation))
    ) {
      selected.unsafe = true;
      return;
    }
    turn.human ??= human;
    if (suffix.length > 2) {
      selected.unsafe = true;
      return;
    }
    const answer = suffix[1];
    if (!answer) return;
    if (
      answer.role !== 'assistant' ||
      answer.id === human.id ||
      prefix.some((m) => m.id === answer.id) ||
      answer.delivery.generation !== human.delivery.generation ||
      (turn.answer && turn.answer.id !== answer.id)
    ) {
      selected.unsafe = true;
      return;
    }
    turn.answer = answer;
  }
  function read(selected: Owner, valid: () => boolean) {
    if (!valid()) return;
    const state = selected.session.getSnapshot();
    if (!valid()) return;
    selected.unsafe ||= unsafe(state);
    if (!selected.unsafe) capture(selected, state);
    if (!valid()) return;
    if (Array.isArray(state?.messages) && Array.isArray(state?.toolCalls)) {
      const rows = selected.content.project(state);
      if (!valid()) return;
      publish({ rows, ...(selected.unsafe ? { canSubmit: false } : {}) });
    }
    if (!valid()) return;
    const latest = selected.session.getSnapshot();
    if (!valid()) return;
    // Subscribers may replace the observation while rows are being published.
    if (latest !== state) {
      selected.unsafe = true;
      return;
    }
    return state;
  }
  function blocked() {
    publish({
      canSubmit: false,
      outcome: 'paused',
      error: 'Start a new conversation to continue.',
    });
  }
  async function submit(text: string) {
    if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
      return;
    const admitted = new AbortController();
    operation = admitted;
    const current = () =>
      !disposed && operation === admitted && !admitted.signal.aborted;
    publish({
      busy: true,
      canSubmit: false,
      activity: owner ? 'running' : 'creating',
      outcome: null,
      error: null,
    });
    try {
      if (!current()) return;
      if (!owner) {
        const id = await options.createThread(admitted.signal);
        if (!current()) return;
        if (!id || typeof id !== 'string')
          throw new Error('Unconfirmed conversation');
        const session = options.sessionFactory(id);
        const selected: Owner = {
          session,
          content: createMessageContent(),
          closed: false,
          unsafe: false,
          confirmed: [],
          generations: new Set(),
        };
        if (!current()) {
          void close(selected);
          return;
        }
        owner = selected;
        const release = session.subscribe(() =>
          read(
            selected,
            () =>
              !disposed &&
              owner === selected &&
              !selected.closed &&
              !operation?.signal.aborted
          )
        );
        selected.release = release;
        if (selected.closed) release();
        if (!current()) return;
        publish({ threadId: id, activity: 'running' });
      }
      const selected = owner;
      const valid = () => current() && owner === selected && !selected.closed;
      const before = read(selected, valid);
      if (!valid()) return;
      if (
        !before ||
        selected.unsafe ||
        before.messages.length !== selected.confirmed.length
      ) {
        blocked();
        return;
      }
      selected.turn = { text };
      const result = await selected.session.submit(text, {
        signal: admitted.signal,
      });
      if (!valid()) return;
      const live = read(selected, valid),
        turn = selected.turn;
      if (!valid()) return;
      if (
        result !== 'success' ||
        !live ||
        live.status !== 'idle' ||
        selected.unsafe ||
        !turn?.human ||
        !turn.answer ||
        !complete(turn.answer) ||
        live.messages.length !== selected.confirmed.length + 2
      ) {
        blocked();
        return;
      }
      const expected = Object.freeze([
        ...selected.confirmed,
        turn.human,
        turn.answer,
      ]);
      publish({ activity: 'confirming' });
      if (!valid()) return;
      if (!read(selected, valid) || selected.unsafe) {
        if (valid()) blocked();
        return;
      }
      selected.loading = true;
      await selected.session.load({ signal: admitted.signal });
      if (!valid()) return;
      const saved = read(selected, valid);
      if (!valid()) return;
      if (
        !saved ||
        selected.unsafe ||
        saved.status !== 'idle' ||
        !saved.history?.length ||
        !Array.isArray(saved.history[0].next) ||
        saved.history[0].next.length ||
        saved.messages.length !== expected.length ||
        expected.some(
          (m, i) =>
            !equal(m, saved.messages[i]) ||
            !complete(saved.messages[i]) ||
            saved.messages[i].delivery.generation !== saved.messages[i].id
        )
      ) {
        blocked();
        return;
      }
      selected.confirmed = Object.freeze([...saved.messages]);
      selected.generations.add(turn.human.delivery.generation);
      selected.loading = false;
      selected.turn = undefined;
      publish({ outcome: 'success', canSubmit: true });
    } catch {
      if (current())
        publish({
          canSubmit: false,
          outcome: 'error',
          error:
            'The conversation could not be confirmed. Start a new conversation.',
        });
    } finally {
      if (current()) {
        operation = undefined;
        publish({ busy: false, activity: 'idle' });
      }
    }
  }
  async function stop() {
    if (disposed || !operation) return;
    operation.abort();
    operation = undefined;
    const selected = owner;
    const cleanup = detach();
    publish({
      busy: false,
      activity: 'idle',
      canSubmit: false,
      outcome: 'aborted',
      error: null,
    });
    try {
      await selected?.session.stop();
    } catch {
      /* An uncertain request is never repeated. */
    }
    await cleanup;
  }
  async function newConversation() {
    if (disposed || snapshot.busy) return;
    const cleanup = detach();
    const admitted = new AbortController();
    operation = admitted;
    const current = () =>
      !disposed && operation === admitted && !admitted.signal.aborted;
    publish({ busy: true, canSubmit: false, activity: 'replacing' });
    if (!current()) return;
    await cleanup;
    if (!current()) return;
    operation = undefined;
    publish({
      threadId: null,
      rows: Object.freeze([]),
      busy: false,
      canSubmit: true,
      activity: 'idle',
      outcome: null,
      error: null,
      viewGeneration: snapshot.viewGeneration + 1,
    });
  }
  function dispose(): Promise<void> {
    if (disposal) return disposal;
    disposed = true;
    operation?.abort();
    operation = undefined;
    void detach();
    listeners.clear();
    disposal = Promise.all([...cleanups]).then(() => undefined);
    return disposal;
  }
  return {
    getSnapshot: () => snapshot,
    subscribe(notify: () => void) {
      if (disposed) return () => undefined;
      listeners.add(notify);
      return () => {
        listeners.delete(notify);
      };
    },
    submit,
    stop,
    newConversation,
    dispose,
  };
}

The shared backend runs the existing c-messages graph by default. Runtime settings may select a developer LangGraph target; the same target is used for creation, streaming and saved history. SDK retries are disabled, and only the exact returned thread ID confirms creation.

connection.ts
import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
import { createMessagesApplication, type MessagesSession } from './application';
 
export function messagesConnection(configuration: RuntimeBridgeConfiguration): {
  apiUrl: string;
  headers: Record<string, string>;
} {
  if (
    configuration.status === 'error' ||
    (configuration.status === 'configured' &&
      configuration.target.kind === 'ag-ui')
  )
    throw new Error('Runtime configuration failed.');
  const target =
    configuration.status === 'configured' &&
    configuration.target.kind === 'langsmith'
      ? configuration.target
      : null;
  return {
    apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
    headers: target ? { 'x-api-key': target.apiKey } : {},
  };
}
export function createConnectedApplication(
  connection: ReturnType<typeof messagesConnection>
) {
  const client = new Client({
    apiUrl: connection.apiUrl,
    apiKey: null,
    defaultHeaders: connection.headers,
    callerOptions: { maxRetries: 0 },
  });
  return createMessagesApplication({
    async createThread(signal) {
      const threadId = crypto.randomUUID();
      const created = await client.threads.create({
        threadId,
        ifExists: 'raise',
        signal,
      });
      if (created.thread_id !== threadId)
        throw new Error('Conversation creation was not confirmed.');
      return threadId;
    },
    sessionFactory(threadId) {
      const session = createSession({
        assistantId: 'c-messages',
        threadId,
        apiUrl: connection.apiUrl,
        clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
      });
      try {
        const state = session.getSnapshot();
        if (
          !state ||
          !Array.isArray(state.messages) ||
          !Array.isArray(state.toolCalls) ||
          !Array.isArray(state.interrupts) ||
          !Array.isArray(state.subgraphs) ||
          ![
            'getSnapshot',
            'subscribe',
            'submit',
            'load',
            'stop',
            'dispose',
          ].every(
            (method) =>
              typeof session[method as keyof typeof session] === 'function'
          )
        )
          throw new Error('Runtime session is unavailable.');
      } catch {
        try {
          void Promise.resolve(session.dispose()).catch(() => undefined);
        } catch {
          /* A rejected session cannot be installed. */
        }
        throw new Error('Runtime session is unavailable.');
      }
      return session as MessagesSession;
    },
  });
}

Build from source

From a Threadplane checkout with dependencies installed:

npx nx build cockpit-chat-messages-react
npx nx e2e cockpit-chat-messages-react

The isolated build uses installed private candidate packages. Browser tests exercise actual SDK HTTP and SSE with events from the compiled Python graph and deterministic providers. Docs, Code and Run select this same React implementation. The default Angular example and its backend remain available.

Cross-origin iframe hosts

A cross-origin child cannot read the parent’s visible viewport. On iOS Safari, the software keyboard can shrink the parent’s visualViewport while the child continues to see the iframe’s full fixed height. Size the frame in the parent; changing the child’s 100dvh alone does not solve this.

The built-in docs workspace handles parent sizing for Angular and React examples. For a custom full-screen parent with a frame normally capped at 580px, use a flex host so its header and footer share the same visible height:

<section class="runtime-host">
  <header>Your chat</header>
  <iframe title="Chat" src="https://your-chat.example"></iframe>
  <footer>Your parent controls</footer>
</section>
.runtime-host {
  position: fixed;
  inset-inline: 0;
  top: var(--runtime-top, 0px);
  height: min(580px, var(--runtime-height, 100dvh));
  display: flex;
  flex-direction: column;
}
.runtime-host > header,
.runtime-host > footer {
  flex: none;
}
.runtime-host > iframe {
  flex: 1;
  min-height: 0;
  width: 100%;
  border: 0;
}
function fitRuntimeHost(host) {
  const viewport = host.ownerDocument.defaultView?.visualViewport;
  if (!viewport) return () => {};
  const names = ['--runtime-height', '--runtime-top'];
  const previous = names.map((name) => [
    host.style.getPropertyValue(name),
    host.style.getPropertyPriority(name),
  ]);
  const update = () => {
    if (viewport.scale !== 1) return;
    host.style.setProperty(names[0], `${viewport.height}px`);
    // This host is fixed, so use offsetTop, not document-relative pageTop.
    host.style.setProperty(names[1], `${viewport.offsetTop}px`);
  };
  update();
  viewport.addEventListener('resize', update);
  viewport.addEventListener('scroll', update);
  return () => {
    viewport.removeEventListener('resize', update);
    viewport.removeEventListener('scroll', update);
    names.forEach((name, index) => {
      const [value, priority] = previous[index];
      if (value) host.style.setProperty(name, value, priority);
      else host.style.removeProperty(name);
    });
  };
}

Call fitRuntimeHost once when the parent host mounts and run its returned cleanup when it unmounts—for example, return it from a React effect or register it with Angular’s DestroyRef.onDestroy. Keep the iframe node and src stable while resizing so the child retains its conversation, draft, focus and reading position. This pattern does not require reading the child DOM or adding a cross-origin message protocol. Configure the child’s allowed embedding origins separately if its application requires them.

Looking for something specific?