{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-chat",
  "title": "AgentChat",
  "description": "Full agent chat pane: streamed markdown turns, tool chips, reasoning, and a composer with a model-controls slot.",
  "dependencies": [
    "ai",
    "@ai-sdk/react",
    "streamdown",
    "@hugeicons/react",
    "@hugeicons/core-free-icons",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/message.json",
    "https://ui.neon.com/r/message-scroller.json",
    "https://ui.neon.com/r/bubble.json",
    "https://ui.neon.com/r/marker.json",
    "https://ui.neon.com/r/button.json",
    "https://ui.neon.com/r/tool-call-chip.json",
    "https://ui.neon.com/r/neon-tokens.json",
    "https://ui.neon.com/r/empty-state.json",
    "https://ui.neon.com/r/neon-loader.json"
  ],
  "files": [
    {
      "path": "src/components/agent-chat/agent-chat.tsx",
      "content": "\"use client\";\n\nimport {\n  ArrowRight01Icon,\n  Loading03Icon,\n  SentIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type { ChatStatus, UIMessage } from \"ai\";\nimport { domAnimation, LazyMotion, m, useReducedMotion } from \"motion/react\";\nimport type {\n  ComponentProps,\n  FormEvent,\n  KeyboardEvent,\n  ReactNode,\n} from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\nimport { Streamdown } from \"streamdown\";\n\nimport { EmptyState } from \"@/components/empty-state/empty-state\";\nimport { NeonMarkShimmer } from \"@/components/neon-loader/neon-loader\";\nimport { ToolCallChip } from \"@/components/tool-call-chip/tool-call-chip\";\nimport type { ToolCallState } from \"@/components/tool-call-chip/tool-call-chip\";\nimport { Button } from \"@/components/ui/button\";\nimport { Marker, MarkerContent } from \"@/components/ui/marker\";\nimport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n} from \"@/components/ui/message-scroller\";\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * BLOCK STORYBOARD\n *\n * A full agent chat pane.\n *\n *  entrance   each new turn rises 8px on a quick spring\n *  rhythm     a reply sits tight under its prompt; turns\n *             breathe with more air between exchanges\n *  streaming  the scroller follows while pinned, backs off\n *             when the reader scrolls up, and offers a\n *             jump-to-latest button\n *  busy       the Neon mark and status line shimmer in sync\n *             \"Agent is working\" until tokens arrive\n *  switches   a user turn whose metadata carries a new\n *             model/effort gets a quiet \"switched to\"\n *             marker above it\n *  composer   flush foot of the pane: textarea on top,\n *             model controls + send below, the top border\n *             warms to primary on focus\n * ───────────────────────────────────────────────────────── */\nconst TURN_SPRING = {\n  damping: 32,\n  stiffness: 420,\n  type: \"spring\" as const,\n};\n\nconst TURN_RISE_PX = 8;\n\n/** Rises new turns in; static under reduced motion. */\nconst TurnEntrance = ({ children }: { children: ReactNode }) => {\n  const reduced = useReducedMotion();\n\n  if (reduced) {\n    return <div>{children}</div>;\n  }\n\n  return (\n    <m.div\n      animate={{ opacity: 1, y: 0 }}\n      initial={{ opacity: 0, y: TURN_RISE_PX }}\n      transition={TURN_SPRING}\n    >\n      {children}\n    </m.div>\n  );\n};\n\n/** Attach to user turns via sendMessage metadata to record the controls used. */\nexport interface AgentChatTurnMetadata {\n  /** Model id, e.g. \"gpt-5-2\". */\n  model?: string;\n  /** Display name for the marker, e.g. \"GPT-5.2\". Falls back to model. */\n  modelName?: string;\n  /** Reasoning effort used for the turn. */\n  effort?: string;\n}\n\nconst turnMetadata = (message: UIMessage): AgentChatTurnMetadata | null => {\n  const { metadata } = message;\n\n  if (metadata && typeof metadata === \"object\" && \"model\" in metadata) {\n    return metadata as AgentChatTurnMetadata;\n  }\n\n  return null;\n};\n\n/** \"switched to GPT-5.2 · high thinking\" when a turn changes controls. */\nconst switchLabel = (\n  current: AgentChatTurnMetadata,\n  previous: AgentChatTurnMetadata | null\n): string | null => {\n  const modelChanged =\n    previous !== null && current.model !== undefined\n      ? current.model !== previous.model\n      : false;\n  const effortChanged =\n    previous !== null && current.effort !== undefined\n      ? current.effort !== previous.effort\n      : false;\n\n  if (!(modelChanged || effortChanged)) {\n    return null;\n  }\n\n  const name = current.modelName ?? current.model ?? \"\";\n  const effort =\n    current.effort && current.effort !== \"off\"\n      ? ` · ${current.effort} thinking`\n      : \"\";\n\n  return `switched to ${name}${effort}`;\n};\n\nconst chipState = (state: string): ToolCallState => {\n  if (state === \"output-error\") {\n    return \"error\";\n  }\n\n  return state === \"output-available\" ? \"done\" : \"running\";\n};\n\ntype Part = UIMessage[\"parts\"][number];\n\n/**\n * A finished turn has no running tools. History-hydrated parts don't carry\n * the live stream's state strings, so anything still \"running\" in a settled\n * message is actually done.\n */\nconst settleTools = (tools: ToolEntry[]): ToolEntry[] =>\n  tools.map((tool) =>\n    tool.state === \"running\" ? { ...tool, state: \"done\" } : tool\n  );\n\nexport interface ToolEntry {\n  detail?: string;\n  name: string;\n  state: ToolCallState;\n}\n\ntype Segment =\n  | { kind: \"text\"; text: string }\n  | { kind: \"reasoning\"; text: string }\n  | { kind: \"tools\"; tools: ToolEntry[] };\n\n/** First string value of a tool's input, e.g. the path or command. */\nconst toolDetail = (part: Part): string | undefined => {\n  if (!(\"input\" in part) || typeof part.input !== \"object\" || !part.input) {\n    return undefined;\n  }\n\n  return Object.values(part.input).find(\n    (value): value is string => typeof value === \"string\"\n  );\n};\n\n/** Fold message parts into render segments, grouping tool runs. */\nconst segmentParts = (parts: Part[]): Segment[] => {\n  const segments: Segment[] = [];\n\n  for (const part of parts) {\n    if (part.type === \"text\") {\n      segments.push({ kind: \"text\", text: part.text });\n      continue;\n    }\n\n    if (part.type === \"reasoning\") {\n      segments.push({ kind: \"reasoning\", text: part.text });\n      continue;\n    }\n\n    let tool: ToolEntry | null = null;\n\n    if (part.type === \"dynamic-tool\") {\n      tool = {\n        detail: toolDetail(part),\n        name: part.toolName,\n        state: chipState(part.state),\n      };\n    } else if (part.type.startsWith(\"tool-\") && \"state\" in part) {\n      tool = {\n        detail: toolDetail(part),\n        name: part.type.slice(\"tool-\".length),\n        state: chipState(String(part.state)),\n      };\n    }\n\n    if (tool) {\n      const last = segments.at(-1);\n\n      if (last?.kind === \"tools\") {\n        last.tools.push(tool);\n      } else {\n        segments.push({ kind: \"tools\", tools: [tool] });\n      }\n    }\n  }\n\n  return segments;\n};\n\n/* ─────────────────────────────────────────────────────\n * A run of tool calls folded into one working block: while any\n * call is live the header shimmers (\"Working…\") and the log is\n * open; when the run lands it collapses to a one-line receipt\n * (\"n steps\") the reader can reopen. The chips inside stay the\n * house log-line vocabulary.\n * ─────────────────────────────────────────────────── */\nexport const ToolGroup = ({\n  tools,\n  live = false,\n}: {\n  tools: ToolEntry[];\n  /** The turn is still streaming — hold open across gaps between calls. */\n  live?: boolean;\n}) => {\n  const running = tools.some((tool) => tool.state === \"running\");\n  // \"Working\" for the entire live turn: between tool calls every chip is\n  // momentarily settled, and folding in those gaps reads as a glitch.\n  const working = live || running;\n  const failed = tools.filter((tool) => tool.state === \"error\").length;\n  // User toggle wins; otherwise open while working, closed at rest.\n  const [userOpen, setUserOpen] = useState<boolean | null>(null);\n  const open = userOpen ?? working;\n\n  const summary = working\n    ? \"Working…\"\n    : `${tools.length} step${tools.length === 1 ? \"\" : \"s\"}${failed > 0 ? ` · ${failed} failed` : \"\"}`;\n\n  return (\n    <div\n      className=\"border-border/60 border-l-2 pl-2.5\"\n      data-slot=\"tool-group\"\n      data-state={working ? \"working\" : \"done\"}\n    >\n      <button\n        aria-expanded={open}\n        className=\"group inline-flex items-center gap-1 py-0.5 text-muted-foreground text-xs transition-colors hover:text-foreground\"\n        onClick={() => setUserOpen(!open)}\n        type=\"button\"\n      >\n        <HugeiconsIcon\n          aria-hidden\n          icon={ArrowRight01Icon}\n          strokeWidth={2}\n          className={cn(\n            \"size-3 transition-transform duration-200 motion-reduce:transition-none\",\n            open && \"rotate-90\"\n          )}\n        />\n        {working ? (\n          <span className=\"shimmer shimmer-duration-2400\">{summary}</span>\n        ) : (\n          <span>{summary}</span>\n        )}\n      </button>\n      {open && (\n        <div className=\"mt-1 flex flex-col items-start gap-1 pb-0.5\">\n          {tools.map((tool, toolIndex) => (\n            <ToolCallChip\n              detail={tool.detail}\n              key={`${tool.name}-${toolIndex.toString()}`}\n              name={tool.name}\n              state={tool.state}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n};\n\n/** Explicit markdown styling — no typography plugin required. */\nconst MARKDOWN_CLASS = cn(\n  \"text-sm leading-relaxed\",\n  \"[&_p]:my-2 first:[&_p]:mt-0 last:[&_p]:mb-0\",\n  \"[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:font-semibold [&_h1]:text-base\",\n  \"[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:font-semibold [&_h2]:text-sm\",\n  \"[&_h3]:mt-3 [&_h3]:mb-1.5 [&_h3]:font-medium [&_h3]:text-sm\",\n  \"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5\",\n  \"[&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[0.85em]\",\n  \"[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:border [&_pre]:border-border/60 [&_pre]:bg-muted/30 [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0\",\n  \"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2\",\n  \"[&_blockquote]:my-2 [&_blockquote]:border-border/60 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground\",\n  \"[&_strong]:font-semibold\"\n);\n\nexport type ChatMessageProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  message: UIMessage;\n  /**\n   * The turn is still streaming: tool groups hold open for the whole run\n   * instead of folding in the gaps between calls.\n   */\n  isLive?: boolean;\n};\n\n/** One conversation turn: user bubble or agent markdown with tool chips. */\nexport const ChatMessage = ({\n  className,\n  message,\n  isLive = false,\n  ...props\n}: ChatMessageProps) => {\n  if (message.role === \"user\") {\n    const text = message.parts\n      .map((part) => (part.type === \"text\" ? part.text : \"\"))\n      .join(\"\");\n\n    return (\n      <div\n        className={cn(\"flex justify-end\", className)}\n        data-role=\"user\"\n        {...props}\n      >\n        <div className=\"max-w-[85%] whitespace-pre-wrap bg-primary px-3 py-2 text-primary-foreground text-sm\">\n          {text}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\"space-y-2.5\", className)}\n      data-role=\"assistant\"\n      {...props}\n    >\n      {segmentParts(message.parts).map((segment, index) => {\n        const key = `${message.id}-${index.toString()}`;\n\n        if (segment.kind === \"reasoning\") {\n          return (\n            <p\n              className=\"border-border/60 border-l-2 pl-2.5 text-muted-foreground/80 text-xs leading-relaxed\"\n              key={key}\n            >\n              {segment.text}\n            </p>\n          );\n        }\n\n        if (segment.kind === \"tools\") {\n          const tools = isLive ? segment.tools : settleTools(segment.tools);\n          // One call is a log line, not a folder.\n          if (tools.length === 1 && tools[0]) {\n            return (\n              <div className=\"border-border/60 border-l-2 pl-2.5\" key={key}>\n                <ToolCallChip\n                  detail={tools[0].detail}\n                  name={tools[0].name}\n                  state={tools[0].state}\n                />\n              </div>\n            );\n          }\n          return <ToolGroup key={key} live={isLive} tools={tools} />;\n        }\n\n        return (\n          <div className={MARKDOWN_CLASS} key={key}>\n            <Streamdown>{segment.text}</Streamdown>\n          </div>\n        );\n      })}\n    </div>\n  );\n};\n\nconst autoGrow = (event: FormEvent<HTMLTextAreaElement>) => {\n  const textarea = event.currentTarget;\n  textarea.style.height = \"auto\";\n  textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px`;\n};\n\nexport interface ChatInputProps {\n  onSend: (text: string) => void;\n  disabled?: boolean;\n  busy?: boolean;\n  placeholder?: string;\n  /** Slot at the left of the composer's control row, e.g. ThinkingModelSelect. */\n  controls?: ReactNode;\n  className?: string;\n}\n\n/** Composer: auto-growing textarea, controls slot, and a send action. */\nexport const ChatInput = ({\n  busy = false,\n  className,\n  controls,\n  disabled = false,\n  onSend,\n  placeholder = \"Describe a change…\",\n}: ChatInputProps) => {\n  const [text, setText] = useState(\"\");\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n  const submit = useCallback(() => {\n    const trimmed = text.trim();\n\n    if (!trimmed || busy || disabled) {\n      return;\n    }\n\n    onSend(trimmed);\n    setText(\"\");\n\n    const textarea = textareaRef.current;\n\n    if (textarea) {\n      textarea.style.height = \"auto\";\n      textarea.focus();\n    }\n  }, [busy, disabled, onSend, text]);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (event.key === \"Enter\" && !event.shiftKey) {\n      event.preventDefault();\n      submit();\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"border-border/60 border-t bg-card transition-colors focus-within:border-primary/50\",\n        disabled && \"opacity-60\",\n        className\n      )}\n      data-slot=\"chat-input\"\n    >\n      <textarea\n        aria-label=\"Message the agent\"\n        className=\"block max-h-40 w-full resize-none bg-transparent px-3.5 pt-3 pb-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm\"\n        disabled={disabled}\n        onChange={(event) => setText(event.target.value)}\n        onInput={autoGrow}\n        onKeyDown={handleKeyDown}\n        placeholder={placeholder}\n        ref={textareaRef}\n        rows={1}\n        value={text}\n      />\n      <div className=\"flex items-end justify-between gap-2 px-2.5 pb-2.5\">\n        <div className=\"flex min-w-0 items-center gap-2\">{controls}</div>\n        <Button\n          aria-label={busy ? \"Waiting for the agent\" : \"Send message\"}\n          className=\"shrink-0\"\n          disabled={disabled || busy || text.trim().length === 0}\n          onClick={submit}\n          size=\"icon-sm\"\n        >\n          {busy ? (\n            <HugeiconsIcon\n              className=\"animate-spin\"\n              icon={Loading03Icon}\n              strokeWidth={2}\n            />\n          ) : (\n            <HugeiconsIcon icon={SentIcon} strokeWidth={2} />\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n};\n\nconst DefaultEmptyState = () => (\n  <EmptyState\n    className=\"h-full py-10\"\n    description=\"Describe a feature and the agent edits the live app, database included.\"\n    title=\"Start building\"\n  />\n);\n\nexport type ChatWorkingIndicatorProps = ComponentProps<\"div\"> & {\n  /** Status line beside the resolving Neon mark. */\n  label?: string;\n};\n\n/** The busy row: the Neon mark and a status line sharing one shimmer sweep. */\nexport const ChatWorkingIndicator = ({\n  className,\n  label = \"Agent is working\\u2026\",\n  ...props\n}: ChatWorkingIndicatorProps) => (\n  <div\n    aria-live=\"polite\"\n    className={cn(\n      \"flex items-center gap-2 text-muted-foreground text-xs\",\n      className\n    )}\n    data-slot=\"chat-working-indicator\"\n    {...props}\n  >\n    <NeonMarkShimmer className=\"text-primary\" size={14} />\n    <span className=\"shimmer\">{label}</span>\n  </div>\n);\n\nexport type AgentChatProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  messages: UIMessage[];\n  /** useChat status: \"submitted\" | \"streaming\" | \"ready\" | \"error\". */\n  status: ChatStatus;\n  onSend: (text: string) => void;\n  /** Disable the composer, e.g. while an environment provisions. */\n  disabled?: boolean;\n  placeholder?: string;\n  /** Rendered while the conversation is empty. */\n  emptyState?: ReactNode;\n  /** Slot at the left of the composer's control row. */\n  controls?: ReactNode;\n  /**\n   * The controls currently selected in the composer. When they differ from\n   * the last sent turn's metadata, a \"switched to\" marker previews at the\n   * end of the timeline immediately.\n   */\n  activeControls?: AgentChatTurnMetadata;\n  /** Shown in the error state; called to resend the last message. */\n  onRetry?: () => void;\n};\n\nexport const AgentChat = ({\n  activeControls,\n  className,\n  controls,\n  disabled = false,\n  emptyState,\n  messages,\n  onRetry,\n  onSend,\n  placeholder,\n  status,\n  ...props\n}: AgentChatProps) => {\n  const busy = status === \"submitted\" || status === \"streaming\";\n  const lastUserIndex = messages.findLastIndex(\n    (message) => message.role === \"user\"\n  );\n  const lastUserMetadata =\n    lastUserIndex !== -1 && messages[lastUserIndex]\n      ? turnMetadata(messages[lastUserIndex])\n      : null;\n  const pendingSwitch =\n    activeControls && lastUserMetadata\n      ? switchLabel(activeControls, lastUserMetadata)\n      : null;\n\n  return (\n    <LazyMotion features={domAnimation} strict>\n      <div\n        className={cn(\"flex min-h-0 flex-col\", className)}\n        data-slot=\"agent-chat\"\n        {...props}\n      >\n        <MessageScrollerProvider autoScroll defaultScrollPosition=\"end\">\n          <MessageScroller className=\"relative min-h-0 flex-1\">\n            <MessageScrollerViewport className=\"neon-scroll-fade h-full\">\n              <MessageScrollerContent className=\"p-4\">\n                {messages.length === 0 && (emptyState ?? <DefaultEmptyState />)}\n                {messages.map((message, index) => {\n                  let marker: string | null = null;\n\n                  if (message.role === \"user\") {\n                    const metadata = turnMetadata(message);\n                    const previous = messages\n                      .slice(0, index)\n                      .findLast((entry) => entry.role === \"user\");\n\n                    if (metadata) {\n                      marker = switchLabel(\n                        metadata,\n                        previous ? turnMetadata(previous) : null\n                      );\n                    }\n                  }\n\n                  return (\n                    <MessageScrollerItem\n                      className={cn(\n                        message.role === \"user\"\n                          ? \"mt-6 first:mt-0\"\n                          : \"mt-3 first:mt-0\"\n                      )}\n                      key={message.id}\n                      scrollAnchor={index === lastUserIndex}\n                    >\n                      <TurnEntrance>\n                        {marker ? (\n                          <Marker className=\"mb-3\" variant=\"separator\">\n                            <MarkerContent className=\"font-mono text-[10px]\">\n                              {marker}\n                            </MarkerContent>\n                          </Marker>\n                        ) : null}\n                        <ChatMessage\n                          isLive={busy && index === messages.length - 1}\n                          message={message}\n                        />\n                      </TurnEntrance>\n                    </MessageScrollerItem>\n                  );\n                })}\n                {status === \"submitted\" && (\n                  <ChatWorkingIndicator className=\"mt-3\" />\n                )}\n                {pendingSwitch && !busy ? (\n                  <TurnEntrance>\n                    <Marker\n                      aria-live=\"polite\"\n                      className=\"mt-3\"\n                      variant=\"separator\"\n                    >\n                      <MarkerContent className=\"font-mono text-[10px]\">\n                        {pendingSwitch}\n                      </MarkerContent>\n                    </Marker>\n                  </TurnEntrance>\n                ) : null}\n                {status === \"error\" && (\n                  <div\n                    className=\"mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/[0.045] px-3 py-2\"\n                    role=\"alert\"\n                  >\n                    <p className=\"text-destructive text-xs\">\n                      The agent hit an error. Your message wasn’t lost.\n                    </p>\n                    {onRetry ? (\n                      <button\n                        className=\"shrink-0 rounded-sm border border-border/60 px-2 py-1 text-foreground text-xs transition-colors hover:border-border\"\n                        onClick={onRetry}\n                        type=\"button\"\n                      >\n                        Retry\n                      </button>\n                    ) : null}\n                  </div>\n                )}\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton className=\"-translate-x-1/2 absolute bottom-3 left-1/2\" />\n          </MessageScroller>\n        </MessageScrollerProvider>\n\n        <ChatInput\n          busy={busy}\n          className=\"shrink-0\"\n          controls={controls}\n          disabled={disabled}\n          onSend={onSend}\n          placeholder={placeholder}\n        />\n      </div>\n    </LazyMotion>\n  );\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}