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(); });