Elements

Elements · Messages

Edit a sent message

Rewrite a turn in place, told up front how many replies the edit throws away.

sending discards 3 replies
fig. 01

Installation

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

The sent bubble becomes a small composer in place: rewrite it, and a warning names how many replies sending the edit will throw away. With a runtime, editing opens the same message in its own composer and forks a new branch on send; standalone you hold the value, the editing flag, and the warning count yourself.

Getting started

s.message.composer.isEditing flips true the moment ActionBarPrimitive.Edit is pressed; render an edit composer in place of the bubble while it's true.

Trigger editing from the bubble

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

import { ActionBarPrimitive, MessagePrimitive, useAuiState } from "@assistant-ui/react";
import { PencilLineIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton } from "@/components/assistant-ui/elements/surfaces";

export function UserTurn() {
  const isEditing = useAuiState((s) => s.message.composer.isEditing);

  return (
    <MessagePrimitive.Root className="flex w-full max-w-sm flex-col items-end gap-1">
      {isEditing ? (
        <EditComposer />
      ) : (
        <div className={cn(field, "flex items-center gap-2 rounded-2xl px-3.5 py-2.5")}>
          <MessagePrimitive.Parts />
          <ActionBarPrimitive.Edit aria-label="Edit message" className={cn(ghostButton, "size-6 shrink-0")}>
            <PencilLineIcon className="size-3.5" />
          </ActionBarPrimitive.Edit>
        </div>
      )}
    </MessagePrimitive.Root>
  );
}

Render the edit composer

Inside a message that's editing, ComposerPrimitive.* scopes itself to that message's own composer rather than the thread's.

import { ComposerPrimitive } from "@assistant-ui/react";

function EditComposer() {
  return (
    <ComposerPrimitive.Root className={cn(field, "flex w-full flex-col gap-3 rounded-2xl p-3.5")}>
      <ComposerPrimitive.Input rows={2} autoFocus className="resize-none bg-transparent px-3 py-2.5 text-[13.5px] outline-none" />
      <div className="flex items-center justify-end gap-2">
        <ComposerPrimitive.Cancel className="h-8 rounded-full px-3.5 text-xs font-medium">
          Cancel
        </ComposerPrimitive.Cancel>
        <ComposerPrimitive.Send className="bg-foreground text-background h-8 rounded-full px-3.5 text-xs font-medium">
          Send
        </ComposerPrimitive.Send>
      </div>
    </ComposerPrimitive.Root>
  );
}

Thread already ships this exact swap for user and assistant messages, without the discard warning covered below; install @assistant-ui/thread for the full composition.

Anatomy

<div data-slot="edit-message">
  <button>{/* the sent bubble; click to start editing */}</button>
</div>

While editing, the same slot renders a different shape instead:

<div data-slot="edit-message">
  <textarea />
  <div>{/* "sending discards N replies", only when discardedReplies > 0 */}</div>
  <div>
    <button>Cancel</button>
    <button>Send</button>
  </div>
</div>

Standalone, discardedReplies is a plain number you supply and can define however your app models deletion. At runtime, editing does not delete anything: sending calls the message's edit composer's send(), which appends the rewrite as a new sibling of the original under the same parent, exactly like reloading an assistant message. The original message and everything under it stay in the thread's history, reachable again through the branch picker's n / m stepper, so nothing is actually discarded, only no longer the branch showing. If you want the same up-front warning, compute the count that would drop out of view: the assistant replies after this message on the branch currently displayed.

Examples

Compute the warning count

s.message.index is this message's position in the currently displayed branch; everything after it that would stop showing is s.thread.messages.slice(s.message.index + 1).

const discardedReplies = useAuiState(
  (s) => s.thread.messages.slice(s.message.index + 1).filter((m) => m.role === "assistant").length,
);

Cancel drops the draft, not the branch

ComposerPrimitive.Cancel calls the edit composer's cancel(), which discards the in-progress edit text and flips isEditing back to false; the original message is untouched, since nothing was sent.

Restyle the composer

Both lanes take className on the root; the field token from surfaces.tsx is the only surface either state uses.

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

API reference

ActionBarPrimitive and ComposerPrimitive

PartRendersNotes
ActionBarPrimitive.EditbuttonCalls aui.composer.beginEdit() for this message; disabled while already editing.
ComposerPrimitive.RootformScopes to the message's edit composer when rendered inside an editing message.
ComposerPrimitive.InputtextareaThe editable text.
ComposerPrimitive.CancelbuttonDiscards the edit and exits editing mode.
ComposerPrimitive.SendbuttonSends the edit as a new sibling branch; disabled while the text is empty.

Message and composer state

SelectorTypeDescription
s.message.composer.isEditingbooleanWhether this message is currently being edited.
s.message.indexnumberThis message's position in the currently displayed branch.
s.thread.messagesreadonly MessageState[]The full displayed branch, in order.