OperantAI Workflow Design System
ComfortableCompact

Composer (.ods-composer) is the answer to AGENTS.md's decision-table row "Freeform AI instruction" — reach for it instead of a bare .ods-textarea whenever the user is typing an instruction to an agent. It bundles a textarea, a toolbar (for context chips and a token-count meta), and a Send/Stop button that switches on isStreaming.

Installation

HTML — already included if you loaded the 3 stylesheets. See the Installation guide. Note: Cmd/Ctrl+Enter-to-send, Escape-to-stop, and the controlled/uncontrolled value handling below are React-layer behavior (a useState + onKeyDown handler) — plain HTML needs equivalent JS wired up by hand.

React

npm install @operant/ui

@operant/ui is currently a monorepo workspace package, not yet published to the public npm registry.

Usage

HTML

<div class="ods-composer">
  <textarea class="ods-composer-input" rows="1" aria-label="Prompt" placeholder="Ask the agent…"></textarea>
  <div class="ods-composer-toolbar">
    <span class="grow"></span>
    <button type="button" class="ods-btn -primary -sm">Send</button>
  </div>
</div>

React

import { Composer } from "@operant/ui";

export default function Example() {
  return <Composer placeholder="Ask the agent…" onSend={(value) => console.log(value)} />;
}

Examples

With attached context chips

children renders inside the toolbar, before the trailing meta/send controls — the natural slot for ComposerChips (attached files, the active model, etc.).

<div class="ods-composer">
  <textarea class="ods-composer-input" rows="1" aria-label="Prompt" placeholder="Ask the agent…"></textarea>
  <div class="ods-composer-toolbar">
    <span class="ods-composer-chip">
      <span class="ods-dot -automation"></span> claude-opus-4-8
      <button type="button" class="ods-iconbtn -sm" aria-label="Remove">×</button>
    </span>
    <span class="ods-composer-chip">
      checkout-svc/deploy.yaml
      <button type="button" class="ods-iconbtn -sm" aria-label="Remove">×</button>
    </span>
    <span class="grow"></span>
    <button type="button" class="ods-btn -primary -sm">Send</button>
  </div>
</div>
import { Composer, ComposerChip } from "@operant/ui";

<Composer placeholder="Ask the agent…">
  <ComposerChip onRemove={() => {}}>
    <span className="ods-dot -automation" /> claude-opus-4-8
  </ComposerChip>
  <ComposerChip onRemove={() => {}}>checkout-svc/deploy.yaml</ComposerChip>
</Composer>;

With a token counter

meta renders as .ods-composer-meta, right before the Send/Stop button.

<div class="ods-composer">
  <textarea class="ods-composer-input" rows="1" aria-label="Prompt"></textarea>
  <div class="ods-composer-toolbar">
    <span class="grow"></span>
    <span class="ods-composer-meta">120 / 8k</span>
    <button type="button" class="ods-btn -primary -sm">Send</button>
  </div>
</div>
<Composer meta="120 / 8k" placeholder="Ask the agent…" />

Streaming

While isStreaming, the Send button is replaced by a Stop button. Escape stops generation only while streaming — it's not a generic "clear" shortcut.

<div class="ods-composer">
  <textarea class="ods-composer-input" rows="1" aria-label="Prompt">Summarize the last deploy…</textarea>
  <div class="ods-composer-toolbar">
    <span class="grow"></span>
    <button type="button" class="ods-btn -secondary -sm">Stop</button>
  </div>
</div>
<Composer isStreaming defaultValue="Summarize the last deploy…" onStop={() => {}} />

Disabled

<div class="ods-composer">
  <textarea class="ods-composer-input" rows="1" aria-label="Prompt" disabled>Composer is disabled</textarea>
  <div class="ods-composer-toolbar">
    <span class="grow"></span>
    <button type="button" class="ods-btn -primary -sm" disabled>Send</button>
  </div>
</div>
<Composer disabled defaultValue="Composer is disabled" />

API Reference

<Composer>

Extends TextareaHTMLAttributes (minus value/defaultValue/onChange/className, which are redefined below).

PropTypeDefaultDescription
valuestring—Controlled value. Pair with onValueChange. Omit both to use uncontrolled defaultValue.
defaultValuestring—Initial value for uncontrolled usage.
onValueChange(value: string) => void—Fired on every keystroke.
onSend(value: string) => void—Fired with the current value on Cmd/Ctrl+Enter and on Send-button click. In uncontrolled mode, the composer clears itself after sending; in controlled mode you own value and decide whether/how to clear it.
onStop() => void—Fired on Escape, only while isStreaming is true.
isStreamingbooleanfalseSwaps the Send button for a Stop button and enables the Escape-to-stop shortcut.
childrenReactNode—Rendered in the toolbar before the trailing meta/send controls — typically ComposerChips.
metaReactNode—Rendered as .ods-composer-meta (e.g. a token counter like "120 / 8k").
sendLabelstring"Send"Send button label.
stopLabelstring"Stop"Stop button label.
disabledboolean—Disables the textarea and the Send button (blocked when the trimmed value is empty, too).
rowsnumber1Initial textarea row count.
aria-labelstring"Prompt"Accessible name for the textarea.

<ComposerChip>

PropTypeDefaultDescription
onRemove() => void—Renders a remove control (.ods-iconbtn -sm) when provided. Omit for a non-removable chip.
removeLabelstring"Remove"Accessible label for the remove control.
childrenReactNode—Chip content.

HTML-side modifiers: .ods-composer (base, no variants) wrapping .ods-composer-input (textarea) and .ods-composer-toolbar (holds .ods-composer-chips, .ods-composer-meta, and the send/stop button). The chip's remove affordance reuses .ods-iconbtn -sm — there's no dedicated "chip remove" class.

Accessibility

  • Cmd/Ctrl+Enter sends and Escape stops (while streaming) per AGENTS.md #8's keyboard requirements for the composer — both are wired in the onKeyDown handler, not via a global listener, so they only fire while the textarea has focus.
  • disabled and an empty (or whitespace-only) trimmed value both suppress the Send button natively (disabled attribute), not just visually.
  • The textarea has aria-label="Prompt" by default — override it if the surrounding page already has a visible label for the composer, to avoid duplicate/conflicting accessible names.
  • ComposerChip's remove control is a real <button aria-label> sized via the same .ods-iconbtn class used for other icon-only toolbar actions (AGENTS.md #3), so it's independently focusable and announced correctly — it isn't just a decorative × inside the chip's text.