{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-queue",
  "title": "Message Queue",
  "description": "Keeps follow-ups typed while the agent is busy. They stack above the composer, can be edited, reordered or removed, and go out one at a time when the agent is free.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/message-queue/message-queue.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { GripVertical, Pencil, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type QueuedMessage = {\n  id: string\n  text: string\n}\n\nexport type MessageQueueProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  items: readonly QueuedMessage[]\n  onItemsChange: (items: QueuedMessage[]) => void\n  /** While true the queue waits. When it turns false the first item goes. */\n  busy: boolean\n  /** Receives the item being sent. It has already left `items`. */\n  onRelease: (item: QueuedMessage) => void\n  label?: string\n}\n\nconst RELEASE_MS = 180\nconst SETTLE_MS = 160\n\nfunction reducedMotion() {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  )\n}\n\nfunction moved<T>(items: readonly T[], from: number, to: number) {\n  const next = [...items]\n  const [item] = next.splice(from, 1)\n  if (item !== undefined) next.splice(to, 0, item)\n  return next\n}\n\nfunction preview(text: string) {\n  const line = text.replace(/\\s+/g, \" \").trim()\n  return line.length > 40 ? `${line.slice(0, 40)}…` : line\n}\n\nexport function MessageQueue({\n  items,\n  onItemsChange,\n  busy,\n  onRelease,\n  label = \"Queued messages\",\n  className,\n  ...rootProps\n}: MessageQueueProps) {\n  const hintId = React.useId()\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const rows = React.useRef(new Map<string, HTMLLIElement>())\n  const before = React.useRef(new Map<string, number>())\n  const focusAfter = React.useRef<{ id: string; slot: string } | null>(null)\n\n  const [editing, setEditing] = React.useState<{\n    id: string\n    draft: string\n  } | null>(null)\n  const [dragging, setDragging] = React.useState<string | null>(null)\n  const [spent, setSpent] = React.useState(false)\n  const [message, setMessage] = React.useState(\"\")\n\n  // One item per idle spell. The host normally turns busy back on as soon as\n  // it sends, and this keeps a host that does not from emptying the queue.\n  if (busy && spent) setSpent(false)\n\n  const head = items[0]\n  const releasingId =\n    !busy && !spent && !dragging && head && editing?.id !== head.id\n      ? head.id\n      : null\n\n  const latest = React.useRef({ items, onItemsChange, onRelease })\n  React.useEffect(() => {\n    latest.current = { items, onItemsChange, onRelease }\n  })\n\n  React.useEffect(() => {\n    if (!releasingId) return\n\n    const timer = setTimeout(\n      () => {\n        const current = latest.current\n        const item = current.items[0]\n        if (item?.id !== releasingId) return\n        setSpent(true)\n        setMessage(`Sending: ${preview(item.text)}`)\n        current.onItemsChange(current.items.slice(1))\n        current.onRelease(item)\n      },\n      reducedMotion() ? 0 : RELEASE_MS\n    )\n\n    return () => clearTimeout(timer)\n  }, [releasingId])\n\n  const measure = () => {\n    before.current = new Map(\n      [...rows.current].map(([id, row]) => [\n        id,\n        row.getBoundingClientRect().top,\n      ])\n    )\n  }\n\n  React.useLayoutEffect(() => {\n    const previous = before.current\n    before.current = new Map()\n\n    if (previous.size > 0 && !reducedMotion()) {\n      for (const [id, row] of rows.current) {\n        const top = previous.get(id)\n        if (top === undefined) continue\n        const shift = top - row.getBoundingClientRect().top\n        if (!shift) continue\n\n        row.style.transition = \"none\"\n        row.style.transform = `translateY(${shift}px)`\n        void row.offsetHeight\n        row.style.transition = `transform ${SETTLE_MS}ms ease-out`\n        row.style.transform = \"\"\n        setTimeout(() => {\n          row.style.transition = \"\"\n        }, SETTLE_MS)\n      }\n    }\n\n    const target = focusAfter.current\n    if (target) {\n      focusAfter.current = null\n      const row = rows.current.get(target.id)\n      const control = row?.querySelector<HTMLElement>(\n        `[data-slot='${target.slot}']`\n      )\n      ;(control ?? rootRef.current)?.focus()\n    }\n  }, [items, editing])\n\n  const moveTo = (from: number, to: number) => {\n    const item = items[from]\n    if (!item || to < 0 || to >= items.length || to === from) return\n\n    measure()\n    focusAfter.current = { id: item.id, slot: \"message-queue-handle\" }\n    onItemsChange(moved(items, from, to))\n    setMessage(\n      `${preview(item.text)}, moved to position ${to + 1} of ${items.length}`\n    )\n  }\n\n  const remove = (index: number) => {\n    const item = items[index]\n    if (!item) return\n\n    const neighbour = items[index + 1] ?? items[index - 1]\n    measure()\n    focusAfter.current = neighbour\n      ? { id: neighbour.id, slot: \"message-queue-edit\" }\n      : null\n    if (!neighbour) rootRef.current?.focus()\n    onItemsChange(items.filter((candidate) => candidate.id !== item.id))\n    setMessage(`Removed: ${preview(item.text)}`)\n  }\n\n  const commit = () => {\n    if (!editing) return\n\n    const index = items.findIndex((item) => item.id === editing.id)\n    const text = editing.draft.trim()\n    setEditing(null)\n\n    if (index === -1) return\n    if (!text) {\n      remove(index)\n      return\n    }\n\n    focusAfter.current = { id: editing.id, slot: \"message-queue-edit\" }\n    onItemsChange(\n      items.map((item) => (item.id === editing.id ? { ...item, text } : item))\n    )\n  }\n\n  const cancelEdit = () => {\n    if (!editing) return\n    focusAfter.current = { id: editing.id, slot: \"message-queue-edit\" }\n    setEditing(null)\n  }\n\n  const iconButton =\n    \"text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring inline-flex size-8 shrink-0 items-center justify-center rounded-md focus-visible:ring-2 focus-visible:outline-none\"\n\n  return (\n    <div\n      className={cn(\"grid gap-1.5 outline-none\", className)}\n      data-slot=\"message-queue\"\n      data-state={items.length === 0 ? \"empty\" : busy ? \"waiting\" : \"sending\"}\n      ref={rootRef}\n      tabIndex={-1}\n      {...rootProps}\n    >\n      {items.length > 0 ? (\n        <>\n          <p className=\"text-muted-foreground flex items-baseline justify-between gap-3 px-1 text-xs\">\n            <span>\n              <span className=\"text-foreground font-medium\">{label}</span>{\" \"}\n              <span className=\"tabular-nums\">{items.length}</span>\n            </span>\n            <span>\n              {busy ? \"Sends when the agent finishes\" : \"Sending the next one\"}\n            </span>\n          </p>\n\n          <ol aria-label={label} className=\"grid gap-1.5\">\n            {items.map((item, index) => {\n              const isEditing = editing?.id === item.id\n              const isReleasing = releasingId === item.id\n\n              return (\n                <li\n                  className={cn(\n                    \"border-border bg-card text-card-foreground flex items-start gap-1 rounded-[var(--radius)] border p-1 pl-0.5 text-sm\",\n                    \"motion-safe:transition-[opacity,translate,box-shadow] motion-safe:duration-200 motion-safe:ease-out motion-safe:starting:translate-y-1 motion-safe:starting:opacity-0\",\n                    isReleasing &&\n                      \"opacity-0 motion-safe:translate-y-2 motion-safe:duration-150 motion-safe:ease-in\",\n                    dragging === item.id && \"ring-ring shadow-md ring-2\"\n                  )}\n                  data-releasing={isReleasing ? \"\" : undefined}\n                  data-slot=\"message-queue-item\"\n                  key={item.id}\n                  ref={(node) => {\n                    if (node) rows.current.set(item.id, node)\n                    else rows.current.delete(item.id)\n                  }}\n                >\n                  <button\n                    aria-describedby={hintId}\n                    aria-label={`Move ${preview(item.text)}`}\n                    className={cn(iconButton, \"cursor-grab touch-none\")}\n                    data-slot=\"message-queue-handle\"\n                    type=\"button\"\n                    onKeyDown={(event) => {\n                      if (event.key === \"ArrowUp\") {\n                        event.preventDefault()\n                        moveTo(index, index - 1)\n                      }\n                      if (event.key === \"ArrowDown\") {\n                        event.preventDefault()\n                        moveTo(index, index + 1)\n                      }\n                    }}\n                    onPointerCancel={() => setDragging(null)}\n                    onPointerDown={(event) => {\n                      if (event.button !== 0) return\n                      event.preventDefault()\n                      event.currentTarget.setPointerCapture(event.pointerId)\n                      setDragging(item.id)\n                    }}\n                    onPointerMove={(event) => {\n                      if (dragging !== item.id) return\n                      const target = items.findIndex((candidate) => {\n                        const row = rows.current.get(candidate.id)\n                        if (!row) return false\n                        const rect = row.getBoundingClientRect()\n                        return event.clientY < rect.top + rect.height / 2\n                      })\n                      const next = target === -1 ? items.length - 1 : target\n                      if (next !== index) moveTo(index, next)\n                    }}\n                    onPointerUp={() => setDragging(null)}\n                  >\n                    <GripVertical aria-hidden=\"true\" size={15} />\n                  </button>\n\n                  {isEditing ? (\n                    <textarea\n                      aria-label=\"Edit queued message\"\n                      autoFocus\n                      className=\"bg-background focus-visible:ring-ring min-h-8 min-w-0 flex-1 resize-none rounded-md px-2 py-1.5 leading-snug focus-visible:ring-2 focus-visible:outline-none\"\n                      rows={Math.min(\n                        Math.max(editing.draft.split(\"\\n\").length, 1),\n                        6\n                      )}\n                      value={editing.draft}\n                      onBlur={commit}\n                      onChange={(event) =>\n                        setEditing({ id: item.id, draft: event.target.value })\n                      }\n                      onFocus={(event) => {\n                        const end = event.currentTarget.value.length\n                        event.currentTarget.setSelectionRange(end, end)\n                      }}\n                      onKeyDown={(event) => {\n                        if (event.key === \"Escape\") {\n                          event.preventDefault()\n                          cancelEdit()\n                        }\n                        if (\n                          event.key === \"Enter\" &&\n                          !event.shiftKey &&\n                          !event.nativeEvent.isComposing\n                        ) {\n                          event.preventDefault()\n                          commit()\n                        }\n                      }}\n                    />\n                  ) : (\n                    <button\n                      aria-label={`Edit ${preview(item.text)}`}\n                      className=\"hover:bg-muted/60 focus-visible:ring-ring min-h-8 min-w-0 flex-1 cursor-text rounded-md px-2 py-1.5 text-left leading-snug break-words whitespace-pre-wrap focus-visible:ring-2 focus-visible:outline-none\"\n                      data-slot=\"message-queue-edit\"\n                      type=\"button\"\n                      onClick={() =>\n                        setEditing({ id: item.id, draft: item.text })\n                      }\n                    >\n                      {item.text}\n                    </button>\n                  )}\n\n                  {isEditing ? null : (\n                    <Pencil\n                      aria-hidden=\"true\"\n                      className=\"text-muted-foreground mt-2.5 shrink-0\"\n                      size={13}\n                    />\n                  )}\n\n                  <button\n                    aria-label={`Remove ${preview(item.text)}`}\n                    className={iconButton}\n                    data-slot=\"message-queue-remove\"\n                    type=\"button\"\n                    onMouseDown={(event) => {\n                      if (isEditing) event.preventDefault()\n                    }}\n                    onClick={() => {\n                      if (isEditing) setEditing(null)\n                      remove(index)\n                    }}\n                  >\n                    <X aria-hidden=\"true\" size={15} />\n                  </button>\n                </li>\n              )\n            })}\n          </ol>\n\n          <p className=\"sr-only\" id={hintId}>\n            Use the up and down arrow keys to change the order.\n          </p>\n        </>\n      ) : null}\n\n      <span aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        {message}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "chat",
    "agent",
    "reorder"
  ],
  "type": "registry:ui"
}