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({