{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-chat",
  "title": "Agent chat",
  "description": "Talk to an agent. Its words are roman and yours arrive in italic; it asks before it acts, in a question you must answer, and it tells you what it is doing as a sentence that inks in.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/agent-state.json",
    "https://thedirectors.agency/ui/r/attachment.json",
    "https://thedirectors.agency/ui/r/button.json",
    "https://thedirectors.agency/ui/r/dialog.json",
    "https://thedirectors.agency/ui/r/field.json",
    "https://thedirectors.agency/ui/r/kbd.json",
    "https://thedirectors.agency/ui/r/marker.json",
    "https://thedirectors.agency/ui/r/progress.json",
    "https://thedirectors.agency/ui/r/radio-group.json",
    "https://thedirectors.agency/ui/r/thread.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/agent-chat.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { AgentState, type AgentStateValue } from \"@/components/ui/agent-state\"\nimport { Attachment, AttachmentList } from \"@/components/ui/attachment\"\nimport { Button } from \"@/components/ui/button\"\nimport { Dialog, DialogActions, DialogClose, DialogContent, DialogDescription, DialogMeta, DialogTitle } from \"@/components/ui/dialog\"\nimport { Field, Textarea } from \"@/components/ui/field\"\nimport { Kbd } from \"@/components/ui/kbd\"\nimport { Marker, type MarkerProps } from \"@/components/ui/marker\"\nimport { Progress, type ProgressProps } from \"@/components/ui/progress\"\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\"\nimport { Thread, type ThreadProps } from \"@/components/ui/thread\"\n\ntype AgentChatProps = React.ComponentProps<\"section\"> & {\n  /**\n   * default: the agent's work and your answers run in the conversation as quiet lines. callout: after Weingart's\n   * letter, they hang at the far side in reversed pills, each on a leader line that ends in a dot.\n   */\n  variant?: \"default\" | \"callout\"\n}\n\n/** Talk to an agent. A header, a thread and a composer, stacked; give it a height and the thread takes what's left. */\nfunction AgentChat({ variant = \"default\", className, ...props }: AgentChatProps) {\n  return <section data-slot=\"agent-chat\" data-variant={variant === \"default\" ? undefined : variant} className={cn(\"ot-agent-chat\", className)} {...props} />\n}\n\ntype AgentChatHeaderProps = Omit<React.ComponentProps<\"header\">, \"title\" | \"children\"> & {\n  title: React.ReactNode\n  /** Where the agent is. The words default to the state's own (\"Working\"). */\n  state: AgentStateValue\n  /** Other words for the state: \"Reading the brief\". */\n  status?: string\n}\n\n/** The agent's name, and where it is. */\nfunction AgentChatHeader({ title, state, status, className, ...props }: AgentChatHeaderProps) {\n  return (\n    <header data-slot=\"agent-chat-header\" className={cn(\"ot-agent-chat-head\", className)} {...props}>\n      <h2>{title}</h2>\n      <AgentState state={state}>{status}</AgentState>\n    </header>\n  )\n}\n\n/** The conversation: a Thread that takes the room the header and the composer leave. */\nfunction AgentChatThread({ className, ...props }: ThreadProps) {\n  return <Thread data-slot=\"agent-chat-thread\" className={cn(\"ot-agent-chat-thread\", className)} {...props} />\n}\n\n/** A quiet line from the agent's side of things: \"Opened the Halden folder.\" Hangs as a callout in that variant. */\nfunction AgentChatNote({ dot = true, className, ...props }: MarkerProps) {\n  return (\n    <div data-slot=\"agent-chat-note\" className={cn(\"ot-agent-note\", className)}>\n      <Marker dot={dot} {...props} />\n    </div>\n  )\n}\n\n/** What the agent is doing, as a sentence that inks in as it goes. */\nfunction AgentChatWork({ className, ...props }: Omit<ProgressProps, \"variant\">) {\n  return (\n    <div data-slot=\"agent-chat-work\" className={cn(\"ot-agent-note\", className)}>\n      <Progress variant=\"sentence\" {...props} />\n    </div>\n  )\n}\n\ntype Decision = \"pending\" | \"allowed\" | \"denied\"\n\ntype AgentChatPermissionProps = {\n  /** The question, in the agent's words: \"Read last season's timetable?\" */\n  title: string\n  description?: React.ReactNode\n  /** One fact over the question: what it reaches, \"Halden folder, read only\". */\n  scope?: React.ReactNode\n  /** Pending asks, in a dialog that must be answered; answered, only the receipt stays in the thread. */\n  decision: Decision\n  onDecision: (decision: \"allowed\" | \"denied\") => void\n  allowLabel?: string\n  denyLabel?: string\n  /** The receipt's word for each answer; an action keeps its name. */\n  allowedLabel?: string\n  deniedLabel?: string\n  /** The word over the question, before the scope. */\n  metaLabel?: string\n  /** The receipt while the question waits. */\n  pendingLabel?: string\n}\n\n/**\n * The agent asks before it acts. The question is ours, heavy and narrow; the answers are yours, large in italic.\n * Deny has the focus, and Escape denies too, so nothing is allowed by default. The question and your answer stay\n * in the thread as a receipt: \"Read last season's timetable? Allowed once.\"\n */\nfunction AgentChatPermission({\n  title,\n  description,\n  scope,\n  decision,\n  onDecision,\n  allowLabel = \"Allow once\",\n  denyLabel = \"Deny\",\n  allowedLabel = \"Allowed once\",\n  deniedLabel = \"Denied\",\n  metaLabel = \"Permission\",\n  pendingLabel = \"Waiting for your answer.\",\n}: AgentChatPermissionProps) {\n  // The close that follows an answer mustn't count as a second answer, so the answer is kept at once, not on re-render.\n  const answered = React.useRef(false)\n  React.useEffect(() => {\n    if (decision === \"pending\") answered.current = false\n  }, [decision])\n  const decide = (d: \"allowed\" | \"denied\") => {\n    if (decision !== \"pending\" || answered.current) return\n    answered.current = true\n    onDecision(d)\n  }\n  return (\n    <>\n      <Dialog alert open={decision === \"pending\"} onOpenChange={(open) => !open && decide(\"denied\")}>\n        <DialogContent variant=\"reply\">\n          {scope ? <DialogMeta><span>{metaLabel}</span><hr /><span>{scope}</span></DialogMeta> : null}\n          <DialogTitle>{title}</DialogTitle>\n          {description ? <DialogDescription>{description}</DialogDescription> : null}\n          <DialogActions>\n            <DialogClose onClick={() => decide(\"allowed\")}>{allowLabel}</DialogClose>\n            <DialogClose data-autofocus onClick={() => decide(\"denied\")}>{denyLabel}</DialogClose>\n          </DialogActions>\n        </DialogContent>\n      </Dialog>\n      <AgentChatNote data-slot=\"agent-chat-receipt\" data-decision={decision} dot={decision !== \"pending\"}>\n        {title}{\" \"}\n        {decision === \"pending\" ? <span>{pendingLabel}</span> : <span className=\"ot-yours\">{decision === \"allowed\" ? allowedLabel : deniedLabel}.</span>}\n      </AgentChatNote>\n    </>\n  )\n}\n\ntype AgentChatChoiceProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\"> & {\n  /** The agent's question: \"Which way should the timetable read?\" */\n  question: React.ReactNode\n  options: readonly { value: string; label: string }[]\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  onConfirm: (value: string) => void\n  confirmLabel?: string\n  /** The value you confirmed. The choice closes: the words stay, your word in italic, and nothing can be changed. */\n  answer?: string\n}\n\n/** The agent needs you to pick one. The words are the radios; Continue answers. */\nfunction AgentChatChoice({ question, options, value, defaultValue, onValueChange, onConfirm, confirmLabel = \"Continue\", answer, className, ...props }: AgentChatChoiceProps) {\n  const [local, setLocal] = React.useState(defaultValue)\n  const chosen = answer ?? value ?? local\n  return (\n    <div data-slot=\"agent-chat-choice\" data-answered={answer !== undefined || undefined} className={cn(\"ot-agent-choice\", className)} {...props}>\n      <RadioGroup\n        legend={question}\n        value={chosen}\n        orientation=\"vertical\"\n        onValueChange={(v) => {\n          if (value === undefined) setLocal(v)\n          onValueChange?.(v)\n        }}\n      >\n        {options.map((o) => (\n          <RadioGroupItem key={o.value} value={o.value} disabled={answer !== undefined}>\n            {o.label}\n          </RadioGroupItem>\n        ))}\n      </RadioGroup>\n      {answer === undefined ? (\n        <Button variant=\"bracket\" disabled={!chosen} onClick={() => chosen && onConfirm(chosen)}>\n          {confirmLabel}\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\ntype ComposerAttachment = { id: string; name: string; size?: string; state?: \"uploading\" | \"processing\" | \"done\" | \"error\"; progress?: number; status?: string }\n\ntype AgentChatComposerProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\"> & {\n  /** Names the box: \"Write to Ada\". */\n  label: string\n  placeholder?: string\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  /** Called with the words when you send. Held here, the box then clears; held by you, clear it yourself. */\n  onSend: (value: string) => void\n  /** The agent is answering: Send gives way to Stop, and the box stays open for your next words. */\n  busy?: boolean\n  onStop?: () => void\n  attachments?: readonly ComposerAttachment[]\n  /** Files chosen with Attach or dropped on the composer. Without it there is no Attach. */\n  onAttach?: (files: File[]) => void\n  onRemoveAttachment?: (id: string) => void\n  accept?: string\n  /** What to fix, in plain words, hung from the box as a callout. */\n  error?: React.ReactNode\n  maxLength?: number\n  disabled?: boolean\n  sendLabel?: string\n  stopLabel?: string\n  attachLabel?: string\n  /** The word after the keys in the hint: \"⌘ Enter sends\". */\n  sendsLabel?: string\n  /** What the enclosure line says while files are dragged over, given how many. */\n  dropLabel?: (count: number) => string\n}\n\nconst noop = () => () => {}\nconst onMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)\n\n/**\n * Where you write to the agent: a ruled box your words arrive on in italic, the files enclosed under it as a\n * letter's \"Encl. (2)\", Attach and Send. Enter starts a new line; ⌘ Enter (Ctrl Enter) sends. Drag files over and\n * the enclosure line counts them in before you let go.\n */\nfunction AgentChatComposer({\n  label,\n  placeholder,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onSend,\n  busy = false,\n  onStop,\n  attachments = [],\n  onAttach,\n  onRemoveAttachment,\n  accept,\n  error,\n  maxLength,\n  disabled = false,\n  sendLabel = \"Send\",\n  stopLabel = \"Stop\",\n  attachLabel = \"Attach\",\n  sendsLabel = \"sends\",\n  dropLabel = (n) => `Let go to enclose ${n === 1 ? \"it\" : `all ${n}`}.`,\n  className,\n  ...props\n}: AgentChatComposerProps) {\n  const [local, setLocal] = React.useState(defaultValue)\n  const text = value ?? local\n  const [incoming, setIncoming] = React.useState(0)\n  const picker = React.useRef<HTMLInputElement>(null)\n  const mod = React.useSyncExternalStore(noop, () => (onMac() ? \"⌘\" : \"Ctrl\"), () => \"⌘\")\n  const canSend = !disabled && !busy && (text.trim() !== \"\" || attachments.length > 0)\n\n  const write = (v: string) => {\n    if (value === undefined) setLocal(v)\n    onValueChange?.(v)\n  }\n  const send = () => {\n    if (!canSend) return\n    onSend(text)\n    if (value === undefined) setLocal(\"\")\n  }\n  const files = (e: React.DragEvent) => onAttach && !disabled && e.dataTransfer.types.includes(\"Files\")\n  const shown = attachments.length + incoming\n\n  return (\n    <form\n      data-slot=\"agent-chat-composer\"\n      data-over={incoming > 0 || undefined}\n      className={cn(\"ot-agent-compose\", className)}\n      onSubmit={(e) => {\n        e.preventDefault()\n        send()\n      }}\n      onDragOver={(e) => {\n        if (!files(e)) return\n        e.preventDefault()\n        e.dataTransfer.dropEffect = \"copy\"\n        setIncoming(e.dataTransfer.items.length || 1)\n      }}\n      onDragLeave={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIncoming(0)\n      }}\n      onDrop={(e) => {\n        if (!files(e)) return\n        e.preventDefault()\n        setIncoming(0)\n        onAttach?.(Array.from(e.dataTransfer.files))\n      }}\n      {...props}\n    >\n      <Field label={label} error={error} maxLength={maxLength} count={maxLength !== undefined}>\n        <Textarea\n          value={text}\n          placeholder={placeholder}\n          disabled={disabled}\n          rows={2}\n          aria-keyshortcuts=\"Meta+Enter Control+Enter\"\n          onChange={(e) => write(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key !== \"Enter\" || !(e.metaKey || e.ctrlKey)) return\n            e.preventDefault()\n            send()\n          }}\n        />\n      </Field>\n      {shown > 0 ? (\n        <AttachmentList variant=\"enclosure\" data-count={shown} className=\"ot-agent-encl\">\n          {attachments.map((a) => (\n            <Attachment\n              key={a.id}\n              name={a.name}\n              size={a.size}\n              state={a.state}\n              progress={a.progress}\n              status={a.status}\n              onRemove={onRemoveAttachment && !disabled ? () => onRemoveAttachment(a.id) : undefined}\n            />\n          ))}\n          {incoming > 0 ? <li className=\"ot-agent-drop\">{dropLabel(incoming)}</li> : null}\n        </AttachmentList>\n      ) : null}\n      <div className=\"ot-agent-compose-actions\">\n        {onAttach ? (\n          <>\n            <Button variant=\"bracket\" type=\"button\" disabled={disabled} onClick={() => picker.current?.click()}>\n              {attachLabel}\n            </Button>\n            <input\n              ref={picker}\n              type=\"file\"\n              multiple\n              accept={accept}\n              tabIndex={-1}\n              aria-hidden=\"true\"\n              hidden\n              onChange={(e) => {\n                onAttach(Array.from(e.target.files ?? []))\n                e.target.value = \"\" // the same file can be chosen again\n              }}\n            />\n          </>\n        ) : null}\n        <p className=\"ot-agent-compose-hint\">\n          <Kbd>{mod}</Kbd> <Kbd>Enter</Kbd> {sendsLabel}\n        </p>\n        {busy && onStop ? (\n          <Button variant=\"bracket\" type=\"button\" onClick={onStop}>\n            {stopLabel}\n          </Button>\n        ) : (\n          <Button variant=\"statement\" type=\"submit\" disabled={!canSend}>\n            {sendLabel}\n          </Button>\n        )}\n      </div>\n    </form>\n  )\n}\n\nexport {\n  AgentChat,\n  AgentChatChoice,\n  AgentChatComposer,\n  AgentChatHeader,\n  AgentChatNote,\n  AgentChatPermission,\n  AgentChatThread,\n  AgentChatWork,\n  type AgentChatChoiceProps,\n  type AgentChatComposerProps,\n  type AgentChatHeaderProps,\n  type AgentChatPermissionProps,\n  type AgentChatProps,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/agent-chat.css",
      "content": "@layer components {\n  /* ── ot-agent-chat ── talk to an agent, after \"It has to be design.\": the agent's words are ours, in roman; yours\n     arrive in italic, and when it asks, the question is heavy and narrow and your answer is large. Composed of\n     ot-thread, ot-msg, ot-attachments, ot-field, ot-dialog, ot-choice, ot-progress, ot-marker and ot-agent. */\n  .ot-agent-chat {\n    display: grid; grid-template-rows: auto minmax(0, 1fr) auto; row-gap: var(--ot-space-5);\n    /* No taller than a window: the thread grows with its messages up to the ceiling, then scrolls. A fixed height left a short\n       thread with a dead block above its first message. Give it a height of your own and that wins. */\n    max-block-size: min(44rem, 88svh); container-type: inline-size;\n  }\n  .ot-agent-chat-head {\n    display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--ot-space-2) var(--ot-space-5);\n    padding-block-end: var(--ot-space-3); border-block-end: var(--ot-hairline) solid var(--ot-rule);\n  }\n  .ot-agent-chat-head > h2 { margin: 0; font-size: var(--ot-mp); line-height: var(--ot-mp-lh); font-weight: 600; letter-spacing: var(--ot-mp-tr); }\n  .ot-agent-chat > .ot-thread { block-size: auto; min-block-size: 0; }\n\n  /* The agent's work, told as it goes: a sentence that inks in, at the size of the messages. */\n  .ot-agent-note { display: grid; justify-items: start; max-inline-size: 34rem; }\n  .ot-agent-note .ot-progress[data-variant=\"sentence\"] > .ot-progress-label { font-size: var(--ot-mp); line-height: var(--ot-mp-lh); letter-spacing: var(--ot-mp-tr); }\n  /* A receipt: the question stays ours, the answer is yours. */\n  .ot-agent-note [data-decision] { flex-wrap: wrap; column-gap: var(--ot-space-2); color: var(--ot-graphite); }\n  .ot-agent-note [data-decision] > .ot-yours { color: var(--ot-ink); }\n  .ot-agent-note [data-decision=\"pending\"] > span { color: var(--ot-pencil); }\n\n  /* The choice: the question over a column of words, Continue under them. Answered, the words step back and only yours stays. */\n  .ot-agent-choice { display: grid; justify-items: start; row-gap: var(--ot-space-4); max-inline-size: 34rem; }\n  .ot-agent-choice[data-answered] .ot-choice-word { color: var(--ot-pencil); }\n\n  /* ── the composer ── a ruled box your words arrive on, the files under it as a letter's enclosure line. */\n  .ot-agent-compose { display: grid; row-gap: var(--ot-space-4); }\n  .ot-agent-compose textarea.ot-input { min-block-size: calc(var(--lh) * 2); max-block-size: calc(var(--lh) * 6); scrollbar-width: thin; }\n  .ot-agent-compose-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--ot-space-3) var(--ot-space-5); }\n  .ot-agent-compose-hint { flex: 1 1 auto; margin: 0; text-align: end; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); color: var(--ot-pencil); }\n  .ot-agent-compose-actions > .ot-btn:last-child { margin-inline-start: auto; } /* Send (or Stop) keeps the far end, wrapped or not */\n  @container (max-width: 22rem) { .ot-agent-compose-hint { display: none; } }\n  /* Disabled, the box keeps its rules, only fainter, and nothing takes the accent. */\n  .ot-agent-compose textarea.ot-input:disabled { background: repeating-linear-gradient(transparent 0 calc(var(--lh) - 1px), var(--ot-rule) calc(var(--lh) - 1px) var(--lh)) 0 var(--ot-rule-shift, -0.3rem) / 100% var(--lh); background-attachment: local; }\n  .ot-agent-compose-hint > .ot-kbd { color: var(--ot-graphite); }\n  /* Files dragged over: the enclosure line counts them in, in pencil, before you let go; let go and they are ink. */\n  .ot-agent-compose[data-over] .ot-agent-encl::before { color: var(--ot-ink); }\n  .ot-agent-drop { display: inline; font-style: italic; font-family: var(--ot-expression); font-size: calc(var(--ot-expression-scale) * 1em); color: var(--ot-pencil); }\n  .ot-agent-compose[data-over] textarea.ot-input { background-size: 100% var(--ot-stroke), 100% var(--lh); }\n\n  /* ── callout ── Weingart's letter: the agent's work and your receipts leave the column and hang at the far side, reversed\n     out of pills, each on a leader line that reaches back toward the conversation and ends in a dot. */\n  .ot-agent-chat[data-variant=\"callout\"] { --ot-agent-pill: var(--ot-ink); --ot-agent-on: var(--ot-paper); }\n  .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note {\n    position: relative; justify-self: end; max-inline-size: min(20rem, 72%);\n    margin-inline-start: var(--ot-space-7); padding: 0.35em 0.95em 0.42em; border-radius: 1.1em;\n    background: var(--ot-agent-pill);\n    /* inside the pill the ink is paper, so the parts that ink in (the work sentence) do it in reverse */\n    --ot-ink: var(--ot-agent-on); --ot-graphite: var(--ot-agent-on); --ot-pencil: color-mix(in srgb, var(--ot-agent-on) 62%, transparent); --ot-rule-strong: color-mix(in srgb, var(--ot-agent-on) 34%, transparent);\n    color: var(--ot-agent-on);\n  }\n  .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note::before {\n    content: \"\"; position: absolute; inset-block-start: 50%; inset-inline-end: 100%; inline-size: var(--ot-space-6); block-size: var(--ot-hairline);\n    background: var(--ot-agent-pill);\n  }\n  .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note::after {\n    content: \"\"; position: absolute; inset-block-start: calc(50% - 2px); inset-inline-end: calc(100% + var(--ot-space-6) - 2px); inline-size: 5px; block-size: 5px; border-radius: 50%;\n    background: var(--ot-agent-pill);\n  }\n  .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note :is(.ot-marker, .ot-progress-label) { font-size: var(--ot-pp); line-height: var(--ot-pp-lh); letter-spacing: 0; font-weight: 500; }\n  .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note .ot-marker { color: inherit; }\n  .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note .ot-marker-dot { display: none; } /* the leader's dot says it */\n  @media (forced-colors: active) {\n    .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note { background: Canvas; color: CanvasText; border: 1px solid CanvasText; }\n    .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note::before, .ot-agent-chat[data-variant=\"callout\"] .ot-agent-note::after { background: CanvasText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/agent-chat.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/agent-chat.css\"": {}
  },
  "meta": {
    "movement": "XI",
    "underneath": "native",
    "contract": "ot-agent-chat"
  },
  "type": "registry:ui"
}
