Composer

Free · MIT

Where 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.

Markdown

Preview

Install

With the Candor CLI

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

TSX · The example above
"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>

PropTypeDefaultDescription
valuerequiredstring—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.
stateComposerState | undefined"ready"ready, sending, streaming, locked (with a reason), held (typing open, sending waits, with a reason) or offline.
scopeComposerScope | 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.
mentionsMentionOption[] | undefined[]What @ can insert, grouped under their headings.
commandsSlashCommand[] | undefined[]What / can run, only at the start of a message.
referencesEntityRef[] | 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.
attachmentsAttachment[] | 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.
acceptstring | 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.
placeholderstring | undefined—Shown in the empty box; "Ask a question, or describe a change" by default.
labelstring | undefined—The accessible name of the text box; "Message" by default.
toolsReact.ReactNode | undefined—Your own controls in the toolbar, after the attach button: a dictation button, a model picker.
hintsboolean | undefinedtrueThe keyboard hints shown at wide widths ("↵ send · ⇧↵ new line"), naming @ and / only when `mentions` or `commands` are given. Defaults to true; false hides them.
textareaRefReact.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".
classNamestring | 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.

KeyEnglish
placeholderAsk a question, or describe a change
labelMessage
commandsCommands
mentionsMentions
attachFilesAttach files
productionProduction
nonProductionNon-production
offlineYou’re offline. Your message will wait here.
hintSendsend
hintNewLinenew line
hintMentionmention
hintCommandcommand
stopStop
sendSend
dropFilesDrop 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 }