mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(webui): add copy action to user messages
This commit is contained in:
parent
07f54c25e3
commit
f718c69b2d
@ -43,8 +43,8 @@ import type {
|
|||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
|
/** When false, hide this message's copy button. Default true. */
|
||||||
showAssistantCopyAction?: boolean;
|
showCopyAction?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
onOpenFilePreview?: (path: string) => void;
|
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
|
* 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
|
* "pill" right-aligned with a muted fill; assistant turns render as bare
|
||||||
@ -82,15 +135,13 @@ function ForkArrowIcon({ className }: { className?: string }) {
|
|||||||
*/
|
*/
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
showAssistantCopyAction = true,
|
showCopyAction = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
onOpenFilePreview,
|
onOpenFilePreview,
|
||||||
onForkFromHere,
|
onForkFromHere,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { t } = useTranslation();
|
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 baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
|
||||||
const mentionCliApps = useMemo(
|
const mentionCliApps = useMemo(
|
||||||
() => mergeCliMentionApps(cliApps, message.cliApps),
|
() => mergeCliMentionApps(cliApps, message.cliApps),
|
||||||
@ -101,28 +152,6 @@ export function MessageBubble({
|
|||||||
[mcpPresets, message.mcpPresets],
|
[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") {
|
if (message.kind === "trace") {
|
||||||
return <TraceGroup message={message} animClass={baseAnim} />;
|
return <TraceGroup message={message} animClass={baseAnim} />;
|
||||||
}
|
}
|
||||||
@ -158,6 +187,13 @@ export function MessageBubble({
|
|||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -179,9 +215,8 @@ export function MessageBubble({
|
|||||||
const automationTriggeredLabel = t("message.automationTriggered");
|
const automationTriggeredLabel = t("message.automationTriggered");
|
||||||
|
|
||||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||||
const showCopyButton = showAssistantCopyAction && showAssistantActions;
|
const showCopyButton = showCopyAction && showAssistantActions;
|
||||||
const showForkButton = showAssistantActions && !!onForkFromHere;
|
const showForkButton = showAssistantActions && !!onForkFromHere;
|
||||||
const copyReplyLabel = copied ? t("message.copiedReply") : t("message.copyReply");
|
|
||||||
const forkLabel = t("message.forkFromHere");
|
const forkLabel = t("message.forkFromHere");
|
||||||
const latencyMs = message.latencyMs;
|
const latencyMs = message.latencyMs;
|
||||||
const showLatencyFooter =
|
const showLatencyFooter =
|
||||||
@ -221,27 +256,7 @@ export function MessageBubble({
|
|||||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
<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">
|
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||||
{showCopyButton ? (
|
{showCopyButton ? (
|
||||||
<Tooltip>
|
<MessageCopyButton content={message.content} />
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
{showForkButton ? (
|
{showForkButton ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export function ThreadMessages({
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={unit.message}
|
message={unit.message}
|
||||||
showAssistantCopyAction={
|
showCopyAction={
|
||||||
unit.message.role === "assistant"
|
unit.message.role === "assistant"
|
||||||
? copyFlags[index]
|
? copyFlags[index]
|
||||||
: true
|
: true
|
||||||
|
|||||||
@ -76,9 +76,37 @@ describe("MessageBubble", () => {
|
|||||||
|
|
||||||
expect(row).toHaveClass("ml-auto", "flex");
|
expect(row).toHaveClass("ml-auto", "flex");
|
||||||
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
|
||||||
|
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Fork" })).not.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", () => {
|
it("renders fork control in completed assistant action rows", () => {
|
||||||
const onForkFromHere = vi.fn();
|
const onForkFromHere = vi.fn();
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
@ -279,7 +307,7 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
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 = {
|
const message: UIMessage = {
|
||||||
id: "a-mid",
|
id: "a-mid",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@ -287,7 +315,7 @@ describe("MessageBubble", () => {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<MessageBubble message={message} showAssistantCopyAction={false} />);
|
render(<MessageBubble message={message} showCopyAction={false} />);
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user