AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUp, FileIcon, Loader2, Paperclip, RotateCcw, Square, TriangleAlert, X } from "lucide-react"
import { AutosizeTextarea } from "@/registry/ui/autosize-textarea"
import { cn } from "@/lib/utils"

/** Which keystroke commits the message. Flip it for users who expect the opposite. */
export type ChatComposerSendKey = "enter" | "mod-enter"

export type ChatAttachmentStatus = "uploading" | "ready" | "error"

export interface ChatAttachment {
  /** Stable within one composer instance; survives list mutations. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chat-composer.json

Prompt

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 gateisComposing, keyCode === 229, and a composition flag cleared one macrotask after compositionend (WebKit fires it before the confirming keydown). Enter during composition is left entirely to the IME.
  • Refusal as feedbackaria-disabled keeps 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 native disabled button.
  • 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.

On This Page