Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 141 additions & 3 deletions apps/docs/content/components/(chatbot)/message.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ This is **required** for the MessageResponse component to work properly. Without
- **Response branching** with navigation controls to switch between multiple AI response versions
- **Markdown rendering** with GFM support (tables, task lists, strikethrough), math equations, and smart streaming
- **Action buttons** for common operations (retry, like, dislike, copy, share) with tooltips and state management
- **Replies and quotes** for referencing a complete message or selected text, with a removable quote preview
- **File attachments** display with support for images and generic files with preview and remove functionality
- Code blocks with syntax highlighting and copy-to-clipboard functionality
- Keyboard accessible with proper ARIA labels
Expand Down Expand Up @@ -158,6 +159,76 @@ const ActionsDemo = () => {
export default ActionsDemo;
```

## Quoting and replying

Use `MessageReply` inside `MessageActions` to reply to a message. Pass the original
message as `text` and, optionally, a ref to `MessageContent` as `selectionRef`.
When text is selected entirely within that content, `onReply` receives the selected
plain text. Otherwise, it receives the original `text`, preserving its Markdown.

<Preview path="message-reply" />

The example keeps the quote separate from the draft and renders it with
`MessageQuote` inside `PromptInputHeader`. The Reply action remains available to
keyboard and touch users without requiring text selection. Selecting a new quote
replaces the previous one without changing the draft. Removing a quote returns
focus to the input.

`MessageQuote` can also display a sent message's reference: omit `onRemove` for a
read-only quote. Quote text is rendered as text, without interpreting Markdown or
HTML. Long quotes scroll within the preview and can be focused to scroll with the
keyboard. Labels can be localized; pass children to `MessageReply` to replace its
default icon.

### State and keyboard behavior

Reply state belongs to your application; no provider or changes to the
`PromptInputMessage` shape are required. Store the message ID alongside the quoted
text if you need to link back to its source. Use a snapshot of the quote so that
later edits or streaming updates do not change what the user selected.

The example handles Escape on the composer, not globally. It respects prevented
events and IME composition, so dismissing a nested menu or composing text does not
accidentally remove a quote. Escape and the remove button preserve the draft.
The textarea also guards the IME confirmation Enter event so it does not send the
reply while the input method is finishing a character.

### Sending quoted context with the AI SDK

The preview above adds replies locally. In your application, explicitly include
the quote when calling `sendMessage`; displaying `MessageQuote` alone does not
send context to the model. One option is to prepend a Markdown blockquote to the
outgoing text:

```tsx
const handleSubmit = ({ text, files }: PromptInputMessage) => {
if (!text.trim()) {
return;
}

const quotedText = quote?.text
.split("\n")
.map((line) => `> ${line}`)
.join("\n");

return sendMessage({
text: quotedText ? `${quotedText}\n\n${text}` : text,
files,
});
};
```

Keep a snapshot of the submitted quote and decide when to clear it in your
application's send lifecycle. With `useChat`, use `onError` and the `isError`,
`isAbort`, and `isDisconnect` flags passed to `onFinish`: `sendMessage` can resolve
after a transport error, so awaiting it alone does not confirm success. Use a
controlled `PromptInputTextarea` if you need to preserve or restore the draft on
failure, and avoid clearing a newer draft or quote when an earlier request ends.

If your application stores quotes as custom message data instead, ensure your
server includes that data when constructing model messages. Treat quoted content
as user-provided context, just like the rest of the message.

## Props

### `<Message />`
Expand All @@ -181,8 +252,9 @@ export default ActionsDemo;
<TypeTable
type={{
"...props": {
description: "Any other props are spread to the content div.",
type: "React.HTMLAttributes<HTMLDivElement>",
description:
"Any other props, including ref, are spread to the content div.",
type: 'React.ComponentProps<"div">',
},
}}
/>
Expand Down Expand Up @@ -278,6 +350,71 @@ export default ActionsDemo;
}}
/>

### `<MessageReply />`

<TypeTable
type={{
text: {
description:
"The complete message to quote when there is no selection within selectionRef.",
type: "string",
required: true,
},
onReply: {
description:
"Called with the selected plain text or the complete message. Manage quote state and composer focus in this callback.",
type: "(text: string) => void",
required: true,
},
selectionRef: {
description:
"Ref to the message content. Only a selection entirely inside this element is used.",
type: "React.RefObject<HTMLElement | null>",
},
label: {
description: "Accessible label for the reply action.",
type: "string",
default: '"Reply"',
},
"...props": {
description:
"Other MessageAction props, including disabled, tooltip, children, and event handlers. Prevent default in onClick to cancel a reply.",
type: "MessageActionProps",
},
}}
/>

### `<MessageQuote />`

<TypeTable
type={{
children: {
description:
"The quoted content. Pass a string to render it as plain text.",
type: "React.ReactNode",
},
label: {
description: "Visible label and accessible name of the quote group.",
type: "string",
default: '"Replying to"',
},
onRemove: {
description:
"Shows a remove button when provided. Remove the quote and restore composer focus in this callback.",
type: "() => void",
},
removeLabel: {
description: "Accessible label for the remove button.",
type: "string",
default: '"Remove quote"',
},
"...props": {
description: "Any other props are spread to the root div.",
type: 'React.ComponentProps<"div">',
},
}}
/>

### `<MessageBranch />`

<TypeTable
Expand Down Expand Up @@ -314,7 +451,8 @@ export default ActionsDemo;
<TypeTable
type={{
"...props": {
description: "Any other props are spread to the underlying ButtonGroup component.",
description:
"Any other props are spread to the underlying ButtonGroup component.",
type: "React.ComponentProps<typeof ButtonGroup>",
},
}}
Expand Down
179 changes: 179 additions & 0 deletions packages/elements/__tests__/message-reply-example.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";

import Example from "../../examples/src/message-reply";

describe("message reply example", () => {
it("preserves the draft and focuses the input when adding a quote", async () => {
render(<Example />);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "My unfinished thought");
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
expect(input).toHaveFocus();
expect(input).toHaveValue("My unfinished thought");
expect(
screen.getByRole("group", { name: "Replying to assistant" })
).toBeInTheDocument();
});

it("replaces a quote without replacing the draft", async () => {
render(<Example />);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "My unfinished thought");
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
await userEvent.click(
screen.getByRole("button", { name: "Reply to user" })
);
expect(
screen.queryByRole("group", { name: "Replying to assistant" })
).not.toBeInTheDocument();
expect(
screen.getByRole("group", { name: "Replying to user" })
).toHaveTextContent("What makes a good loading state?");
expect(input).toHaveValue("My unfinished thought");
});

it("preserves the draft and restores focus when removing a quote", async () => {
render(<Example />);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "My unfinished thought");
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
await userEvent.click(screen.getByRole("button", { name: "Remove quote" }));
expect(input).toHaveFocus();
expect(input).toHaveValue("My unfinished thought");
expect(
screen.queryByRole("button", { name: "Remove quote" })
).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Quote removed.");
});

it("removes the quote with Escape in the composer without clearing the draft", async () => {
render(<Example />);
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "Keep this draft");
await userEvent.keyboard("{Escape}");
expect(
screen.queryByRole("button", { name: "Remove quote" })
).not.toBeInTheDocument();
expect(input).toHaveValue("Keep this draft");
expect(input).toHaveFocus();
});

it("does not consume Escape outside the composer", async () => {
render(<Example />);
const reply = screen.getByRole("button", { name: "Reply to assistant" });
await userEvent.click(reply);
reply.focus();
await userEvent.keyboard("{Escape}");
expect(
screen.getByRole("button", { name: "Remove quote" })
).toBeInTheDocument();
});

it("keeps the quote when Escape belongs to IME composition", async () => {
render(<Example />);
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
const input = screen.getByRole("textbox", { name: "Your reply" });
fireEvent.compositionStart(input);
fireEvent.keyDown(input, { key: "Escape" });
expect(
screen.getByRole("button", { name: "Remove quote" })
).toBeInTheDocument();
fireEvent.compositionEnd(input);
fireEvent.keyDown(input, { key: "Escape", keyCode: 229 });
expect(
screen.getByRole("button", { name: "Remove quote" })
).toBeInTheDocument();
fireEvent.keyDown(input, { isComposing: true, key: "Escape" });
expect(
screen.getByRole("button", { name: "Remove quote" })
).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Escape" });
expect(
screen.queryByRole("button", { name: "Remove quote" })
).not.toBeInTheDocument();
});

it("respects Escape already handled by a nested control", async () => {
render(<Example />);
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
const input = screen.getByRole("textbox", { name: "Your reply" });
input.addEventListener("keydown", (event) => event.preventDefault(), {
once: true,
});
fireEvent.keyDown(input, { key: "Escape" });
expect(
screen.getByRole("button", { name: "Remove quote" })
).toBeInTheDocument();
});

it("sends the quote with the reply and clears the composer", async () => {
render(<Example />);
await userEvent.click(
screen.getByRole("button", { name: "Reply to user" })
);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "Explain this in more detail");
await userEvent.keyboard("{Enter}");
await expect(
screen.findByText("Explain this in more detail")
).resolves.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Remove quote" })
).not.toBeInTheDocument();
expect(input).toHaveValue("");
const quote = screen.getByRole("group", { name: "Replying to user" });
expect(quote).toHaveTextContent("What makes a good loading state?");
expect(screen.getByRole("button", { name: "Send reply" })).toBeDisabled();
});

it("supports multiline drafts without sending on Shift+Enter", async () => {
render(<Example />);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "First line");
await userEvent.keyboard("{Shift>}{Enter}{/Shift}");
await userEvent.type(input, "Second line");
expect(input).toHaveValue("First line\nSecond line");
expect(
screen.getAllByRole("button", { name: "Reply to user" })
).toHaveLength(1);
});

it("does not send on the IME confirmation Enter after composition ends", async () => {
render(<Example />);
await userEvent.click(
screen.getByRole("button", { name: "Reply to assistant" })
);
const input = screen.getByRole("textbox", { name: "Your reply" });
await userEvent.type(input, "입력 확정");
await act(() => {
fireEvent.compositionStart(input);
});
await act(() => {
fireEvent.compositionEnd(input);
});
await act(() => {
fireEvent.keyDown(input, { key: "Enter", keyCode: 229 });
});
expect(input).toHaveValue("입력 확정");
expect(
screen.getByRole("button", { name: "Remove quote" })
).toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: "Reply to user" })
).toHaveLength(1);
});
});
Loading