diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 51e8fb0b..b5ae6004 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -43,8 +43,8 @@ import type {
interface MessageBubbleProps {
message: UIMessage;
- /** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
- showAssistantCopyAction?: boolean;
+ /** When false, hide this message's copy button. Default true. */
+ showCopyAction?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
@@ -71,6 +71,59 @@ function ForkArrowIcon({ className }: { className?: string }) {
);
}
+function MessageCopyButton({ content }: { content: string }) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const copyResetRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (copyResetRef.current !== null) {
+ window.clearTimeout(copyResetRef.current);
+ }
+ };
+ }, []);
+
+ const onCopy = useCallback(() => {
+ void copyTextToClipboard(content).then((ok) => {
+ if (!ok) return;
+ setCopied(true);
+ if (copyResetRef.current !== null) {
+ window.clearTimeout(copyResetRef.current);
+ }
+ copyResetRef.current = window.setTimeout(() => {
+ setCopied(false);
+ copyResetRef.current = null;
+ }, 1_500);
+ });
+ }, [content]);
+
+ const label = copied ? t("message.copiedReply") : t("message.copyReply");
+ return (
+
+
+
+
+ {label}
+
+ );
+}
+
/**
* Render a single message. Following agent-chat-ui: user turns are a rounded
* "pill" right-aligned with a muted fill; assistant turns render as bare
@@ -82,15 +135,13 @@ function ForkArrowIcon({ className }: { className?: string }) {
*/
export function MessageBubble({
message,
- showAssistantCopyAction = true,
+ showCopyAction = true,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
onForkFromHere,
}: MessageBubbleProps) {
const { t } = useTranslation();
- const [copied, setCopied] = useState(false);
- const copyResetRef = useRef(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
const mentionCliApps = useMemo(
() => mergeCliMentionApps(cliApps, message.cliApps),
@@ -101,28 +152,6 @@ export function MessageBubble({
[mcpPresets, message.mcpPresets],
);
- useEffect(() => {
- return () => {
- if (copyResetRef.current !== null) {
- window.clearTimeout(copyResetRef.current);
- }
- };
- }, []);
-
- const onCopyAssistantReply = useCallback(() => {
- void copyTextToClipboard(message.content).then((ok) => {
- if (!ok) return;
- setCopied(true);
- if (copyResetRef.current !== null) {
- window.clearTimeout(copyResetRef.current);
- }
- copyResetRef.current = window.setTimeout(() => {
- setCopied(false);
- copyResetRef.current = null;
- }, 1_500);
- });
- }, [message.content]);
-
if (message.kind === "trace") {
return ;
}
@@ -158,6 +187,13 @@ export function MessageBubble({
/>
) : null}
+ {hasText && showCopyAction ? (
+
+
+
+
+
+ ) : null}
);
}
@@ -179,9 +215,8 @@ export function MessageBubble({
const automationTriggeredLabel = t("message.automationTriggered");
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
- const showCopyButton = showAssistantCopyAction && showAssistantActions;
+ const showCopyButton = showCopyAction && showAssistantActions;
const showForkButton = showAssistantActions && !!onForkFromHere;
- const copyReplyLabel = copied ? t("message.copiedReply") : t("message.copyReply");
const forkLabel = t("message.forkFromHere");
const latencyMs = message.latencyMs;
const showLatencyFooter =
@@ -221,27 +256,7 @@ export function MessageBubble({
{showCopyButton ? (
-
-
-
-
- {copyReplyLabel}
-
+
) : null}
{showForkButton ? (
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index 2e4703f9..7536b848 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -109,7 +109,7 @@ export function ThreadMessages({
) : (
{
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
+ it("copies user messages from the shared message action", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ const message: UIMessage = {
+ id: "u-copy",
+ role: "user",
+ content: "Copy this user prompt.",
+ createdAt: Date.now(),
+ };
+
+ try {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ expect(writeText).toHaveBeenCalledWith("Copy this user prompt.");
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
+ );
+ } finally {
+ Reflect.deleteProperty(navigator, "clipboard");
+ }
+ });
+
it("renders fork control in completed assistant action rows", () => {
const onForkFromHere = vi.fn();
const message: UIMessage = {
@@ -279,7 +307,7 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
});
- it("does not show copy when showAssistantCopyAction is false", () => {
+ it("does not show copy when showCopyAction is false", () => {
const message: UIMessage = {
id: "a-mid",
role: "assistant",
@@ -287,7 +315,7 @@ describe("MessageBubble", () => {
createdAt: Date.now(),
};
- render();
+ render();
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
});