# Message Queue

> 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.

- Category: composer
- Job: Ask — Get intent into the AI: composers, voice, attachments and asking in context.
- Slug: `message-queue`
- HTML: https://ai-patterns.app/patterns/composer/message-queue
- JSON: https://ai-patterns.app/api/patterns/composer/message-queue
- Seen in: [Claude](https://code.claude.com/docs/en/interactive-mode), [Replit](https://docs.replit.com/features/agent/steer-and-queue-messages), [Perplexity](https://www.perplexity.ai/help-center/en/articles/13600190-what-s-new-in-advanced-deep-research)

## Summary
A 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.

## When to use
- Agentic products where turns run for seconds or minutes (coding agents, research, multi-step tool use) and users already know their next instruction.
- 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".

## When not to use
- Fast, single-shot chat where responses land in a second or two — the queue would never be visible long enough to matter.
- When the next message depends on reading the current answer first; a queue only helps when the follow-up is already known.
- Products that can't actually defer a message — never show a queue the backend won't honor in order.

## Anatomy
- Composer: input, send button (morphs to Stop while busy and empty), and a "Queue ⌘↵" button that appears while busy and there's a draft.
- Placeholder hint while busy: "↵ to send, ⌘↵ to queue" — teaches the shortcut exactly when it applies.
- 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.
- Row actions: Send now (return arrow), Edit (pencil), Remove (×).

## Behavior
- Idle: Enter sends; ⌘Enter behaves like Enter (there's nothing to queue behind).
- 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.
- 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.
- Send now removes the row and sends it immediately, skipping the line.
- 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.
- ↑ on an empty composer recalls the last queued message for editing, mirroring shell history.
- Rows slide in from the composer when queued and lift away when sent or removed; the stack reflows with layout animation.

## Content guidelines
- Name the state plainly: "Queued", "2 queued". Avoid "pending", which reads like a sending failure.
- Show the shortcut as glyphs (⌘↵, Ctrl↵) matched to the user's platform, never both.
- Previews are one line, truncated; the full text is available on hover and via Edit.

## Accessibility
- The queue is a list labeled "Queued messages"; every row action is a real button with an `aria-label` and a tooltip.
- A polite live region announces the queue count when it changes, so screen-reader users know a message was parked rather than sent.
- The input exposes its shortcuts via `aria-keyshortcuts`, and the visible "Queue" button gives pointer and touch users the same action without the chord.
- Edit and Remove return focus to the composer.
- Respect `prefers-reduced-motion`: rows fade instead of sliding and reflowing.

## Related patterns
- **Stop Generation Button** — Use it as well as the send/stop control the composer shows while the agent is busy.
- **Prompt Bar** — Use it instead when the agent can't be busy between messages and there is nothing to queue.
- **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.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUp, CornerDownLeft, Pencil, Square, X } from "lucide-react";

import { cn } from "@/lib/utils";

export interface QueuedMessage {
  id: string;
  text: string;
}

export type SendMode = "now" | "queued";

export interface MessageQueueComposerProps {
  /** True while the agent is working on a turn. ⌘/Ctrl+Enter queues only while busy. */
  busy: boolean;
  /**
   * Called when a message goes out. `"now"` is a message sent directly (Enter, or
   * "Send now" on a queued item — the host decides whether that steers or interrupts
   * the running turn); `"queued"` is a queued message auto-sent when the turn ended.
   */
  onSend: (text: string, mode: SendMode) => void;
  onStop?: () => void;
  placeholder?: string;
  className?: string;
}

const spring = { type: "spring", stiffness: 520, damping: 40 } as const;

let uid = 0;
const nextId = () => `q${++uid}`;

const noop = () => () => {};

function useIsMac() {
  return React.useSyncExternalStore(
    noop,
    () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent),
    () => true
  );
}

