Elements

Elements · Messages

Message queue

Turns you typed while a run was in flight, stacked and cancelable until it finishes.

Fix the converter and add a guardrunning
3 queuedsends when this finishes
  • 1Also add a changeset
  • 2Then run the full suite
  • 3And open the PR when it's green
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-message-queue"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

While a reply is streaming, a message you send next doesn't get blocked or dropped: it stacks below a live "running" row and stays cancelable until its turn comes. With a runtime the queue is the composer's own, filled by sending normally while a run is active; standalone you hold the running text and the list yourself.

Getting started

A runtime that supports queueing tracks pending sends on s.composer.queue; nothing special is required to add to it besides sending while s.thread.isRunning is already true.

Render the queue

ComposerPrimitive.Queue maps over the pending items; QueueItemPrimitive.Text and .Remove read and act on the one currently in scope.

components/assistant-ui/elements/message-queue.tsx
"use client";

import { ComposerPrimitive, QueueItemPrimitive, useAuiState } from "@assistant-ui/react";
import { ArrowUpIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton, mono } from "@/components/assistant-ui/elements/surfaces";

export function MessageQueue() {
  const queueLength = useAuiState((s) => s.composer.queue.length);

  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      {queueLength > 0 && (
        <div className="flex items-baseline justify-between px-1">
          <span className={cn(mono, "text-foreground/35")}>{queueLength} queued</span>
          <span className={cn(mono, "text-foreground/35")}>sends when this finishes</span>
        </div>
      )}
      <ul className="flex flex-col gap-1.5">
        <ComposerPrimitive.Queue>
          {({ queueItem }) => (
            <li
              key={queueItem.id}
              className={cn(field, "flex items-center gap-2.5 rounded-2xl py-2 pr-2 pl-3")}
            >
              <span className="text-foreground/60 min-w-0 flex-1 truncate text-[13.5px]">
                <QueueItemPrimitive.Text />
              </span>
              <ArrowUpIcon className="text-foreground/25 size-3 shrink-0" />
              <QueueItemPrimitive.Remove aria-label="Remove from queue" className={cn(ghostButton, "size-6 shrink-0")}>
                <XIcon className="size-3.5" />
              </QueueItemPrimitive.Remove>
            </li>
          )}
        </ComposerPrimitive.Queue>
      </ul>
    </div>
  );
}

Show what's running

There's no single selector for the running prompt's text the way there is for the queue; while s.thread.isRunning is true it's simply the most recently sent user message.

const running = useAuiState((s) =>
  s.thread.isRunning ? [...s.thread.messages].reverse().find((m) => m.role === "user") : undefined,
);

Render the same pulsing-dot row the standalone element uses once running resolves to a message; a user message's content is an array of parts, not a plain string, so join the text parts yourself: running.content.filter((p) => p.type === "text").map((p) => p.text).join(" ").

Anatomy

<div data-slot="message-queue">
  <div>{/* running: pulsing dot, text, "running" badge */}</div>
  <div>{/* "n queued · sends when this finishes", only when queued.length > 0 */}</div>
  <ul>
    <li>{/* index, text, arrow, remove button */}</li>
  </ul>
</div>

Only one message is ever "running"; onCancel is offered exclusively on queued items and each one fades and slides in as it's added, keying on the item's id so the ones already visible don't replay the animation. Standalone this is one flat list with no concept of order beyond array position. At runtime, s.composer.queue can also be reordered with aui.composer.queueItem({ id }).move({ insertAfter, insertBefore }) and one item can jump ahead of the rest with aui.composer.queueItem({ id }).move({ lane: "steer", insertAfter: null }), neither of which this simple view surfaces. Queueing itself is a capability, not a guarantee: check s.thread.capabilities.queue before assuming a mid-run send lands in the queue rather than being rejected.

Examples

Move a queued message to the front

QueueItemPrimitive.Steer runs a queued item next instead of waiting its turn:

<QueueItemPrimitive.Steer className={cn(ghostButton, "px-2 text-xs")}>
  Run now
</QueueItemPrimitive.Steer>

Restyle the queue

Both lanes take className on the root, and the field, ghostButton, and mono tokens from surfaces.tsx cover the queued row, the remove button, and the small caps labels.

<MessageQueue className="gap-3" /* ... */ />

API reference

ComposerPrimitive and QueueItemPrimitive

PartRendersNotes
ComposerPrimitive.QueuefragmentRender-prop over every pending queue item, in order.
QueueItemPrimitive.TextspanThe item's text parts, joined.
QueueItemPrimitive.RemovebuttonRemoves this item from the queue.
QueueItemPrimitive.SteerbuttonRuns this item next instead of waiting its turn.

Composer state

SelectorTypeDescription
s.composer.queuereadonly { id: string; parts: ... }[]The pending sends, in queue order.
s.thread.isRunningbooleanTrue while a message is actively running.
s.thread.capabilities.queuebooleanWhether this runtime queues mid-run sends at all.
aui.composer.queueItem({ id }).move(placement)(placement: { lane?: "queue" | "steer"; insertAfter?: string | null; insertBefore?: string | null }) => voidReorders an item or moves it to run next.
aui.composer.queueItem({ id }).remove()() => voidRemoves this item from the queue.