From df47b7b79dd85dd21a0b6afb1a03748ec98fa161 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:12:09 +0700 Subject: [PATCH] feat(frontend): ensure the planner tab opens when the view button is selected (#12621) --- .../features/chat/plan-preview.test.tsx | 111 +++++--- .../conversation/conversation-tabs.test.tsx | 106 +++++++- .../hooks/use-select-conversation-tab.test.ts | 238 ++++++++++++++++++ .../components/features/chat/plan-preview.tsx | 19 +- .../conversation-tab-nav.tsx | 3 + .../conversation-tabs/conversation-tabs.tsx | 64 ++--- .../src/hooks/use-select-conversation-tab.ts | 64 +++++ 7 files changed, 517 insertions(+), 88 deletions(-) create mode 100644 frontend/__tests__/hooks/use-select-conversation-tab.test.ts create mode 100644 frontend/src/hooks/use-select-conversation-tab.ts diff --git a/frontend/__tests__/components/features/chat/plan-preview.test.tsx b/frontend/__tests__/components/features/chat/plan-preview.test.tsx index 2d9989050f..3a6a536c0c 100644 --- a/frontend/__tests__/components/features/chat/plan-preview.test.tsx +++ b/frontend/__tests__/components/features/chat/plan-preview.test.tsx @@ -1,8 +1,9 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { PlanPreview } from "#/components/features/chat/plan-preview"; +import { useConversationStore } from "#/stores/conversation-store"; // Mock the feature flag to always return true (not testing feature flag behavior) vi.mock("#/utils/feature-flags", () => ({ @@ -20,13 +21,24 @@ vi.mock("react-i18next", async (importOriginal) => { }; }); +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ conversationId: "test-conversation-id" }), +})); + describe("PlanPreview", () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); }); afterEach(() => { vi.clearAllMocks(); + localStorage.clear(); }); it("should render nothing when planContent is null", () => { @@ -83,39 +95,6 @@ describe("PlanPreview", () => { expect(container.textContent).toContain("COMMON$READ_MORE"); }); - it("should call onViewClick when View button is clicked", async () => { - const user = userEvent.setup(); - const onViewClick = vi.fn(); - - renderWithProviders( - , - ); - - const viewButton = screen.getByTestId("plan-preview-view-button"); - expect(viewButton).toBeInTheDocument(); - - await user.click(viewButton); - - expect(onViewClick).toHaveBeenCalledTimes(1); - }); - - it("should call onViewClick when Read More button is clicked", async () => { - const user = userEvent.setup(); - const onViewClick = vi.fn(); - const longContent = "A".repeat(350); - - renderWithProviders( - , - ); - - const readMoreButton = screen.getByTestId("plan-preview-read-more-button"); - expect(readMoreButton).toBeInTheDocument(); - - await user.click(readMoreButton); - - expect(onViewClick).toHaveBeenCalledTimes(1); - }); - it("should call onBuildClick when Build button is clicked", async () => { const user = userEvent.setup(); const onBuildClick = vi.fn(); @@ -224,4 +203,68 @@ describe("PlanPreview", () => { expect(container.querySelector("h5")).toBeInTheDocument(); expect(container.querySelector("h6")).toBeInTheDocument(); }); + + it("should call selectTab with 'planner' when View button is clicked", async () => { + const user = userEvent.setup(); + const planContent = "Plan content"; + const conversationId = "test-conversation-id"; + + // Arrange: Set up initial state + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + + renderWithProviders(); + + // Act: Click the View button + const viewButton = screen.getByTestId("plan-preview-view-button"); + await user.click(viewButton); + + // Assert: Verify selectTab was called with 'planner' and panel was opened + // The hook sets hasRightPanelToggled, which should trigger isRightPanelShown update + // In tests, we need to manually sync or check hasRightPanelToggled + expect(useConversationStore.getState().selectedTab).toBe("planner"); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem(`conversation-state-${conversationId}`)!, + ); + expect(storedState.selectedTab).toBe("planner"); + expect(storedState.rightPanelShown).toBe(true); + }); + + it("should call selectTab with 'planner' when Read more button is clicked", async () => { + const user = userEvent.setup(); + const longContent = "A".repeat(350); + const conversationId = "test-conversation-id"; + + // Arrange: Set up initial state + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + + renderWithProviders(); + + // Act: Click the Read more button + const readMoreButton = screen.getByTestId("plan-preview-read-more-button"); + await user.click(readMoreButton); + + // Assert: Verify selectTab was called with 'planner' and panel was opened + // The hook sets hasRightPanelToggled, which should trigger isRightPanelShown update + // In tests, we need to manually sync or check hasRightPanelToggled + expect(useConversationStore.getState().selectedTab).toBe("planner"); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem(`conversation-state-${conversationId}`)!, + ); + expect(storedState.selectedTab).toBe("planner"); + expect(storedState.rightPanelShown).toBe(true); + }); }); diff --git a/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx b/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx index 58af4ff491..447d282bd8 100644 --- a/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx +++ b/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router"; import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs"; import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu"; +import { useConversationStore } from "#/stores/conversation-store"; const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd"; const REAL_CONVERSATION_ID = "conv-abc123"; @@ -34,6 +35,11 @@ describe("ConversationTabs localStorage behavior", () => { localStorage.clear(); vi.resetAllMocks(); mockConversationId = TASK_CONVERSATION_ID; + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); }); describe("task-prefixed conversation IDs", () => { @@ -57,7 +63,7 @@ describe("ConversationTabs localStorage behavior", () => { wrapper: createWrapper(REAL_CONVERSATION_ID), }); - const changesTab = screen.getByText("COMMON$CHANGES"); + const changesTab = screen.getByTestId("conversation-tab-editor"); await user.click(changesTab); const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`; @@ -87,4 +93,102 @@ describe("ConversationTabs localStorage behavior", () => { expect(parsed.unpinnedTabs).toContain("terminal"); }); }); + + describe("hook integration", () => { + it("should open panel and select tab when clicking a tab while panel is closed", async () => { + mockConversationId = REAL_CONVERSATION_ID; + const user = userEvent.setup(); + + // Arrange: Panel is closed, no tab selected + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + + render(, { + wrapper: createWrapper(REAL_CONVERSATION_ID), + }); + + // Act: Click the terminal tab + const terminalTab = screen.getByTestId("conversation-tab-terminal"); + await user.click(terminalTab); + + // Assert: Panel should be open and terminal tab selected + expect(useConversationStore.getState().selectedTab).toBe("terminal"); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${REAL_CONVERSATION_ID}`, + )!, + ); + expect(storedState.selectedTab).toBe("terminal"); + expect(storedState.rightPanelShown).toBe(true); + }); + + it("should close panel when clicking the same active tab", async () => { + mockConversationId = REAL_CONVERSATION_ID; + const user = userEvent.setup(); + + // Arrange: Panel is open with editor tab selected + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + render(, { + wrapper: createWrapper(REAL_CONVERSATION_ID), + }); + + // Act: Click the editor tab again + const editorTab = screen.getByTestId("conversation-tab-editor"); + await user.click(editorTab); + + // Assert: Panel should be closed + expect(useConversationStore.getState().hasRightPanelToggled).toBe(false); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${REAL_CONVERSATION_ID}`, + )!, + ); + expect(storedState.rightPanelShown).toBe(false); + }); + + it("should switch to different tab when clicking another tab while panel is open", async () => { + mockConversationId = REAL_CONVERSATION_ID; + const user = userEvent.setup(); + + // Arrange: Panel is open with editor tab selected + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + render(, { + wrapper: createWrapper(REAL_CONVERSATION_ID), + }); + + // Act: Click the browser tab + const browserTab = screen.getByTestId("conversation-tab-browser"); + await user.click(browserTab); + + // Assert: Browser tab should be selected, panel still open + expect(useConversationStore.getState().selectedTab).toBe("browser"); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${REAL_CONVERSATION_ID}`, + )!, + ); + expect(storedState.selectedTab).toBe("browser"); + }); + }); }); diff --git a/frontend/__tests__/hooks/use-select-conversation-tab.test.ts b/frontend/__tests__/hooks/use-select-conversation-tab.test.ts new file mode 100644 index 0000000000..d1857d511a --- /dev/null +++ b/frontend/__tests__/hooks/use-select-conversation-tab.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab"; +import { useConversationStore } from "#/stores/conversation-store"; + +const TEST_CONVERSATION_ID = "test-conversation-id"; + +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ conversationId: TEST_CONVERSATION_ID }), +})); + +describe("useSelectConversationTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + }); + + describe("selectTab", () => { + it("should open panel and select tab when panel is closed", () => { + // Arrange: Panel is closed + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Act: Select a tab + act(() => { + result.current.selectTab("editor"); + }); + + // Assert: Panel should be open and tab selected + expect(useConversationStore.getState().selectedTab).toBe("editor"); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${TEST_CONVERSATION_ID}`, + )!, + ); + expect(storedState.selectedTab).toBe("editor"); + expect(storedState.rightPanelShown).toBe(true); + }); + + it("should close panel when clicking the same active tab", () => { + // Arrange: Panel is open with editor tab selected + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Act: Click the same tab again + act(() => { + result.current.selectTab("editor"); + }); + + // Assert: Panel should be closed + expect(useConversationStore.getState().hasRightPanelToggled).toBe(false); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${TEST_CONVERSATION_ID}`, + )!, + ); + expect(storedState.rightPanelShown).toBe(false); + }); + + it("should switch to different tab when panel is already open", () => { + // Arrange: Panel is open with editor tab selected + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Act: Select a different tab + act(() => { + result.current.selectTab("terminal"); + }); + + // Assert: New tab should be selected, panel still open + expect(useConversationStore.getState().selectedTab).toBe("terminal"); + expect(useConversationStore.getState().isRightPanelShown).toBe(true); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${TEST_CONVERSATION_ID}`, + )!, + ); + expect(storedState.selectedTab).toBe("terminal"); + }); + }); + + describe("isTabActive", () => { + it("should return true when tab is selected and panel is visible", () => { + // Arrange: Panel is open with editor tab selected + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Assert: Editor tab should be active + expect(result.current.isTabActive("editor")).toBe(true); + }); + + it("should return false when tab is selected but panel is not visible", () => { + // Arrange: Editor tab selected but panel is closed + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Assert: Editor tab should not be active + expect(result.current.isTabActive("editor")).toBe(false); + }); + + it("should return false when different tab is selected", () => { + // Arrange: Panel is open with editor tab selected + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Assert: Terminal tab should not be active + expect(result.current.isTabActive("terminal")).toBe(false); + }); + }); + + describe("onTabChange", () => { + it("should update both Zustand store and localStorage when changing tab", () => { + // Arrange + useConversationStore.setState({ + selectedTab: null, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Act: Change tab + act(() => { + result.current.onTabChange("browser"); + }); + + // Assert: Both store and localStorage should be updated + expect(useConversationStore.getState().selectedTab).toBe("browser"); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${TEST_CONVERSATION_ID}`, + )!, + ); + expect(storedState.selectedTab).toBe("browser"); + }); + + it("should set tab to null when passing null", () => { + // Arrange + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Act: Set tab to null + act(() => { + result.current.onTabChange(null); + }); + + // Assert: Tab should be null + expect(useConversationStore.getState().selectedTab).toBe(null); + + // Verify localStorage was updated + const storedState = JSON.parse( + localStorage.getItem( + `conversation-state-${TEST_CONVERSATION_ID}`, + )!, + ); + expect(storedState.selectedTab).toBe(null); + }); + }); + + describe("returned values", () => { + it("should return current selectedTab from store", () => { + // Arrange + useConversationStore.setState({ + selectedTab: "vscode", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Assert: Should return current selectedTab + expect(result.current.selectedTab).toBe("vscode"); + }); + + it("should return current isRightPanelShown from store", () => { + // Arrange + useConversationStore.setState({ + selectedTab: "editor", + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + // Assert: Should return current panel state + expect(result.current.isRightPanelShown).toBe(true); + }); + }); +}); diff --git a/frontend/src/components/features/chat/plan-preview.tsx b/frontend/src/components/features/chat/plan-preview.tsx index a38cf2ae9c..a309ba4881 100644 --- a/frontend/src/components/features/chat/plan-preview.tsx +++ b/frontend/src/components/features/chat/plan-preview.tsx @@ -6,6 +6,7 @@ import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; import { Typography } from "#/ui/typography"; import { I18nKey } from "#/i18n/declaration"; import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; +import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab"; import { planHeadings } from "#/components/features/markdown/plan-headings"; const MAX_CONTENT_LENGTH = 300; @@ -13,20 +14,20 @@ const MAX_CONTENT_LENGTH = 300; interface PlanPreviewProps { /** Raw plan content from PLAN.md file */ planContent?: string | null; - onViewClick?: () => void; onBuildClick?: () => void; } /* eslint-disable i18next/no-literal-string */ -export function PlanPreview({ - planContent, - onViewClick, - onBuildClick, -}: PlanPreviewProps) { +export function PlanPreview({ planContent, onBuildClick }: PlanPreviewProps) { const { t } = useTranslation(); + const { selectTab } = useSelectConversationTab(); const shouldUsePlanningAgent = USE_PLANNING_AGENT(); + const handleViewClick = () => { + selectTab("planner"); + }; + // Truncate plan content for preview const truncatedContent = useMemo(() => { if (!planContent) return ""; @@ -49,8 +50,8 @@ export function PlanPreview({