{"slug":"message-queue","category":"composer","job":"ask","added":"2026-11-04T15:38Z","title":"Message Queue","description":"A composer that keeps working mid-turn: ⌘Enter queues follow-ups above the input to send now, edit, or auto-send when the agent finishes.","url":"https://ai-patterns.app/patterns/composer/message-queue","markdown":"https://ai-patterns.app/patterns/composer/message-queue.md","json":"https://ai-patterns.app/api/patterns/composer/message-queue","seenIn":[{"team":"Claude","source":"https://code.claude.com/docs/en/interactive-mode"},{"team":"Replit","source":"https://docs.replit.com/features/agent/steer-and-queue-messages"},{"team":"Perplexity","source":"https://www.perplexity.ai/help-center/en/articles/13600190-what-s-new-in-advanced-deep-research"}],"spec":"# Message Queue\n\n## Summary\nA composer that stays usable while the agent is working. Enter still sends right away, but ⌘Enter (Ctrl+Enter elsewhere) parks the message in a queue stacked above the input. Each queued message can be sent now, pulled back to edit, or removed — and when the current turn finishes, the oldest one goes out automatically. Users keep thinking ahead instead of waiting for the agent to finish.\n\n## When to use\n- Agentic products where turns run for seconds or minutes (coding agents, research, multi-step tool use) and users already know their next instruction.\n- When interrupting the running turn is costly or risky, so \"send it after this\" needs to be a distinct, safer choice than \"send it now\".\n\n## When not to use\n- Fast, single-shot chat where responses land in a second or two — the queue would never be visible long enough to matter.\n- When the next message depends on reading the current answer first; a queue only helps when the follow-up is already known.\n- Products that can't actually defer a message — never show a queue the backend won't honor in order.\n\n## Anatomy\n- Composer: input, send button (morphs to Stop while busy and empty), and a \"Queue ⌘↵\" button that appears while busy and there's a draft.\n- Placeholder hint while busy: \"↵ to send, ⌘↵ to queue\" — teaches the shortcut exactly when it applies.\n- Queue stack: one muted row per queued message, above the composer, oldest first. The first row carries the count (\"2 queued\"); each row shows a truncated preview.\n- Row actions: Send now (return arrow), Edit (pencil), Remove (×).\n\n## Behavior\n- Idle: Enter sends; ⌘Enter behaves like Enter (there's nothing to queue behind).\n- Busy: Enter sends immediately — the host decides whether that steers or interrupts the current turn. ⌘Enter appends the draft to the queue and clears the input.\n- When a turn ends, the oldest queued message is sent automatically and marked as coming from the queue; the rest wait for the next turn to finish. Stopping the turn also releases the queue.\n- Send now removes the row and sends it immediately, skipping the line.\n- Edit pulls the message back into the composer for changes. If the user had an unsent draft, the draft takes the edited message's slot so nothing typed is lost.\n- ↑ on an empty composer recalls the last queued message for editing, mirroring shell history.\n- Rows slide in from the composer when queued and lift away when sent or removed; the stack reflows with layout animation.\n\n## Content guidelines\n- Name the state plainly: \"Queued\", \"2 queued\". Avoid \"pending\", which reads like a sending failure.\n- Show the shortcut as glyphs (⌘↵, Ctrl↵) matched to the user's platform, never both.\n- Previews are one line, truncated; the full text is available on hover and via Edit.\n\n## Accessibility\n- The queue is a list labeled \"Queued messages\"; every row action is a real button with an `aria-label` and a tooltip.\n- A polite live region announces the queue count when it changes, so screen-reader users know a message was parked rather than sent.\n- The input exposes its shortcuts via `aria-keyshortcuts`, and the visible \"Queue\" button gives pointer and touch users the same action without the chord.\n- Edit and Remove return focus to the composer.\n- Respect `prefers-reduced-motion`: rows fade instead of sliding and reflowing.\n\n## Related patterns\n- **Stop Generation Button** — Use it as well as the send/stop control the composer shows while the agent is busy.\n- **Prompt Bar** — Use it instead when the agent can't be busy between messages and there is nothing to queue.\n- **Task List** — Use it as well when the agent has its own plan, so the plan shows its steps and the queue holds the user's next instructions.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUp, CornerDownLeft, Pencil, Square, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface QueuedMessage {\n  id: string;\n  text: string;\n}\n\nexport type SendMode = \"now\" | \"queued\";\n\nexport interface MessageQueueComposerProps {\n  /** True while the agent is working on a turn. ⌘/Ctrl+Enter queues only while busy. */\n  busy: boolean;\n  /**\n   * Called when a message goes out. `\"now\"` is a message sent directly (Enter, or\n   * \"Send now\" on a queued item — the host decides whether that steers or interrupts\n   * the running turn); `\"queued\"` is a queued message auto-sent when the turn ended.\n   */\n  onSend: (text: string, mode: SendMode) => void;\n  onStop?: () => void;\n  placeholder?: string;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 520, damping: 40 } as const;\n\nlet uid = 0;\nconst nextId = () => `q${++uid}`;\n\nconst noop = () => () => {};\n\nfunction useIsMac() {\n  return React.useSyncExternalStore(\n    noop,\n    () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent),\n    () => true\n  );\n}\n\nexport function MessageQueueComposer({\n  busy,\n  onSend,\n  onStop,\n  placeholder = \"Ask anything\",\n  className,\n}: MessageQueueComposerProps) {\n  const [value, setValue] = React.useState(\"\");\n  const [queue, setQueue] = React.useState<QueuedMessage[]>([]);\n  const inputRef = React.useRef<HTMLTextAreaElement>(null);\n  const reduceMotion = useReducedMotion();\n  const mac = useIsMac();\n  const mod = mac ? \"⌘\" : \"Ctrl\";\n\n  // When the running turn ends, the oldest queued message goes out on its own.\n  const wasBusy = React.useRef(busy);\n  React.useEffect(() => {\n    if (wasBusy.current && !busy && queue.length > 0) {\n      const [next, ...rest] = queue;\n      setQueue(rest);\n      onSend(next.text, \"queued\");\n    }\n    wasBusy.current = busy;\n  }, [busy, queue, onSend]);\n\n  function focusInput() {\n    requestAnimationFrame(() => {\n      const el = inputRef.current;\n      if (!el) return;\n      el.focus();\n      el.setSelectionRange(el.value.length, el.value.length);\n    });\n  }\n\n  function submit(toQueue: boolean) {\n    const text = value.trim();\n    if (!text) return;\n    setValue(\"\");\n    if (toQueue && busy) setQueue((q) => [...q, { id: nextId(), text }]);\n    else onSend(text, \"now\");\n  }\n\n  function sendNow(item: QueuedMessage) {\n    setQueue((q) => q.filter((m) => m.id !== item.id));\n    onSend(item.text, \"now\");\n  }\n\n  function edit(item: QueuedMessage) {\n    const draft = value.trim();\n    // Pull the message back into the composer; an unsent draft takes its slot so nothing is lost.\n    setQueue((q) => q.flatMap((m) => (m.id !== item.id ? [m] : draft ? [{ id: nextId(), text: draft }] : [])));\n    setValue(item.text);\n    focusInput();\n  }\n\n  function remove(item: QueuedMessage) {\n    setQueue((q) => q.filter((m) => m.id !== item.id));\n    focusInput();\n  }\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n      e.preventDefault();\n      submit(e.metaKey || e.ctrlKey);\n    } else if (e.key === \"ArrowUp\" && !value && queue.length > 0) {\n      // Up on an empty composer recalls the last queued message for editing.\n      e.preventDefault();\n      edit(queue[queue.length - 1]);\n    }\n  }\n\n  const hint = busy ? `↵ to send, ${mod}↵ to queue` : placeholder;\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <ul aria-label=\"Queued messages\" className=\"flex flex-col gap-1.5 empty:hidden\">\n        <AnimatePresence initial={false}>\n          {queue.map((item, i) => (\n            <motion.li\n              key={item.id}\n              layout={!reduceMotion}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.97 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97, transition: { duration: 0.15 } }}\n              transition={spring}\n              className=\"mx-2 flex items-center gap-2 rounded-xl bg-muted py-1.5 pr-1.5 pl-3 text-sm last:mb-1.5\"\n            >\n              <span className=\"shrink-0 font-medium text-foreground\">\n                {i === 0 ? (queue.length === 1 ? \"1 queued\" : `${queue.length} queued`) : `#${i + 1}`}\n              </span>\n              <span className=\"min-w-0 flex-1 truncate text-muted-foreground\" title={item.text}>\n                {item.text}\n              </span>\n              <QueueAction label=\"Send now\" onClick={() => sendNow(item)}>\n                <CornerDownLeft className=\"size-3.5\" />\n              </QueueAction>\n              <QueueAction label=\"Edit\" onClick={() => edit(item)}>\n                <Pencil className=\"size-3.5\" />\n              </QueueAction>\n              <QueueAction label=\"Remove from queue\" onClick={() => remove(item)}>\n                <X className=\"size-3.5\" />\n              </QueueAction>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n\n      <form\n        onSubmit={(e) => {\n          e.preventDefault();\n          submit(false);\n        }}\n        className=\"flex items-end gap-2 rounded-2xl border bg-card p-2 pl-3.5 shadow-sm focus-within:ring-2 focus-within:ring-ring/30\"\n      >\n        <textarea\n          ref={inputRef}\n          rows={1}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={onKeyDown}\n          placeholder={hint}\n          aria-label=\"Message\"\n          aria-keyshortcuts={busy ? `Enter ${mac ? \"Meta\" : \"Control\"}+Enter` : \"Enter\"}\n          className=\"max-h-32 min-h-8 flex-1 resize-none bg-transparent py-1.5 text-sm outline-none [field-sizing:content] placeholder:text-muted-foreground\"\n        />\n        {busy && value.trim() && (\n          <button\n            type=\"button\"\n            onClick={() => submit(true)}\n            className=\"flex h-8 shrink-0 items-center gap-1 rounded-full border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          >\n            Queue\n            <kbd className=\"font-sans text-[10px]\">{mod}↵</kbd>\n          </button>\n        )}\n        <motion.button\n          type={busy && !value.trim() ? \"button\" : \"submit\"}\n          onClick={busy && !value.trim() ? onStop : undefined}\n          disabled={!busy && !value.trim()}\n          aria-label={busy && !value.trim() ? \"Stop\" : \"Send\"}\n          whileTap={{ scale: 0.92 }}\n          className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-background transition-colors disabled:bg-muted disabled:text-muted-foreground\"\n        >\n          {busy && !value.trim() ? <Square className=\"size-3 fill-current\" /> : <ArrowUp className=\"size-4\" />}\n        </motion.button>\n      </form>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {queue.length > 0 ? `${queue.length} message${queue.length > 1 ? \"s\" : \"\"} queued` : \"\"}\n      </p>\n    </div>\n  );\n}\n\nfunction QueueAction({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-label={label}\n      title={label}\n      className=\"flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:bg-background focus-visible:text-foreground focus-visible:outline-none\"\n    >\n      {children}\n    </button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}