Elements

Elements · Messages

Stopped run

You pressed stop. The half-written answer stays, and continuing is one tap away.

stopped by you
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-stopped-run"
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.

StoppedRun shows what a cancelled generation leaves behind: the words that arrived before the stop, a small reason badge, and a way to pick it back up or let it go. With a runtime the words and the reason come from the message that was cancelled; standalone you pass in whatever you captured.

Getting started

A cancelled message settles into status.type === "incomplete" with status.reason === "cancelled". Its content up to that point is still there; only the run stopped.

Detect a cancelled message and wire its actions

components/assistant-ui/elements/thread.aui.tsx
"use client";

import { useAui, useAuiState } from "@assistant-ui/react";
import { StoppedRun } from "@/components/assistant-ui/elements/stopped-run";

function CancelledNotice() {
  const aui = useAui();
  const status = useAuiState((s) =>
    s.message.role === "assistant" ? s.message.status : undefined,
  );
  const words = useAuiState((s) =>
    s.message.role === "assistant"
      ? s.message.content
          .filter((part): part is { type: "text"; text: string } => part.type === "text")
          .flatMap((part) => part.text.split(" "))
      : [],
  );

  if (status?.type !== "incomplete" || status.reason !== "cancelled") return null;

  return (
    <StoppedRun
      words={words}
      reason="stopped by you"
      onContinue={() => aui.message.reload()}
      onDiscard={() => aui.message.delete()}
    />
  );
}

Understand what Continue really does

reload() starts a new run for the same turn; it does not resume the exact cut-off text, since assistant-ui has no built-in prefix-continuation primitive. A backend that supports literal continuation needs the partial text itself, for example passed through reload's runConfig.custom and read back inside your ChatModelAdapter.

Anatomy

<div data-slot="stopped-run">
  <p>{/* words joined by a space, with a blinking cursor after them */}</p>
  <div>
    <span>{/* the reason badge, e.g. "stopped by you" */}</span>
    <button>Continue</button>
    <button>Discard</button>
  </div>
</div>

The cursor is decorative (aria-hidden) and always renders while the component is mounted; there is no internal "still streaming" state to turn it off. reason is freeform text, not a fixed enum, so the badge can describe any way a run ends short of completion, not only a manual stop.

Examples

Other reasons a run stops

status.reason covers more than a manual stop: "length", "content-filter", "tool-calls", and "error" are all real values alongside "cancelled". The same component fits any of them with a different label:

const LABEL: Record<string, string> = {
  cancelled: "stopped by you",
  length: "hit the length limit",
  "content-filter": "blocked by a content filter",
  error: "failed partway through",
};

<StoppedRun words={words} reason={LABEL[status.reason] ?? "stopped early"} />

Where discard goes

const onDiscard = () => aui.message.delete();

delete() removes the message from the thread. Its Promise<void> return exists because a persisted runtime's delete is asynchronous; you rarely need to await it from a click handler.

Restyle the badge and actions

Both lanes take className on the root. The badge reads the shared field and mono surfaces from surfaces.tsx.

<StoppedRun className="max-w-none" /* ... */ />

API reference

Message state

Selector / methodTypeDescription
s.message.statusMessageStatus | undefined{ type: "incomplete", reason: "cancelled" } when the message stopped because you pressed stop. Other incomplete reasons: "length", "content-filter", "tool-calls", "error", "other".
aui.thread.cancelRun()() => voidStops the active run; the streaming message settles into the incomplete status above.
aui.message.reload(config?)(config?: { runConfig?: RunConfig }) => voidStarts a new run for this turn. Restarts generation; it does not resume the cut-off text.
aui.message.delete()() => void | Promise<void>Removes the message from the thread.