mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(frontend): add the build button to the planner tab (#13235)
This commit is contained in:
@@ -5,11 +5,43 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConversationTabTitle } from "#/components/features/conversation/conversation-tabs/conversation-tab-title";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
|
||||
// Mock the services that the hook depends on
|
||||
vi.mock("#/api/git-service/git-service.api");
|
||||
vi.mock("#/api/git-service/v1-git-service.api");
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock services for Build button
|
||||
const mockSend = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-send-message", () => ({
|
||||
useSendMessage: vi.fn(() => ({
|
||||
send: mockSend,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("#/services/chat-service", () => ({
|
||||
createChatMessage: vi.fn((content, imageUrls, fileUrls, timestamp) => ({
|
||||
action: "message",
|
||||
args: { content, image_urls: imageUrls, file_urls: fileUrls, timestamp },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the hooks that useUnifiedGetGitChanges depends on
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({
|
||||
@@ -51,11 +83,24 @@ describe("ConversationTabTitle", () => {
|
||||
// Mock GitService methods
|
||||
vi.mocked(GitService.getGitChanges).mockResolvedValue([]);
|
||||
vi.mocked(V1GitService.getGitChanges).mockResolvedValue([]);
|
||||
|
||||
// Reset stores for Build button tests
|
||||
useConversationStore.setState({
|
||||
planContent: null,
|
||||
conversationMode: "plan",
|
||||
});
|
||||
useAgentStore.setState({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
useOptimisticUserMessageStore.setState({
|
||||
optimisticUserMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
@@ -146,4 +191,94 @@ describe("ConversationTabTitle", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Build Button", () => {
|
||||
it("should show Build button when conversationKey is 'planner' and planContent exists", () => {
|
||||
// Arrange
|
||||
useConversationStore.setState({ planContent: "# Plan content" });
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title="Planner" conversationKey="planner" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const buildButton = screen.getByTestId("planner-tab-build-button");
|
||||
expect(buildButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Build button when conversationKey is not 'planner'", () => {
|
||||
// Arrange
|
||||
useConversationStore.setState({ planContent: "# Plan content" });
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title="Browser" conversationKey="browser" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByTestId("planner-tab-build-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable Build button when no planContent exists", () => {
|
||||
// Arrange
|
||||
useConversationStore.setState({ planContent: null });
|
||||
useAgentStore.setState({ curAgentState: AgentState.AWAITING_USER_INPUT });
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title="Planner" conversationKey="planner" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const buildButton = screen.getByTestId("planner-tab-build-button");
|
||||
expect(buildButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable Build button when agent is running", () => {
|
||||
// Arrange
|
||||
useConversationStore.setState({ planContent: "# Plan content" });
|
||||
useAgentStore.setState({ curAgentState: AgentState.RUNNING });
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title="Planner" conversationKey="planner" />,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const buildButton = screen.getByTestId("planner-tab-build-button");
|
||||
expect(buildButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should switch to code mode and send message when Build button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
useConversationStore.setState({
|
||||
planContent: "# Plan content",
|
||||
conversationMode: "plan",
|
||||
});
|
||||
useAgentStore.setState({ curAgentState: AgentState.AWAITING_USER_INPUT });
|
||||
|
||||
renderWithProviders(
|
||||
<ConversationTabTitle title="Planner" conversationKey="planner" />,
|
||||
);
|
||||
|
||||
const buildButton = screen.getByTestId("planner-tab-build-button");
|
||||
|
||||
// Act
|
||||
await user.click(buildButton);
|
||||
|
||||
// Assert
|
||||
expect(useConversationStore.getState().conversationMode).toBe("code");
|
||||
expect(createChatMessage).toHaveBeenCalledWith(
|
||||
"Execute the plan based on the .agents_tmp/PLAN.md file.",
|
||||
[],
|
||||
[],
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RefreshIcon from "#/icons/u-refresh.svg?react";
|
||||
import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
|
||||
import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
type ConversationTabTitleProps = {
|
||||
title: string;
|
||||
conversationKey: string;
|
||||
};
|
||||
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
export function ConversationTabTitle({
|
||||
title,
|
||||
conversationKey,
|
||||
}: ConversationTabTitleProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch } = useUnifiedGetGitChanges();
|
||||
const { handleBuildPlanClick } = useHandleBuildPlanClick();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { planContent } = useConversationStore();
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
// Determine if Build button should be disabled
|
||||
const isAgentRunning =
|
||||
curAgentState === AgentState.RUNNING ||
|
||||
curAgentState === AgentState.LOADING;
|
||||
const isBuildDisabled = isAgentRunning || !planContent;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between border-b border-[#474A54] py-2 px-3">
|
||||
<span className="text-xs font-medium text-white">{title}</span>
|
||||
@@ -28,6 +47,24 @@ export function ConversationTabTitle({
|
||||
<RefreshIcon width={12.75} height={15} color="#ffffff" />
|
||||
</button>
|
||||
)}
|
||||
{conversationKey === "planner" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBuildPlanClick}
|
||||
disabled={isBuildDisabled}
|
||||
className={cn(
|
||||
"flex items-center justify-center h-5 min-w-17 px-2 rounded bg-white transition-opacity",
|
||||
isBuildDisabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:opacity-90 cursor-pointer",
|
||||
)}
|
||||
data-testid="planner-tab-build-button"
|
||||
>
|
||||
<Typography.Text className="text-black text-[11px] font-medium leading-5">
|
||||
{t(I18nKey.COMMON$BUILD)} ⌘↩
|
||||
</Typography.Text>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user