<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.
Your message row appears when the run is issued
The user-message row is drawn when a run is actually handed to an engine (beginTurn(prompt)), not when Send is tapped. submit returning sent is the tap; the row belongs to the run that follows it.
That distinction is what keeps a stray “You said:” bubble off the screen:
See Transcript User Row → When the row appears.
Layout invariants
Three CSS rules decide whether the composer is usable on a phone, each pinned byapp/src/css.test.ts.
Each of these was a measured defect, not a hypothetical. The
shipping-to-stores page ties them into the wider ship path.
The double-count guard in
app/src/css.test.ts now knows both the --safe-area-inset-* and the newer --inset-* variable names, so the rename that split the two apart could not silently make it stop guarding anything.On Android the keyboard height does not come from
visualViewport at all — it arrives through the window-insets bridge on every frame of the IME animation, so --keyboard-height updates continuously and the composer slides with the keyboard rather than teleporting between endpoints.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.

