Composer
Free · MITWhere a person asks. Mentions (@) attach things in the user's system as chips the agent receives as references, not as text it has to parse; slash commands (/) start a known action. The scope pill says which environment the ask runs against, and turns to a warning when that is production.
Enter sends, Shift+Enter breaks a line. While an agent replies the send button becomes Stop. A locked composer says why; an offline one says so. With onAttach, files can also be pasted or dropped onto it. tools adds your own controls to the toolbar, such as a dictation button. A held composer ({ kind: "held", reason }) keeps typing open and says why sending waits. textareaRef focuses the box from outside, e.g. after a new project starts.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/composer
Brings in: attachment-preview, chat, entity-chip, locale, tooltip, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { Composer } from "@/components/composer"
import type { Attachment, EntityRef } from "@/lib/chat"
export default function Example() {
const [value, setValue] = React.useState("")
const [refs, setRefs] = React.useState<EntityRef[]>([])
// Attach with the button, by pasting a screenshot, or by dropping files on the box. A real app uploads here and
// sets progress, then a previewUrl it serves; this one only lists them.
const [files, setFiles] = React.useState<Attachment[]>([{ id: "f0", name: "refund-policy.pdf", size: 182_000, mediaType: "application/pdf" }])
const attach = (picked: File[]) =>
setFiles((f) => [...f, ...picked.map((p, i) => ({ id: `f${f.length + i + 1}`, name: p.name, size: p.size, mediaType: p.type }))])
return (
<Composer
className="max-w-2xl"
value={value}
onChange={setValue}
onSubmit={() => setValue("")}
references={refs}
onReferencesChange={setRefs}
attachments={files}
onAttach={attach}
onRemoveAttachment={(id) => setFiles((f) => f.filter((x) => x.id !== id))}
scope={{ environment: "Shop", detail: "staging", production: false }}
mentions={[
{ id: "m1", group: "Tables", label: "orders", entity: { kind: "table", name: "orders" }, description: "Every order" },
{ id: "m2", group: "People", label: "Dana Ortiz", description: "Support lead" },
]}
commands={[{ id: "c1", name: "refund", description: "Draft a refund for an order", args: "<order>" }]}
/>
)
}<Composer>
| Prop | Type | Default | Description |
|---|---|---|---|
| valuerequired | string | — | The text in the box. |
| onChangerequired | (value: string) => void | — | Called with the new text as the person types. |
| onSubmitrequired | (value: string, references: EntityRef[]) => void | — | Called on send with the text and the entities mentioned. |
| onStop | (() => void) | undefined | — | Shown as Stop while a reply is streaming. |
| state | ComposerState | undefined | "ready" | ready, sending, streaming, locked (with a reason), held (typing open, sending waits, with a reason) or offline. |
| scope | ComposerScope | undefined | — | Where the next message's work lands; production turns the pill to a warning. |
| onScopeClick | (() => void) | undefined | — | Makes the scope pill a button, e.g. to change environment. |
| mentions | MentionOption[] | undefined | [] | What @ can insert, grouped under their headings. |
| commands | SlashCommand[] | undefined | [] | What / can run, only at the start of a message. |
| references | EntityRef[] | undefined | [] | Entities already attached, shown as chips. A chip added after the composer drew pops in; a removed one pops out. |
| onReferencesChange | ((references: EntityRef[]) => void) | undefined | — | Called when a chip is added or removed. |
| attachments | Attachment[] | undefined | [] | Files attached, with upload progress or an error, as chips that pop in when added and out when removed. |
| onAttach | ((files: File[]) => void) | undefined | — | Shows an attach button; called with the chosen files. |
| accept | string | undefined | — | Which files the attach button's picker offers, as for `<input accept>` (e.g. "image/*,.pdf"). Files pasted or dropped arrive as they are, so check types where you upload. |
| onRemoveAttachment | ((id: string) => void) | undefined | — | Called when a file is removed. |
| placeholder | string | undefined | — | Shown in the empty box; "Ask a question, or describe a change" by default. |
| label | string | undefined | — | The accessible name of the text box; "Message" by default. |
| tools | React.ReactNode | undefined | — | Your own controls in the toolbar, after the attach button: a dictation button, a model picker. |
| hints | boolean | undefined | true | The keyboard hints shown at wide widths ("↵ send · ⇧↵ new line"), naming @ and / only when `mentions` or `commands` are given. Defaults to true; false hides them. |
| textareaRef | React.Ref<HTMLTextAreaElement> | undefined | — | The text box, to focus it or read its selection from outside. |
| sendShape | "square" | "round" | undefined | "square" | The send button's shape: "square" (the default, rounded corners) or "round". |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under composer in <LocaleProvider strings={{ … }}>; the table is exported as composerStrings.
| Key | English |
|---|---|
| placeholder | Ask a question, or describe a change |
| label | Message |
| commands | Commands |
| mentions | Mentions |
| attachFiles | Attach files |
| production | Production |
| nonProduction | Non-production |
| offline | You’re offline. Your message will wait here. |
| hintSend | send |
| hintNewLine | new line |
| hintMention | mention |
| hintCommand | command |
| stop | Stop |
| send | Send |
| dropFiles | Drop files to attach |
Types
- ComposerState
| "ready" | "sending" | "streaming" | { kind: "locked"; reason: string } | { kind: "offline"; reason?: string | undefined } /** Typing stays open, but sending waits, and the box says why: "Your partner is still thinking". */ | { kind: "held"; reason: string }