Let users select and quote text from messages, similar to Claude's quoting experience.
Allow users to select text in assistant messages and reply with a quote reference — just like Claude, ChatGPT, and other modern AI interfaces.
How It Works
- User selects text in a message
- A floating toolbar appears near the selection with a Quote button
- User clicks it — a quote preview appears in the composer
- User types their reply and sends
- The sent message displays the quoted text above the user's reply
Quote data is stored in metadata.custom.quote and is not injected into message content. Your backend decides how to present the quoted context to the LLM.
Quick Start
1. Add the Floating Selection Toolbar
Place SelectionToolbarPrimitive inside your ThreadPrimitive.Root. It renders a floating toolbar near the user's text selection — only when text is selected within a message.
import { SelectionToolbarPrimitive, ThreadPrimitive } from "@assistant-ui/react";
import { QuoteIcon } from "lucide-react";
const Thread = () => {
return (
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport>
<ThreadPrimitive.Messages components={{ ... }} />
...
</ThreadPrimitive.Viewport>
{/* Floating toolbar — appears on text selection */}
<SelectionToolbarPrimitive.Root className="flex items-center gap-1 rounded-lg border bg-popover px-1 py-1 shadow-md">
<SelectionToolbarPrimitive.Quote className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm hover:bg-accent">
<QuoteIcon className="size-3.5" />
Quote
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
</ThreadPrimitive.Root>
);
};The Root component:
- Listens for
mouseupandkeyupevents to detect text selections - Validates the selection is within a single message (cross-message selections are ignored)
- Renders a portal positioned above the selection
- Prevents
mousedownfrom clearing the selection when clicking the toolbar - Hides automatically on scroll or when the selection is cleared
2. Show Quote Preview in Composer
Add ComposerPrimitive.Quote inside the composer to show what's being quoted:
import { ComposerPrimitive } from "@assistant-ui/react";
const Composer = () => {
return (
<ComposerPrimitive.Root>
{/* Quote preview — only renders when a quote is set */}
<ComposerPrimitive.Quote className="flex items-start gap-2 border-l-4 border-primary/40 bg-muted/50 px-3 py-2 text-sm">
<ComposerPrimitive.QuoteText className="line-clamp-2 flex-1 italic text-muted-foreground" />
<ComposerPrimitive.QuoteDismiss>
×
</ComposerPrimitive.QuoteDismiss>
</ComposerPrimitive.Quote>
<ComposerPrimitive.Input placeholder="Send a message..." />
<ComposerPrimitive.Send />
</ComposerPrimitive.Root>
);
};3. Display Quotes in Sent Messages
Use useMessageQuote() to render quoted text in user messages:
import { MessagePrimitive, useMessageQuote } from "@assistant-ui/react";
const QuoteBlock = () => {
const quote = useMessageQuote();
if (!quote) return null;
return (
<div className="mb-1 border-l-4 border-primary/40 pl-3 text-xs italic text-muted-foreground">
{quote.text}
</div>
);
};
const UserMessage = () => {
return (
<MessagePrimitive.Root>
<QuoteBlock />
<MessagePrimitive.Parts />
</MessagePrimitive.Root>
);
};Backend Handling
The quote is stored in message metadata — not in message content. This gives your backend full control over how to present quoted context to the LLM.
Data Shape
type QuoteInfo = {
readonly text: string; // selected plain text
readonly messageId: string; // source message ID
};
// Stored at: message.metadata.custom.quoteExample: Prepend as Markdown Blockquote
A simple approach is to prepend the quoted text as a > blockquote before converting to model messages:
import { convertToModelMessages, streamText } from "ai";
import type { UIMessage } from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: myModel,
messages: await convertToModelMessages(injectQuoteContext(messages)),
});
return result.toUIMessageStreamResponse();
}
function injectQuoteContext(messages: UIMessage[]): UIMessage[] {
return messages.map((msg) => {
const quote = (msg.metadata as Record<string, unknown>)?.custom;
if (
!quote ||
typeof quote !== "object" ||
!("quote" in (quote as Record<string, unknown>))
)
return msg;
const q = (quote as Record<string, unknown>).quote;
if (
!q ||
typeof q !== "object" ||
!("text" in (q as Record<string, unknown>))
)
return msg;
const text = (q as { text: unknown }).text;
if (typeof text !== "string") return msg;
return {
...msg,
parts: [{ type: "text" as const, text: `> ${text}\n\n` }, ...msg.parts],
};
});
}Example: Claude-Style Citation Source
For Claude's API, you can pass the quoted text as a citation source. This enables Claude to produce citations that reference the quoted text:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
export async function POST(req: Request) {
const { messages } = await req.json();
// Transform messages: extract quotes into Claude source blocks
const claudeMessages = messages.map((msg) => {
const quote = msg.metadata?.custom?.quote;
if (!quote?.text) {
return { role: msg.role, content: extractText(msg) };
}
return {
role: "user",
content: [
{
type: "text",
text: quote.text,
cache_control: { type: "ephemeral" },
citations: { enabled: true },
},
{
type: "text",
text: extractText(msg),
},
],
};
});
const response = await client.messages.create({
model: "claude-sonnet-4-5-20250929",
max_tokens: 1024,
messages: claudeMessages,
});
// ... stream response back
}Example: OpenAI-Style System Context
For OpenAI's API, inject the quote as additional context in the user message:
function injectQuoteForOpenAI(messages) {
return messages.map((msg) => {
const quote = msg.metadata?.custom?.quote;
if (!quote?.text || msg.role !== "user") return msg;
return {
...msg,
content: `[Referring to: "${quote.text}"]\n\n${msg.content}`,
};
});
}Programmatic API
You can set or clear quotes programmatically via the composer runtime:
import { useAui } from "@assistant-ui/react";
function MyComponent() {
const aui = useAui();
const quoteText = () => {
aui.thread().composer.setQuote({
text: "The text to quote",
messageId: "msg-123",
});
};
const clearQuote = () => {
aui.thread().composer.setQuote(undefined);
};
return (
<>
<button onClick={quoteText}>Set Quote</button>
<button onClick={clearQuote}>Clear Quote</button>
</>
);
}API Reference
SelectionToolbarPrimitive.Root
A floating container that appears when text is selected within a message. Renders as a portal positioned above the selection.
- Listens for
mouseup/keyupto detect selection - Validates selection is within a single message
- Hides on scroll or when selection is cleared
- Prevents
mousedownfrom clearing the selection - Provides selection context to child components
SelectionToolbarPrimitive.Quote
A button inside the floating toolbar that captures the selection as a quote.
- Reads selection info from the toolbar context (not
window.getSelection()) - Stores
{ text, messageId }in the thread composer - Clears the text selection after quoting
ComposerPrimitive.Quote
A container that only renders when a quote is set.
ComposerPrimitive.QuoteText
Renders the quoted text. Defaults to <span>.
ComposerPrimitive.QuoteDismiss
A button that clears the quote by calling setQuote(undefined). Supports asChild.
useMessageQuote()
const quote: QuoteInfo | undefined = useMessageQuote();Returns the quote attached to the current message, or undefined.
ComposerRuntime.setQuote()
setQuote(quote: QuoteInfo | undefined): voidSet or clear the quote on the composer. The quote is automatically cleared when the message is sent.
Design Notes
- Single quote —
setQuotereplaces, not appends. Only one quote at a time. - Snapshot text — The selected text is captured at quote time, not linked to the source message.
- Cross-message selection — Rejected. The toolbar only appears when the selection is within a single message.
- Streaming messages — The floating toolbar works during streaming since it reads from the captured selection, not message status.
isEmptyunchanged — A quote alone doesn't make the composer non-empty. The user must type a reply.- Scroll hides toolbar — The floating toolbar hides when any scroll event occurs, since the position would become stale.
Related
- Message Editing — Edit user messages
- Thread Component — Main chat container
- ComposerPrimitive — Composer primitive reference
- ActionBarPrimitive — Action bar primitive reference