feat(webui): add copy action to user messages

This commit is contained in:
chengyongru
2026-07-14 22:41:17 +08:00
committed by chengyongru
parent 07f54c25e3
commit f718c69b2d
3 changed files with 96 additions and 53 deletions
+65 -50
View File
@@ -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<number | null>(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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onCopy}
aria-label={label}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{label}</TooltipContent>
</Tooltip>
);
}
/**
* 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<number | null>(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 <TraceGroup message={message} animClass={baseAnim} />;
}
@@ -158,6 +187,13 @@ export function MessageBubble({
/>
</p>
) : null}
{hasText && showCopyAction ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div className="flex min-h-8 items-center justify-end text-muted-foreground">
<MessageCopyButton content={message.content} />
</div>
</TooltipProvider>
) : null}
</div>
);
}
@@ -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({
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copyReplyLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" align="center">{copyReplyLabel}</TooltipContent>
</Tooltip>
<MessageCopyButton content={message.content} />
) : null}
{showForkButton ? (
<Tooltip>
@@ -109,7 +109,7 @@ export function ThreadMessages({
) : (
<MessageBubble
message={unit.message}
showAssistantCopyAction={
showCopyAction={
unit.message.role === "assistant"
? copyFlags[index]
: true
+30 -2
View File
@@ -76,9 +76,37 @@ describe("MessageBubble", () => {
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(<MessageBubble message={message} />);
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(<MessageBubble message={message} showAssistantCopyAction={false} />);
render(<MessageBubble message={message} showCopyAction={false} />);
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
});