From 0b0bfdff0515645cc77c652d99d4f3529b81da01 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:19:14 +0700 Subject: [PATCH] feat(frontend): add the build button to the planner tab (#13235) --- .../conversation-tab-title.test.tsx | 135 ++++++++++++++++++ .../conversation-tab-title.tsx | 37 +++++ 2 files changed, 172 insertions(+) diff --git a/frontend/__tests__/components/conversation-tab-title.test.tsx b/frontend/__tests__/components/conversation-tab-title.test.tsx index 4e3a0aa0fe..e79790ebe4 100644 --- a/frontend/__tests__/components/conversation-tab-title.test.tsx +++ b/frontend/__tests__/components/conversation-tab-title.test.tsx @@ -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(); + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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(); + }); + }); }); diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx index 406b985f33..75dbb23f8e 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx @@ -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 (
{title} @@ -28,6 +47,24 @@ export function ConversationTabTitle({ )} + {conversationKey === "planner" && ( + + )}
); }