Chat Composer
A message composer that owns the send⇄stop run — clears the draft on send, aborts on stop, restores it on failure, and gates send on attachment uploads.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-composer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ChatComposer" component (lucide-react
icons, plus an autosizing textarea primitive). It is the *managed* counterpart
to a controlled prompt field: the component owns the draft and the in-flight
run, so the consumer only supplies async work.
Contract
- forwardRef<HTMLTextAreaElement> (the ref lands on the textarea so callers can
focus it) extending Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit" |
"defaultValue">; the rest of the props spread onto the root wrapper.
- Required: onSend(payload, ctx) where payload is
{ text: string; attachments: ChatAttachment[] } and ctx is { signal:
AbortSignal }. Return a promise that settles when generation ends — the button
stays in its Stop state until then; returning nothing completes immediately.
- ChatAttachment = { id, file: File, status: "uploading" | "ready" | "error",
error?: string, url?: string }.
- Optional: onStop(), uploadAttachment(file, ctx) => Promise<string | void>,
defaultValue, placeholder, sendKey = "enter" | "mod-enter" (default "enter"),
maxRows (8), maxLength, maxAttachments (10), maxFileSize (bytes), accept,
disabled, toolbar (ReactNode), sendLabel ("Send message"), stopLabel
("Stop generating").
- There is deliberately no `value` prop. State that must survive a send
(the transcript, the streaming reply) belongs to the parent; the draft does not.
Behavior
- Send/Stop is one <button> element, never two swapped ones: swapping would
remount the node and drop keyboard focus the moment a run starts. Its
accessible name flips between sendLabel and stopLabel, so the state change is
announced, not just re-iconed (ArrowUp ⇄ filled Square).
- Never use the native `disabled` attribute — it blurs the element to <body>
and makes it unfocusable. Use aria-disabled plus an early return in the
handler; clicking a refused Send announces *why* instead of doing nothing.
- Sending: capture the payload, bump a run id, open an AbortController, then
clear the draft and attachments immediately — an interrupted message must not
still be sitting in the box afterwards. Resolve → idle. Reject → idle *and*
put the draft (and attachments) back, announcing the reason: a failed send is
not the same event as a stopped one.
- Stopping: bump the run id first so the orphaned promise's settle is ignored,
abort the signal, call onStop, return to idle. The draft is NOT restored (it
was really sent); anything already generated belongs to the transcript
component, not here.
- Re-entry: keep the draft, attachments and busy flag in refs alongside state
and read the refs in every handler. Five clicks dispatched inside one JS task
all run the same render's closure, so a state-only guard lets clicks 2..5
through with a stale draft — the ref flips before the second click is
dispatched. Enter while a run is in flight is ignored, and the text typed
during that run is left untouched.
- Keys: sendKey="enter" → Enter sends, Shift+Enter inserts a newline;
sendKey="mod-enter" → Enter inserts a newline, ⌘/Ctrl+Enter sends (⌘/Ctrl+Enter
sends under both policies). Before any of that, three independent IME signals
short-circuit Enter: e.nativeEvent.isComposing, keyCode 229, and a composition
flag set on compositionStart. Clear that flag one macrotask after
compositionEnd — WebKit dispatches compositionend *before* the keydown that
confirmed the candidate, so clearing it synchronously would let that very
Enter send a half-finished CJK sentence.
- Autosize: delegate to an autosizing textarea with minRows=1 and maxRows, which
uses CSS `field-sizing: content` with min/max-height where supported (zero JS
layout reads per keystroke) and falls back to measure-once metrics + a single
height write otherwise. Growth is monotonic while text is appended and a large
paste lands on the cap in one step; past maxRows the field scrolls internally.
- Sendability: blocked when the trimmed text is empty *and* there are no
attachments (whitespace counts as empty), when the character count exceeds
maxLength, or while any attachment is uploading or errored. Attachments
without text are perfectly sendable.
- maxLength is a soft cap: never set the native maxlength attribute and never
truncate. Over the cap the counter turns destructive, the textarea gets
aria-invalid, and the refusal message says how many characters over it is —
every character the user typed stays in the field.
- Attachments have three entry points feeding one addFiles path: the hidden
<input type=file> behind the paperclip, onPaste (only preventDefault when
clipboardData.files is non-empty, so text paste is untouched), and drag/drop on
the shell with a dragenter/dragleave depth counter so nested children don't
flicker the highlight. Each file is checked against accept, maxFileSize,
maxAttachments and a name+size+lastModified identity; every rejection produces
its own human-readable sentence and leaves the existing selection untouched.
- Upload lifecycle: with uploadAttachment, new files start "uploading" and get
their own AbortController; resolve → "ready" (+ url), reject → "error" with a
Retry button. Wrap the call in new Promise(resolve => resolve(fn())) so a
*synchronous* throw becomes a rejection instead of pinning the chip at
"uploading" forever. Removing a chip aborts its upload; a late result for an
already-removed chip must not resurrect it; unmount aborts the run and every
upload.
- Image chips mint their object URL in a useState lazy initializer (once per
mounted chip) and revoke it in that same component's cleanup.
Rendering & styling
- Semantic tokens only: rounded-2xl border border-input bg-background shell with
focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50; drag
target swaps to border-primary bg-primary/5; the action button is bg-primary
text-primary-foreground; chips are bg-muted with border-destructive + a
text-destructive line when failed; the counter and the notice line turn
text-destructive. cn() merges the consumer className onto the root wrapper.
- A single role="status" aria-live="polite" line under the shell carries every
announcement (refusal reason, rejected files, upload failure, "Generation
stopped."). It is always in the DOM with a reserved min-height so appearing
text never shifts the layout and the live region is never created on the fly.
- The keyboard policy is exposed to screen readers through an sr-only span wired
with aria-describedby, so flipping sendKey changes what gets announced.
- Every icon button carries its own aria-label (Attach files, Remove <name>,
Retry upload of <name>); decorative icons are aria-hidden; the uploading
spinner is motion-reduce:animate-none and the chip still says "Uploading…" in
text, so the state survives with animation off.
Customization levers
- Key policy: sendKey is the one knob for "Enter sends" vs "Enter newlines" —
surface it as a user preference rather than hard-coding a house style.
- Run ownership: onSend's promise is the contract. Resolve it when the stream
ends for a Stop-able run, or return nothing for fire-and-forget posts where
the Stop state would be meaningless.
- Attachment policy: accept / maxFileSize / maxAttachments per surface, and drop
uploadAttachment entirely to keep raw Files client-side (chips go straight to
"ready" and send is never gated on a network round trip).
- Density: shell padding (p-2), the size-8 buttons and rounded-2xl radius are the
geometry knobs; scale them together for a compact or spacious variant.
- Toolbar slot: model pickers, tool toggles, a token readout — it sits next to
the paperclip and never touches the send engine.
- Labels: sendLabel / stopLabel and the notice sentences are the localization
surface; keep them phrased as reasons ("Wait for the attachment upload to
finish") rather than states, since they are what a refused click announces.Concepts
- Same-slot state machine — Send and Stop are one element whose accessible name flips, not two elements swapped by a ternary; the node is never remounted, so keyboard focus stays put the instant a run starts.
- Optimistic clear, conditional restore — the draft leaves the box the moment it is sent (so an interrupted message can't linger), but a rejected send hands it straight back. Stop and failure are different events and are treated differently.
- Run id orphaning — Stop bumps a monotonic run id before aborting, so the aborted promise's late settle is dropped instead of dragging the button back through a stale transition.
- Synchronous ref mirror — draft, attachments and the busy flag live in refs as well as state; handlers read the refs, because five clicks landing in one JS task all share one render's closure and a state-only guard would let the stale ones through.
- Triple IME gate —
isComposing,keyCode === 229, and a composition flag cleared one macrotask aftercompositionend(WebKit fires it before the confirming keydown). Enter during composition is left entirely to the IME. - Refusal as feedback —
aria-disabledkeeps the send button focusable and clickable, so a refused click can say why ("Wait for the attachment upload to finish") instead of silently doing nothing like a nativedisabledbutton. - Attachment lifecycle gates the send — a file that is still uploading, or that failed, blocks send with its own reason; removing a chip aborts its upload and a late result for a removed chip is discarded.
Message List
An AI transcript whose scroll behaves — glued to the newest token while streaming, released the instant you scroll up, anchored when older pages land, and windowed so a 1000-turn thread stays 12 nodes wide.
Message Actions
The action bar under one chat message — revealed by hover, focus and touch alike, costing zero layout, with copy in either Markdown or plain text, an exclusive thumbs tri-state and speech that stops when the message unmounts.