<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
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 inComposerState, 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 isheightFor(lineCountOf(text)) on every draft change — a floor that keeps it tappable, a ceiling that stops it eating the conversation.
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.
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.
Best Practices
Keep the draft in state, not the field
Keep the draft in state, not the field
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.Guard the send-disable on the action
Guard the send-disable on the action
streaming ? false : draftOf(state).trim() === "". Disabling on the draft alone makes a streaming Stop go dead the instant the field is empty.Ignore Enter mid-composition
Ignore Enter mid-composition
Check
isComposing before treating Enter as send. Sending during an IME composition posts a half-typed CJK or Tamil message.Clamp the autosize
Clamp the autosize
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.Let submit clear the draft
Let submit clear the draft
Take and clear atomically so a double tap on send is a no-op.
busy cannot close the pre-stream window on its own.Related
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.

