{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-composer","type":"registry:component","title":"Agent composer","description":"The taller composer for agent surfaces: branch, folder and context on a tab above, permissions and model as pills that grow into their lists.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/model-picker.json","https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/agent-composer.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUp, Folder, Gauge, GitBranch, Hand, ListChecks, Mic, Plus, ShieldAlert, Square } from \"lucide-react\";\nimport { ModelPicker, type ModelOption } from \"./model-picker\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * AGENT COMPOSER: the taller composer for agent surfaces\n *\n *   status      a tab hangs off the card's top edge with the\n *               branch, the project folder and how full the\n *               context is; each morphs when it changes, and the\n *               context ring fills, turning amber past 80%\n *   prompt      type; Enter sends, Shift+Enter adds a line, and\n *               the field grows with the text\n *   permission  the pill under the prompt says how much the agent\n *               may do on its own (Auto, Ask first, Plan only,\n *               Full access) and grows into a list with a line on\n *               each, clear of the composer\n *   model       the model pill on the right grows into its own\n *               list, with thinking effort for models that reason\n *   send        the arrow blurs into a spinner, then into Stop;\n *               the mic beside it breathes while it listens\n *   files       + opens the file picker, and paste and drop work\n *               too; chips fold open above the prompt\n *\n * status takes useChat's status as is.\n * ───────────────────────────────────────────────────────── */\n\nexport type AgentComposerStatus = \"ready\" | \"submitted\" | \"streaming\" | \"error\";\n\nexport interface AgentComposerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSubmit\" | \"defaultValue\"> {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onSubmit: (value: string) => void;\n  onStop?: () => void;\n  /** Pass useChat's status straight through. */\n  status?: AgentComposerStatus;\n  placeholder?: string;\n  /** The tab on top. Leave all three out and there's no tab. */\n  branch?: string;\n  folder?: string;\n  context?: { used: number; limit: number };\n  /** The permission modes; defaults to Auto, Ask first, Plan only and Full access. */\n  permissions?: ModelOption[];\n  permission?: string;\n  defaultPermission?: string;\n  onPermissionChange?: (id: string) => void;\n  models: ModelOption[];\n  model: string;\n  onModelChange: (id: string) => void;\n  effort?: string;\n  onEffortChange?: (effort: string) => void;\n  /** Enables +, paste and drop. */\n  onFilesSelected?: (files: File[]) => void;\n  accept?: string;\n  /** Attachment chips, shown above the prompt. */\n  attachments?: React.ReactNode;\n  /** Shows the mic. */\n  onVoice?: () => void;\n  listening?: boolean;\n}\n\nexport const AGENT_PERMISSIONS: ModelOption[] = [\n  { id: \"auto\", name: \"Auto\", description: \"Decides for itself, asks when it's unsure\", icon: <Gauge /> },\n  { id: \"ask\", name: \"Ask first\", description: \"Checks with you before every change\", icon: <Hand /> },\n  { id: \"plan\", name: \"Plan only\", description: \"Reads and plans, changes nothing\", icon: <ListChecks /> },\n  { id: \"full\", name: \"Full access\", description: \"Runs everything without asking\", icon: <ShieldAlert /> },\n];\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst compactNumber = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\nfunction Fold({ show, reduced, children }: { show: boolean; reduced: boolean; children: React.ReactNode }) {\n  const [last, setLast] = React.useState(children);\n  if (show && children !== last) setLast(children);\n  return (\n    <div\n      aria-hidden={!show || undefined}\n      inert={!show}\n      className=\"grid\"\n      style={{\n        gridTemplateRows: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        transition: reduced ? \"none\" : `grid-template-rows 380ms ${MORPH}, opacity ${show ? \"280ms\" : \"140ms\"} ${MORPH}`,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">{show ? children : last}</div>\n    </div>\n  );\n}\n\n/** How full the context is: a small ring that fills, amber past 80% and red past 95%, with the share rolling beside it. */\nfunction ContextMeter({ used, limit, reduced }: { used: number; limit: number; reduced: boolean }) {\n  const share = limit ? Math.min(1, used / limit) : 0;\n  const c = 2 * Math.PI * 5;\n  const tone = share >= 0.95 ? \"text-red-500\" : share >= 0.8 ? \"text-amber-500\" : \"text-foreground/60\";\n  return (\n    <span className=\"flex items-center gap-1.5\" title={`${compactNumber.format(used)} of ${compactNumber.format(limit)} tokens`}>\n      <svg viewBox=\"0 0 14 14\" aria-hidden=\"true\" className={`size-3.5 -rotate-90 transition-colors duration-300 ${tone}`}>\n        <circle cx=\"7\" cy=\"7\" r=\"5\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.2\" strokeWidth=\"2\" />\n        <circle\n          cx=\"7\"\n          cy=\"7\"\n          r=\"5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeDasharray={c}\n          style={{ strokeDashoffset: c * (1 - share), transition: reduced ? \"none\" : `stroke-dashoffset 600ms ${MORPH}` }}\n        />\n      </svg>\n      <span className=\"tabular-nums\">\n        <NumberRoll value={share} format={{ style: \"percent\" }} duration={600} />\n      </span>\n      <span className=\"sr-only\">of the context used</span>\n    </span>\n  );\n}\n\nexport function AgentComposer({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onSubmit,\n  onStop,\n  status = \"ready\",\n  placeholder = \"What should we build?\",\n  branch,\n  folder,\n  context,\n  permissions = AGENT_PERMISSIONS,\n  permission,\n  defaultPermission,\n  onPermissionChange,\n  models,\n  model,\n  onModelChange,\n  effort,\n  onEffortChange,\n  onFilesSelected,\n  accept,\n  attachments,\n  onVoice,\n  listening = false,\n  className = \"\",\n  ...props\n}: AgentComposerProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue);\n  const [ownPermission, setOwnPermission] = React.useState(defaultPermission ?? permissions[0]?.id ?? \"\");\n  const [dragging, setDragging] = React.useState(false);\n  const text = value ?? own;\n  const chosenPermission = permission ?? ownPermission;\n  const cardRef = React.useRef<HTMLFormElement>(null);\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const fileRef = React.useRef<HTMLInputElement>(null);\n  const busy = status === \"submitted\" || status === \"streaming\";\n  const canSend = !busy && text.trim().length > 0;\n  const hasTab = Boolean(branch || folder || context);\n\n  const setText = (next: string) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  React.useLayoutEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 220)}px`;\n  }, [text]);\n\n  const submit = () => {\n    if (!canSend) return;\n    onSubmit(text);\n    if (value === undefined) setOwn(\"\");\n  };\n\n  const takeFiles = (list: FileList | null) => {\n    if (!list || list.length === 0 || !onFilesSelected) return false;\n    onFilesSelected(Array.from(list));\n    return true;\n  };\n\n  const action = busy ? (status === \"streaming\" && onStop ? \"stop\" : \"wait\") : \"send\";\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      {/* The tab: where the agent is working, and how full its context is. */}\n      {hasTab && (\n        <div className=\"mx-3.5 flex h-7 items-center gap-3 rounded-t-[12px] bg-muted px-3 text-[11.5px] text-muted-foreground\">\n          {branch && (\n            <span className=\"flex min-w-0 items-center gap-1.5\">\n              <GitBranch aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n              <span className=\"sr-only\">Branch</span>\n              <span className=\"truncate\">\n                <TextMorph>{branch}</TextMorph>\n              </span>\n            </span>\n          )}\n          {folder && (\n            <span className=\"flex min-w-0 items-center gap-1.5\">\n              <Folder aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n              <span className=\"sr-only\">Project</span>\n              <span className=\"truncate\">\n                <TextMorph>{folder}</TextMorph>\n              </span>\n            </span>\n          )}\n          {context && (\n            <span className=\"ml-auto shrink-0\">\n              <ContextMeter used={context.used} limit={context.limit} reduced={reduced} />\n            </span>\n          )}\n        </div>\n      )}\n\n      <form\n        ref={cardRef}\n        onSubmit={(e) => {\n          e.preventDefault();\n          submit();\n        }}\n        onDragOver={(e) => {\n          if (!onFilesSelected || !e.dataTransfer.types.includes(\"Files\")) return;\n          e.preventDefault();\n          setDragging(true);\n        }}\n        onDragLeave={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);\n        }}\n        onDrop={(e) => {\n          if (!onFilesSelected) return;\n          e.preventDefault();\n          setDragging(false);\n          takeFiles(e.dataTransfer.files);\n        }}\n        className={`@container relative rounded-[20px] bg-background p-1.5 transition-shadow duration-200 ${\n          dragging ? \"shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_40%,transparent)]\" : \"shadow-[0_0_0_1px_var(--border)] focus-within:shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_22%,transparent)]\"\n        }`}\n      >\n        <Fold show={Boolean(attachments)} reduced={reduced}>\n          <div className=\"flex flex-wrap gap-2 px-2 pt-2\">{attachments}</div>\n        </Fold>\n\n        <textarea\n          ref={textareaRef}\n          rows={1}\n          value={text}\n          placeholder={placeholder}\n          aria-label={placeholder}\n          onChange={(e) => setText(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n              e.preventDefault();\n              submit();\n            }\n          }}\n          onPaste={(e) => {\n            if (takeFiles(e.clipboardData.files) && !e.clipboardData.getData(\"text\")) e.preventDefault();\n          }}\n          className=\"block max-h-[220px] w-full resize-none overflow-y-auto bg-transparent px-2.5 pb-2 pt-2 text-[14px] leading-6 text-foreground outline-none placeholder:text-muted-foreground\"\n        />\n\n        <div className=\"flex items-center gap-0.5\">\n          {onFilesSelected && (\n            <>\n              <button\n                type=\"button\"\n                aria-label=\"Add files\"\n                onClick={() => fileRef.current?.click()}\n                className={`flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-foreground transition-colors hover:bg-accent @min-[26rem]:size-8 ${FOCUS}`}\n              >\n                <Plus className=\"size-4\" />\n              </button>\n              <input\n                ref={fileRef}\n                type=\"file\"\n                multiple\n                accept={accept}\n                className=\"hidden\"\n                onChange={(e) => {\n                  takeFiles(e.target.files);\n                  e.target.value = \"\";\n                }}\n              />\n            </>\n          )}\n          <ModelPicker\n            label=\"Permission\"\n            models={permissions}\n            value={chosenPermission}\n            onValueChange={(id) => {\n              if (permission === undefined) setOwnPermission(id);\n              onPermissionChange?.(id);\n            }}\n            anchorRef={cardRef}\n            side=\"top\"\n            align=\"start\"\n          />\n\n          <div className=\"ml-auto flex items-center gap-0.5 @min-[26rem]:gap-1\">\n            <ModelPicker label=\"Model\" models={models} value={model} onValueChange={onModelChange} effort={effort} onEffortChange={onEffortChange} anchorRef={cardRef} side=\"top\" align=\"end\" />\n            {onVoice && (\n              <button\n                type=\"button\"\n                aria-label={listening ? \"Stop listening\" : \"Speak\"}\n                aria-pressed={listening}\n                onClick={onVoice}\n                // On the narrowest composers the mic steps aside so Send always fits.\n                className={`relative hidden size-7 shrink-0 items-center justify-center rounded-full transition-colors duration-200 @min-[22rem]:flex @min-[26rem]:size-8 ${FOCUS} ${\n                  listening ? \"text-primary\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"\n                }`}\n              >\n                {/* Breathes only while it listens. */}\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 rounded-full bg-primary/12\"\n                  style={{ opacity: listening ? 1 : 0, animation: listening && !reduced ? \"ui-breathe 1.6s ease-in-out infinite\" : \"none\", transition: `opacity 200ms ${MORPH}` }}\n                />\n                <Mic className=\"relative size-4\" />\n              </button>\n            )}\n            <button\n              type={action === \"send\" ? \"submit\" : \"button\"}\n              aria-label={action === \"stop\" ? \"Stop\" : action === \"wait\" ? \"Sending\" : \"Send\"}\n              disabled={action === \"send\" ? !canSend : action === \"wait\"}\n              onClick={action === \"stop\" ? onStop : undefined}\n              className={`relative flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[opacity,transform] duration-200 active:scale-95 disabled:opacity-35 ${FOCUS}`}\n            >\n              <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(action === \"send\", reduced)}>\n                <ArrowUp className=\"size-4\" />\n              </span>\n              <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(action === \"stop\", reduced)}>\n                <Square fill=\"currentColor\" className=\"size-3\" />\n              </span>\n              <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(action === \"wait\", reduced)}>\n                <span className={`size-3.5 rounded-full border-[1.5px] border-current/35 border-t-current motion-reduce:animate-none ${action === \"wait\" ? \"animate-spin\" : \"\"}`} />\n              </span>\n            </button>\n          </div>\n        </div>\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 flex items-center justify-center rounded-[20px] bg-background/85 text-[13px] font-medium text-foreground\"\n          style={{ opacity: dragging ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${MORPH}` }}\n        >\n          Drop files to attach\n        </div>\n      </form>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-breathe":{"0%, 100%":{"transform":"scale(0.82)","opacity":"0.55"},"50%":{"transform":"scale(1)","opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/agent-composer"}