mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Refactor and extend, and pass tests (#2976)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import toast from "#/utils/toast";
|
||||
|
||||
describe("Message", () => {
|
||||
it("should render a user message", () => {
|
||||
@@ -44,4 +46,114 @@ describe("Message", () => {
|
||||
expect(screen.getByText("log")).toBeInTheDocument();
|
||||
expect(screen.getByText("'Hello'")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("copy to clipboard", () => {
|
||||
const toastInfoSpy = vi.spyOn(toast, "info");
|
||||
const toastErrorSpy = vi.spyOn(toast, "error");
|
||||
|
||||
it("should copy any message to clipboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChatMessage
|
||||
message={{ sender: "user", content: "Hello" }}
|
||||
isLastMessage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const message = screen.getByTestId("message");
|
||||
let copyButton = within(message).queryByTestId("copy-button");
|
||||
expect(copyButton).not.toBeInTheDocument();
|
||||
|
||||
// I am using `fireEvent` here because `userEvent.hover()` seems to interfere with the
|
||||
// `userEvent.click()` call later on
|
||||
fireEvent.mouseEnter(message);
|
||||
|
||||
copyButton = within(message).getByTestId("copy-button");
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.readText()).resolves.toBe("Hello");
|
||||
expect(toastInfoSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show an error message when the message cannot be copied", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChatMessage
|
||||
message={{ sender: "user", content: "Hello" }}
|
||||
isLastMessage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const message = screen.getByTestId("message");
|
||||
fireEvent.mouseEnter(message);
|
||||
|
||||
const copyButton = within(message).getByTestId("copy-button");
|
||||
const clipboardSpy = vi
|
||||
.spyOn(navigator.clipboard, "writeText")
|
||||
.mockRejectedValue(new Error("Failed to copy"));
|
||||
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(clipboardSpy).toHaveBeenCalled();
|
||||
expect(toastErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmation buttons", () => {
|
||||
const expectButtonsNotToBeRendered = () => {
|
||||
expect(
|
||||
screen.queryByTestId("action-confirm-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("action-reject-button"),
|
||||
).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
it("should display confirmation buttons for the last assistant message", () => {
|
||||
// it should not render buttons if the message is not the last one
|
||||
const { rerender } = render(
|
||||
<ChatMessage
|
||||
message={{ sender: "assistant", content: "Are you sure?" }}
|
||||
isLastMessage={false}
|
||||
awaitingUserConfirmation
|
||||
/>,
|
||||
);
|
||||
expectButtonsNotToBeRendered();
|
||||
|
||||
// it should not render buttons if the message is not from the assistant
|
||||
rerender(
|
||||
<ChatMessage
|
||||
message={{ sender: "user", content: "Yes" }}
|
||||
isLastMessage
|
||||
awaitingUserConfirmation
|
||||
/>,
|
||||
);
|
||||
expectButtonsNotToBeRendered();
|
||||
|
||||
// it should not render buttons if the message is not awaiting user confirmation
|
||||
rerender(
|
||||
<ChatMessage
|
||||
message={{ sender: "assistant", content: "Are you sure?" }}
|
||||
isLastMessage
|
||||
awaitingUserConfirmation={false}
|
||||
/>,
|
||||
);
|
||||
expectButtonsNotToBeRendered();
|
||||
|
||||
// it should render buttons if all conditions are met
|
||||
rerender(
|
||||
<ChatMessage
|
||||
message={{ sender: "assistant", content: "Are you sure?" }}
|
||||
isLastMessage
|
||||
awaitingUserConfirmation
|
||||
/>,
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByTestId("action-confirm-button");
|
||||
const rejectButton = screen.getByTestId("action-reject-button");
|
||||
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
expect(rejectButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,10 @@ import Markdown from "react-markdown";
|
||||
import { FaClipboard, FaClipboardCheck } from "react-icons/fa";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { code } from "../markdown/code";
|
||||
import toast from "#/utils/toast";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import ConfirmIcon from "#/assets/confirm";
|
||||
import RejectIcon from "#/assets/reject";
|
||||
import { changeAgentState } from "#/services/agentStateService";
|
||||
import ConfirmationButtons from "./ConfirmationButtons";
|
||||
|
||||
interface MessageProps {
|
||||
message: Message;
|
||||
@@ -23,32 +19,43 @@ function ChatMessage({
|
||||
isLastMessage,
|
||||
awaitingUserConfirmation,
|
||||
}: MessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isCopy, setIsCopy] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
if (isCopy) {
|
||||
timeout = setTimeout(() => {
|
||||
setIsCopy(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isCopy]);
|
||||
|
||||
const className = twMerge(
|
||||
"markdown-body",
|
||||
"p-3 text-white max-w-[90%] overflow-y-auto rounded-lg relative",
|
||||
message.sender === "user" ? "bg-neutral-700 self-end" : "bg-neutral-500",
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(message.content)
|
||||
.then(() => {
|
||||
setIsCopy(true);
|
||||
setTimeout(() => {
|
||||
setIsCopy(false);
|
||||
}, 1500);
|
||||
toast.info(t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPIED));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"copy-error",
|
||||
t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED),
|
||||
);
|
||||
});
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setIsCopy(true);
|
||||
|
||||
toast.info(t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPIED));
|
||||
} catch {
|
||||
toast.error(
|
||||
"copy-error",
|
||||
t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -60,6 +67,7 @@ function ChatMessage({
|
||||
>
|
||||
{isHovering && (
|
||||
<button
|
||||
data-testid="copy-button"
|
||||
onClick={copyToClipboard}
|
||||
className="absolute top-1 right-1 p-1 bg-neutral-600 rounded hover:bg-neutral-700"
|
||||
aria-label={t(I18nKey.CHAT_INTERFACE$TOOLTIP_COPY_MESSAGE)}
|
||||
@@ -71,43 +79,7 @@ function ChatMessage({
|
||||
<Markdown components={{ code }}>{message.content}</Markdown>
|
||||
{isLastMessage &&
|
||||
message.sender === "assistant" &&
|
||||
awaitingUserConfirmation && (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<p>{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip
|
||||
content={t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)}
|
||||
closeDelay={100}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm action"
|
||||
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
|
||||
onClick={() => {
|
||||
changeAgentState(AgentState.USER_CONFIRMED);
|
||||
}}
|
||||
>
|
||||
<ConfirmIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t(I18nKey.CHAT_INTERFACE$USER_REJECTED)}
|
||||
closeDelay={100}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reject action"
|
||||
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
|
||||
onClick={() => {
|
||||
changeAgentState(AgentState.USER_REJECTED);
|
||||
}}
|
||||
>
|
||||
<RejectIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
awaitingUserConfirmation && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
27
frontend/src/components/chat/ConfirmationButtons.test.tsx
Normal file
27
frontend/src/components/chat/ConfirmationButtons.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe } from "vitest";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ConfirmationButtons from "./ConfirmationButtons";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { changeAgentState } from "#/services/agentStateService";
|
||||
|
||||
describe("ConfirmationButtons", () => {
|
||||
vi.mock("#/services/agentStateService", () => ({
|
||||
changeAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
it("should change agent state appropriately on button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmationButtons />);
|
||||
|
||||
const confirmButton = screen.getByTestId("action-confirm-button");
|
||||
const rejectButton = screen.getByTestId("action-reject-button");
|
||||
|
||||
await user.click(confirmButton);
|
||||
expect(changeAgentState).toHaveBeenCalledWith(AgentState.USER_CONFIRMED);
|
||||
|
||||
await user.click(rejectButton);
|
||||
expect(changeAgentState).toHaveBeenCalledWith(AgentState.USER_REJECTED);
|
||||
});
|
||||
});
|
||||
58
frontend/src/components/chat/ConfirmationButtons.tsx
Normal file
58
frontend/src/components/chat/ConfirmationButtons.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import ConfirmIcon from "#/assets/confirm";
|
||||
import RejectIcon from "#/assets/reject";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import { changeAgentState } from "#/services/agentStateService";
|
||||
|
||||
interface ActionTooltipProps {
|
||||
type: "confirm" | "reject";
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ActionTooltip({ type, onClick }: ActionTooltipProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const content =
|
||||
type === "confirm"
|
||||
? t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)
|
||||
: t(I18nKey.CHAT_INTERFACE$USER_REJECTED);
|
||||
|
||||
return (
|
||||
<Tooltip content={content} closeDelay={100}>
|
||||
<button
|
||||
data-testid={`action-${type}-button`}
|
||||
type="button"
|
||||
aria-label={type === "confirm" ? "Confirm action" : "Reject action"}
|
||||
className="bg-neutral-700 rounded-full p-1 hover:bg-neutral-800"
|
||||
onClick={onClick}
|
||||
>
|
||||
{type === "confirm" ? <ConfirmIcon /> : <RejectIcon />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationButtons() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<p>{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionTooltip
|
||||
type="confirm"
|
||||
onClick={() => changeAgentState(AgentState.USER_CONFIRMED)}
|
||||
/>
|
||||
<ActionTooltip
|
||||
type="reject"
|
||||
onClick={() => changeAgentState(AgentState.USER_REJECTED)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmationButtons;
|
||||
Reference in New Issue
Block a user