{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-composer",
  "title": "Prompt Composer",
  "description": "A prompt input with attachments, a model picker, and a stop state.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "components/spectrumui/blocks/ai-assistants/prompt-composer.tsx",
      "content": "'use client';\n\nimport { useRef, useState } from 'react';\nimport { ArrowUp, Check, ChevronDown, Paperclip, Square, X } from 'lucide-react';\nimport { cn } from '@/lib/utils';\nimport type { Attachment, ModelOption } from './types';\n\nexport type PromptComposerVariant = 'Default' | 'Minimal';\n\nexport interface PromptComposerProps {\n  placeholder?: string;\n  models?: ModelOption[];\n  defaultModelId?: string;\n  attachments?: Attachment[];\n  onRemoveAttachment?: (id: string) => void;\n  onAttach?: () => void;\n  onSend?: (text: string, modelId?: string) => void;\n  onStop?: () => void;\n  isGenerating?: boolean;\n  maxLength?: number;\n  variant?: PromptComposerVariant;\n  className?: string;\n}\n\nfunction formatSize(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;\n  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;\n}\n\nexport function PromptComposer({\n  placeholder = 'Ask anything…',\n  models = [],\n  defaultModelId,\n  attachments = [],\n  onRemoveAttachment,\n  onAttach,\n  onSend,\n  onStop,\n  isGenerating = false,\n  maxLength = 2000,\n  variant = 'Default',\n  className,\n}: PromptComposerProps) {\n  const [value, setValue] = useState('');\n  const [modelId, setModelId] = useState(defaultModelId ?? models[0]?.id);\n  const [pickerOpen, setPickerOpen] = useState(false);\n  const textarea = useRef<HTMLTextAreaElement>(null);\n\n  const activeModel = models.find((model) => model.id === modelId);\n  const canSend = value.trim().length > 0 && !isGenerating;\n  const minimal = variant === 'Minimal';\n\n  function submit() {\n    if (!canSend) return;\n    onSend?.(value.trim(), modelId);\n    setValue('');\n    if (textarea.current) textarea.current.style.height = 'auto';\n  }\n\n  return (\n    <form\n      onSubmit={(event) => {\n        event.preventDefault();\n        submit();\n      }}\n      className={cn(\n        'w-full max-w-[560px] rounded-2xl border border-black/[0.08] bg-white shadow-xs transition-[border-color,box-shadow] duration-150 focus-within:border-black/[0.18] focus-within:shadow-[0_0_0_3px_rgba(0,0,0,0.03)] dark:border-white/[0.09] dark:bg-[#0B0B0D] dark:focus-within:border-white/[0.24] dark:focus-within:shadow-[0_0_0_3px_rgba(255,255,255,0.04)]',\n        className,\n      )}\n    >\n      {attachments.length > 0 && (\n        <ul className=\"flex flex-wrap gap-1.5 px-3 pt-3\">\n          {attachments.map((attachment) => (\n            <li\n              key={attachment.id}\n              className=\"flex items-center gap-1.5 rounded-lg border border-black/[0.07] bg-black/[0.02] py-1 pl-2 pr-1 dark:border-white/[0.08] dark:bg-white/[0.04]\"\n            >\n              <Paperclip className=\"size-3 text-neutral-400 dark:text-neutral-500\" />\n              <span className=\"max-w-[140px] truncate text-[11.5px] font-medium text-neutral-700 dark:text-neutral-300\">\n                {attachment.name}\n              </span>\n              <span className=\"font-mono text-[10px] tabular-nums text-neutral-400 dark:text-neutral-600\">\n                {formatSize(attachment.size)}\n              </span>\n              <button\n                type=\"button\"\n                aria-label={`Remove ${attachment.name}`}\n                onClick={() => onRemoveAttachment?.(attachment.id)}\n                className=\"grid size-4 place-items-center rounded text-neutral-400 transition-[color,transform] duration-150 hover:text-neutral-700 active:scale-[0.9] dark:hover:text-neutral-200\"\n              >\n                <X className=\"size-3\" />\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      <div className=\"px-3.5 pt-3\">\n        <textarea\n          ref={textarea}\n          value={value}\n          rows={minimal ? 1 : 2}\n          maxLength={maxLength}\n          placeholder={placeholder}\n          aria-label=\"Prompt\"\n          onChange={(event) => {\n            setValue(event.target.value);\n            event.target.style.height = 'auto';\n            event.target.style.height = `${Math.min(event.target.scrollHeight, 132)}px`;\n          }}\n          onKeyDown={(event) => {\n            if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {\n              event.preventDefault();\n              submit();\n            }\n          }}\n          className=\"max-h-[132px] w-full resize-none bg-transparent text-[13.5px] leading-[1.6] text-neutral-900 outline-hidden placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-500\"\n        />\n      </div>\n\n      <div className=\"flex items-center justify-between gap-2 px-2.5 pb-2.5 pt-1.5\">\n        <div className=\"flex min-w-0 items-center gap-1\">\n          {!minimal && (\n            <button\n              type=\"button\"\n              aria-label=\"Attach a file\"\n              onClick={onAttach}\n              className=\"grid size-7 shrink-0 place-items-center rounded-lg text-neutral-400 transition-[color,background-color,transform] duration-150 hover:bg-black/[0.04] hover:text-neutral-700 active:scale-[0.94] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-500 dark:hover:bg-white/[0.06] dark:hover:text-neutral-200\"\n            >\n              <Paperclip className=\"size-3.5\" />\n            </button>\n          )}\n\n          {!minimal && models.length > 0 && (\n            <div className=\"relative min-w-0\">\n              <button\n                type=\"button\"\n                aria-haspopup=\"listbox\"\n                aria-expanded={pickerOpen}\n                onClick={() => setPickerOpen((open) => !open)}\n                className=\"flex min-w-0 items-center gap-1 rounded-lg px-2 py-1 font-mono text-[11px] text-neutral-500 transition-[color,background-color,transform] duration-150 hover:bg-black/[0.04] hover:text-neutral-800 active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-neutral-200\"\n              >\n                <span className=\"truncate\">{activeModel?.name ?? 'Model'}</span>\n                <ChevronDown\n                  className={cn(\n                    'size-3 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',\n                    pickerOpen && 'rotate-180',\n                  )}\n                />\n              </button>\n\n              {pickerOpen && (\n                <ul\n                  role=\"listbox\"\n                  aria-label=\"Model\"\n                  className=\"absolute bottom-full left-0 z-10 mb-1.5 w-56 rounded-xl border border-black/[0.08] bg-white p-1 shadow-lg dark:border-white/[0.1] dark:bg-neutral-900\"\n                >\n                  {models.map((model) => {\n                    const selected = model.id === modelId;\n                    return (\n                      <li key={model.id}>\n                        <button\n                          type=\"button\"\n                          role=\"option\"\n                          aria-selected={selected}\n                          disabled={model.disabled}\n                          onClick={() => {\n                            setModelId(model.id);\n                            setPickerOpen(false);\n                          }}\n                          className={cn(\n                            'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-150',\n                            'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400',\n                            model.disabled\n                              ? 'cursor-not-allowed opacity-40'\n                              : 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]',\n                          )}\n                        >\n                          <span className=\"min-w-0 flex-1\">\n                            <span className=\"block truncate text-[12.5px] font-medium text-neutral-800 dark:text-neutral-200\">\n                              {model.name}\n                            </span>\n                            {model.description && (\n                              <span className=\"block truncate text-[11px] text-neutral-400 dark:text-neutral-500\">\n                                {model.description}\n                              </span>\n                            )}\n                          </span>\n                          {model.badge && !selected && (\n                            <span className=\"shrink-0 rounded bg-black/[0.05] px-1 py-0.5 font-mono text-[9px] uppercase tracking-wide text-neutral-500 dark:bg-white/[0.08] dark:text-neutral-400\">\n                              {model.badge}\n                            </span>\n                          )}\n                          {selected && <Check className=\"size-3.5 shrink-0 text-neutral-600 dark:text-neutral-300\" />}\n                        </button>\n                      </li>\n                    );\n                  })}\n                </ul>\n              )}\n            </div>\n          )}\n        </div>\n\n        <div className=\"flex shrink-0 items-center gap-2\">\n          {!minimal && (\n            <span\n              className={cn(\n                'font-mono text-[10.5px] tabular-nums transition-colors duration-150',\n                value.length > maxLength * 0.9\n                  ? 'text-amber-600 dark:text-amber-400'\n                  : 'text-neutral-300 dark:text-neutral-600',\n              )}\n            >\n              {value.length}/{maxLength}\n            </span>\n          )}\n\n          <button\n            type={isGenerating ? 'button' : 'submit'}\n            onClick={isGenerating ? onStop : undefined}\n            aria-label={isGenerating ? 'Stop generating' : 'Send prompt'}\n            disabled={!isGenerating && !canSend}\n            className=\"grid size-7 place-items-center rounded-full bg-neutral-900 text-white transition-[transform,opacity] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.92] disabled:opacity-30 dark:bg-neutral-100 dark:text-neutral-900\"\n          >\n            <span className=\"relative grid size-3.5 place-items-center\">\n              <ArrowUp\n                className={cn(\n                  'absolute size-3.5 transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',\n                  isGenerating ? 'opacity-0 blur-[2px]' : 'opacity-100 blur-0',\n                )}\n              />\n              <Square\n                className={cn(\n                  'absolute size-2.5 fill-current transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]',\n                  isGenerating ? 'opacity-100 blur-0' : 'opacity-0 blur-[2px]',\n                )}\n              />\n            </span>\n          </button>\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport default PromptComposer;\n",
      "type": "registry:block",
      "target": "components/spectrumui/blocks/ai-assistants/prompt-composer.tsx"
    },
    {
      "path": "components/spectrumui/blocks/ai-assistants/types.ts",
      "content": "export type MessageRole = 'user' | 'assistant' | 'system';\n\nexport type MessageState = 'streaming' | 'complete' | 'error';\n\nexport interface Attachment {\n  id: string;\n  name: string;\n  size: number;\n  type: string;\n  url?: string;\n  previewUrl?: string;\n}\n\nexport interface Citation {\n  id: string;\n  index: number;\n  url: string;\n  title: string;\n  snippet?: string;\n  favicon?: string;\n}\n\nexport type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled';\n\nexport interface ToolCall {\n  id: string;\n  name: string;\n  args?: Record<string, unknown>;\n  result?: string;\n  status: ToolCallStatus;\n  startedAt?: number;\n  completedAt?: number;\n  children?: ToolCall[];\n  parallel?: boolean;\n}\n\nexport interface ReasoningStep {\n  id: string;\n  content: string;\n}\n\nexport interface Reasoning {\n  steps: ReasoningStep[];\n  status: 'thinking' | 'complete';\n  durationMs?: number;\n}\n\nexport interface Message {\n  id: string;\n  role: MessageRole;\n  content: string;\n  createdAt?: number;\n  state?: MessageState;\n  attachments?: Attachment[];\n  citations?: Citation[];\n  toolCalls?: ToolCall[];\n  reasoning?: Reasoning;\n}\n\nexport interface Conversation {\n  id: string;\n  label: string;\n}\n\nexport interface ModelOption {\n  id: string;\n  name: string;\n  description?: string;\n  badge?: string;\n  disabled?: boolean;\n}\n\nexport interface SuggestedPrompt {\n  id: string;\n  label: string;\n  prompt?: string;\n}\n\nexport type MessageFeedback = 'positive' | 'negative';\n\nexport interface UsageState {\n  promptTokens: number;\n  completionTokens: number;\n  contextWindow: number;\n  estimatedCostUsd?: number;\n}\n",
      "type": "registry:file",
      "target": "components/spectrumui/blocks/ai-assistants/types.ts"
    }
  ],
  "type": "registry:block"
}