export function MessageQueueComposer({
  busy,
  onSend,
  onStop,
  placeholder = "Ask anything",
  className,
}: MessageQueueComposerProps) {
  const [value, setValue] = React.useState("");
  const [queue, setQueue] = React.useState<QueuedMessage[]>([]);
  const inputRef = React.useRef<HTMLTextAreaElement>(null);
  const reduceMotion = useReducedMotion();
  const mac = useIsMac();
  const mod = mac ? "⌘" : "Ctrl";

  // When the running turn ends, the oldest queued message goes out on its own.
  const wasBusy = React.useRef(busy);
  React.useEffect(() => {
    if (wasBusy.current && !busy && queue.length > 0) {
      const [next, ...rest] = queue;
      setQueue(rest);
      onSend(next.text, "queued");
    }
    wasBusy.current = busy;
  }, [busy, queue, onSend]);

  function focusInput() {
    requestAnimationFrame(() => {
      const el = inputRef.current;
      if (!el) return;
      el.focus();
      el.setSelectionRange(el.value.length, el.value.length);
    });
  }

  function submit(toQueue: boolean) {
    const text = value.trim();
    if (!text) return;
    setValue("");
    if (toQueue && busy) setQueue((q) => [...q, { id: nextId(), text }]);
    else onSend(text, "now");
  }

  function sendNow(item: QueuedMessage) {
    setQueue((q) => q.filter((m) => m.id !== item.id));
    onSend(item.text, "now");
  }

  function edit(item: QueuedMessage) {
    const draft = value.trim();
    // Pull the message back into the composer; an unsent draft takes its slot so nothing is lost.
    setQueue((q) => q.flatMap((m) => (m.id !== item.id ? [m] : draft ? [{ id: nextId(), text: draft }] : [])));
    setValue(item.text);
    focusInput();
  }

  function remove(item: QueuedMessage) {
    setQueue((q) => q.filter((m) => m.id !== item.id));
    focusInput();
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      submit(e.metaKey || e.ctrlKey);
    } else if (e.key === "ArrowUp" && !value && queue.length > 0) {
      // Up on an empty composer recalls the last queued message for editing.
      e.preventDefault();
      edit(queue[queue.length - 1]);
    }
  }

  const hint = busy ? `↵ to send, ${mod}↵ to queue` : placeholder;

  return (
    <div className={cn("w-full", className)}>
      <ul aria-label="Queued messages" className="flex flex-col gap-1.5 empty:hidden">
        <AnimatePresence initial={false}>
          {queue.map((item, i) => (
            <motion.li
              key={item.id}
              layout={!reduceMotion}
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.97 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97, transition: { duration: 0.15 } }}
              transition={spring}
              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"
            >
              <span className="shrink-0 font-medium text-foreground">
                {i === 0 ? (queue.length === 1 ? "1 queued" : `${queue.length} queued`) : `#${i + 1}`}
              </span>
              <span className="min-w-0 flex-1 truncate text-muted-foreground" title={item.text}>
                {item.text}
              </span>
              <QueueAction label="Send now" onClick={() => sendNow(item)}>
                <CornerDownLeft className="size-3.5" />
              </QueueAction>
              <QueueAction label="Edit" onClick={() => edit(item)}>
                <Pencil className="size-3.5" />
              </QueueAction>
              <QueueAction label="Remove from queue" onClick={() => remove(item)}>
                <X className="size-3.5" />
              </QueueAction>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          submit(false);
        }}
        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"
      >
        <textarea
          ref={inputRef}
          rows={1}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          onKeyDown={onKeyDown}
          placeholder={hint}
          aria-label="Message"
          aria-keyshortcuts={busy ? `Enter ${mac ? "Meta" : "Control"}+Enter` : "Enter"}
          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"
        />
        {busy && value.trim() && (
          <button
            type="button"
            onClick={() => submit(true)}
            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"
          >
            Queue
            <kbd className="font-sans text-[10px]">{mod}↵</kbd>
          </button>
        )}
        <motion.button
          type={busy && !value.trim() ? "button" : "submit"}
          onClick={busy && !value.trim() ? onStop : undefined}
          disabled={!busy && !value.trim()}
          aria-label={busy && !value.trim() ? "Stop" : "Send"}
          whileTap={{ scale: 0.92 }}
          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"
        >
          {busy && !value.trim() ? <Square className="size-3 fill-current" /> : <ArrowUp className="size-4" />}
        </motion.button>
      </form>
      <p aria-live="polite" className="sr-only">
        {queue.length > 0 ? `${queue.length} message${queue.length > 1 ? "s" : ""} queued` : ""}
      </p>
    </div>
  );
}

function QueueAction({
  label,
  onClick,
  children,
}: {
  label: string;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      title={label}
      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"
    >
      {children}
    </button>
  );
}
```
