Page actions

React chat input preview

Let the traveler write the next question while the current answer arrives. A controlled composer keeps its draft separate from the accepted message and the agent’s run status.

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.

Accept the draft synchronously

ChatInput calls onSubmit with the text as typed. Return false when the application cannot accept it; the draft then stays in place. Otherwise the input clears and the application can start asynchronous work. Use busy to block another Send while keeping the next draft editable, and pass onStop for active work.

import { useState } from 'react';
import { ChatInput } from '@threadplane/react/chat';
 
export function TripComposer({ busy, accept, stop }: {
  busy: boolean;
  accept: (text: string) => boolean;
  stop: () => void;
}) {
  const [draft, setDraft] = useState('');
  return <ChatInput value={draft} onValueChange={setDraft} busy={busy}
    onSubmit={accept} onStop={stop} placeholder="Change the itinerary…" />;
}

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

Try the composer

Choose Run and type an aviation question. Enter sends your message; Shift+Enter adds a new line. Turn off Enter sends a message to use Enter for new lines and Ctrl/Command+Enter to send. Keyboard submission waits until text composition has finished, and whitespace-only drafts are not sent. Accepted messages preserve the text and spacing you typed.

Change Custom placeholder to update the empty composer. Disable message input blocks typing and sending. These settings stay local and do not start or replace a conversation.

Keep your next draft

While the assistant responds, Send stays disabled and you can type your next draft. That draft remains in the composer while the response is confirmed and after Response saved appears. Stop stays available during active work, including when message input is disabled.

New conversation clears the current draft and transcript locally. If cleanup is still finishing, a newer draft typed during that time is preserved. Your composer settings stay selected. A new server thread is created only when you send a message.

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 inputConnection } 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 InputDemo({
  connection,
  onReady,
}: {
  readonly connection: ReturnType<typeof inputConnection>;
  readonly onReady: () => void;
}) {
  const [application] = useState(() => createConnectedApplication(connection));
  const [draft, setDraft] = useState('');
  const [placeholder, setPlaceholder] = useState('Ask an aviation question…');
  const [submitOnEnter, setSubmitOnEnter] = useState(true);
  const [disabled, setDisabled] = useState(false);
  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 saved.'
    : snapshot.outcome === 'aborted'
    ? 'Stopped. Start a new conversation to continue.'
    : !snapshot.canSubmit
    ? 'Start a new conversation to continue.'
    : 'Ready.';
  return (
    <ExampleLayout
      className="input-demo"
      about={
        <fieldset className="input-settings">
          <legend>Composer settings</legend>
          <label className="input-settings__placeholder">
            Custom placeholder
            <input
              type="text"
              value={placeholder}
              onChange={(event) => setPlaceholder(event.target.value)}
            />
          </label>
          <label>
            <input
              type="checkbox"
              checked={submitOnEnter}
              onChange={(event) => setSubmitOnEnter(event.target.checked)}
            />
            Enter sends a message
          </label>
          <label>
            <input
              type="checkbox"
              checked={disabled}
              onChange={(event) => setDisabled(event.target.checked)}
            />
            Disable message input
          </label>
        </fieldset>
      }
    >
      <header>
        <div>
          <h1>Chat input</h1>
        </div>
        <button
          type="button"
          disabled={snapshot.busy}
          onClick={() => {
            if (application.getSnapshot().busy) return;
            setDraft('');
            void application.newConversation();
          }}
        >
          New conversation
        </button>
      </header>
      <p>
        Try the keyboard shortcuts and customize your composer. You can draft
        your next message while a response arrives.
      </p>
 
      <MessageList
        rows={snapshot.rows}
        renderMessage={renderMessage}
        label="Conversation"
      />
      <ExampleFooter>
        {snapshot.error && <p role="alert">{snapshot.error}</p>}
        <ChatInput
          key={snapshot.viewGeneration}
          label="Message"
          value={draft}
          onValueChange={setDraft}
          placeholder={placeholder}
          submitOnEnter={submitOnEnter}
          hint={
            submitOnEnter
              ? 'Enter to send. Shift+Enter adds a new line.'
              : 'Ctrl/Command+Enter to send. Enter adds a new line.'
          }
          busy={snapshot.busy}
          disabled={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 application holds the controlled draft above the input's view key. Reset clears the old draft synchronously after checking the live owner; cleanup completion never clears newer text. A rejected submission leaves the draft intact.

Confirm conversation history

After a successful response, the owner reads saved history once and requires the same confirmed messages and exact current question and answer. Send remains blocked through that confirmation. Title generation updates metadata without entering the answer stream.

A stopped, failed, uncertain or unexpectedly paused conversation requires New conversation. Resetting never retries an earlier question. This text-only example also blocks continuation if tools, interrupts or child work appear.

application.ts
import type {
  AgentSession,
  AgentSnapshot,
  CompleteOutcome,
  Message,
} from '@threadplane/core';
import {
  createMessageContent,
  type MessageRow,
} from '@threadplane/content/messages';
 
export interface InputSession 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 InputSnapshot {
  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 createInputApplication(options: {
  readonly createThread: (signal: AbortSignal) => Promise<string>;
  readonly sessionFactory: (id: string) => InputSession;
}) {
  type Owner = {
    session: InputSession;
    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: InputSnapshot = Object.freeze({
    threadId: null,
    rows: Object.freeze([]),
    activity: 'idle',
    busy: false,
    canSubmit: true,
    outcome: null,
    error: null,
    viewGeneration: 0,
  });
  function publish(update: Partial<InputSnapshot>) {
    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<InputSession['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<InputSession['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 c-input by default. Runtime settings may select a developer LangGraph target; creation, streaming and saved history all use that target. SDK retries are disabled, and creation requires the exact requested thread identity to be confirmed.

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 { createInputApplication, type InputSession } from './application';
 
export function inputConnection(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 inputConnection>
) {
  const client = new Client({
    apiUrl: connection.apiUrl,
    apiKey: null,
    defaultHeaders: connection.headers,
    callerOptions: { maxRetries: 0 },
  });
  return createInputApplication({
    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-input',
        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 InputSession;
    },
  });
}

Moving between Code and Run keeps the mounted example, draft and settings. Reloading starts an empty page-local conversation with the default settings.

Build from source

From a Threadplane checkout with dependencies installed:

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

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

Looking for something specific?