Skip to main content
The composer is a value, not just a <textarea>: your draft survives a trip to Settings, the height grows with your text and stops before it eats the transcript, and Enter sends the right thing on the right keyboard. Every decision lives in ui/src/composer/composer as a pure function — the field mirrors the state, the state is the source of truth.

Quick Start

1

Start from an empty composer

setDraft is immutable in, immutable out — a subscriber may hold the previous state and diff against it.
2

Mirror the field to the state

The input event copies the field into the state; the state drives the height back.
3

Decide what a key means

keyAction is pure and total — both policies and the IME case are assertable without a keyboard.

The draft survives the screen that holds it

A draft lives in ComposerState, keyed by conversation — not in the text node a route change unmounts. Navigating to Settings and back does not eat what you typed.
focusDraft(state, id) keeps the drafts map whole on a route change, so switching to another conversation shows its draft rather than the previous one’s text.

Autosize is clamped at both ends

The composer’s height is heightFor(lineCountOf(text)) on every draft change — a floor that keeps it tappable, a ceiling that stops it eating the conversation.
A non-finite or negative line count — what a measurement taken mid-rotation looks like — yields COMPOSER_MIN_PX, not NaN. A NaN reaching a style property drops the whole declaration silently and the composer lands under the keyboard.

Send is disabled on an empty draft; Stop never is

The disable rule guards on the action so a streaming Stop never goes dead just because the draft happens to be empty.
The draft is trimmed before the check: a draft of spaces and newlines is an empty message that still costs a request and still ends the conversation on a blank turn.

The key policy

keyAction decides send, newline, or ignore from the handful of fields a key event actually turns on.
Enter during an IME composition is neither a send nor a newline — isComposing returns "ignore". It is the key that commits a candidate; treating it as send posts a half-typed Japanese, Chinese, or Tamil message, and the author sees the mangled result only after it has gone.
Under enter-sends, Alt+Enter is equivalent to Shift+Enter — both insert a newline. Alt is the newline muscle memory on some layouts; if only Shift escaped enter-sends, Alt+Enter would SEND a half-written message. See the Overview composer table — the #4589 pin still holds.

How It Works — submit is atomic

submit(state, busy) takes the draft and clears it in one call. That is what makes the second tap of a double tap a no-op: it finds an empty draft.
busy alone cannot cover the window before streaming starts: the button is under a thumb and the first tap has no visible effect until the first token arrives. Clearing the draft inside submit — the same call that sends it — is what makes the second tap find nothing to send.

Best Practices

ComposerState is the source of truth; the <textarea> mirrors it. A route change unmounts the field but not the value, so the draft survives a trip to Settings.
streaming ? false : draftOf(state).trim() === "". Disabling on the draft alone makes a streaming Stop go dead the instant the field is empty.
Check isComposing before treating Enter as send. Sending during an IME composition posts a half-typed CJK or Tamil message.
Feed heightFor(lineCountOf(text)); the floor keeps a hit target and the ceiling stops the composer covering the transcript. A mid-rotation NaN falls back to the floor.
Take and clear atomically so a double tap on send is a no-op. busy cannot close the pre-stream window on its own.

Overview

The Send/Stop button and the trim-empty rule.

Follow & Jump

Stick-to-bottom while a turn streams.

i18n & A11y

Logical insets that mirror the composer padding under RTL.

Route Focus

Where focus lands after a route change.