Refactor and extend, and pass tests (#2976)

This commit is contained in:
sp.wack
2024-07-17 17:08:05 +03:00
committed by GitHub
parent c897791024
commit 2c02ab9586
4 changed files with 230 additions and 61 deletions

View File

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

View File

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

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

View 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;