Quote Selected Text

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

  1. User selects text in a message
  2. A floating toolbar appears near the selection with a Quote button
  3. User clicks it — a quote preview appears in the composer
  4. User types their reply and sends
  5. 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 mouseup and keyup events 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 mousedown from 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.quote

Example: Prepend as Markdown Blockquote

A simple approach is to prepend the quoted text as a > blockquote before converting to model messages:

app/api/chat/route.ts
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:

app/api/chat/route.ts
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:

app/api/chat/route.ts
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 / keyup to detect selection
  • Validates selection is within a single message
  • Hides on scroll or when selection is cleared
  • Prevents mousedown from 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): void

Set or clear the quote on the composer. The quote is automatically cleared when the message is sent.

Design Notes

  • Single quotesetQuote replaces, 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.
  • isEmpty unchanged — 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.