mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): ensure the planner tab opens when the view button is selected (#12621)
This commit is contained in:
@@ -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(
|
||||
<PlanPreview planContent="Plan content" onViewClick={onViewClick} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanPreview planContent={longContent} onViewClick={onViewClick} />,
|
||||
);
|
||||
|
||||
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(<PlanPreview planContent={planContent} />);
|
||||
|
||||
// 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(<PlanPreview planContent={longContent} />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<ConversationTabs />, {
|
||||
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(<ConversationTabs />, {
|
||||
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(<ConversationTabs />, {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
238
frontend/__tests__/hooks/use-select-conversation-tab.test.ts
Normal file
238
frontend/__tests__/hooks/use-select-conversation-tab.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewClick}
|
||||
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
onClick={handleViewClick}
|
||||
className="flex items-center gap-1 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
data-testid="plan-preview-view-button"
|
||||
>
|
||||
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
|
||||
@@ -73,7 +74,7 @@ export function PlanPreview({
|
||||
{planContent && planContent.length > MAX_CONTENT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewClick}
|
||||
onClick={handleViewClick}
|
||||
className="text-[#4a67bd] cursor-pointer hover:underline text-left"
|
||||
data-testid="plan-preview-read-more-button"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ComponentType } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
type ConversationTabNavProps = {
|
||||
tabValue: string;
|
||||
icon: ComponentType<{ className: string }>;
|
||||
onClick(): void;
|
||||
isActive?: boolean;
|
||||
@@ -10,6 +11,7 @@ type ConversationTabNavProps = {
|
||||
};
|
||||
|
||||
export function ConversationTabNav({
|
||||
tabValue,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
isActive,
|
||||
@@ -22,6 +24,7 @@ export function ConversationTabNav({
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
data-testid={`conversation-tab-${tabValue}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md cursor-pointer",
|
||||
"pl-1.5 pr-2 py-1",
|
||||
|
||||
@@ -13,38 +13,30 @@ import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { VSCodeTooltipContent } from "./vscode-tooltip-content";
|
||||
import {
|
||||
useConversationStore,
|
||||
type ConversationTab,
|
||||
} from "#/stores/conversation-store";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const { conversationId } = useConversationId();
|
||||
const {
|
||||
selectedTab,
|
||||
isRightPanelShown,
|
||||
setHasRightPanelToggled,
|
||||
setSelectedTab,
|
||||
} = useConversationStore();
|
||||
const { setHasRightPanelToggled, setSelectedTab } = useConversationStore();
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const {
|
||||
state: persistedState,
|
||||
setSelectedTab: setPersistedSelectedTab,
|
||||
setRightPanelShown: setPersistedRightPanelShown,
|
||||
} = useConversationLocalStorageState(conversationId);
|
||||
const { state: persistedState } =
|
||||
useConversationLocalStorageState(conversationId);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const onTabChange = (value: ConversationTab | null) => {
|
||||
setSelectedTab(value);
|
||||
// Persist the selected tab to localStorage
|
||||
setPersistedSelectedTab(value);
|
||||
};
|
||||
const {
|
||||
selectTab,
|
||||
isTabActive,
|
||||
onTabChange,
|
||||
selectedTab,
|
||||
isRightPanelShown,
|
||||
} = useSelectConversationTab();
|
||||
|
||||
// Initialize Zustand state from localStorage on component mount
|
||||
useEffect(() => {
|
||||
@@ -73,30 +65,12 @@ export function ConversationTabs() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onTabSelected = (tab: ConversationTab) => {
|
||||
if (selectedTab === tab && isRightPanelShown) {
|
||||
// If clicking the same active tab, close the drawer
|
||||
setHasRightPanelToggled(false);
|
||||
setPersistedRightPanelShown(false);
|
||||
} else {
|
||||
// If clicking a different tab or drawer is closed, open drawer and select tab
|
||||
onTabChange(tab);
|
||||
if (!isRightPanelShown) {
|
||||
setHasRightPanelToggled(true);
|
||||
setPersistedRightPanelShown(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isTabActive = (tab: ConversationTab) =>
|
||||
isRightPanelShown && selectedTab === tab;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
tabValue: "editor",
|
||||
isActive: isTabActive("editor"),
|
||||
icon: GitChanges,
|
||||
onClick: () => onTabSelected("editor"),
|
||||
onClick: () => selectTab("editor"),
|
||||
tooltipContent: t(I18nKey.COMMON$CHANGES),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$CHANGES),
|
||||
label: t(I18nKey.COMMON$CHANGES),
|
||||
@@ -105,7 +79,7 @@ export function ConversationTabs() {
|
||||
tabValue: "vscode",
|
||||
isActive: isTabActive("vscode"),
|
||||
icon: VSCodeIcon,
|
||||
onClick: () => onTabSelected("vscode"),
|
||||
onClick: () => selectTab("vscode"),
|
||||
tooltipContent: <VSCodeTooltipContent />,
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$CODE),
|
||||
label: t(I18nKey.COMMON$CODE),
|
||||
@@ -114,7 +88,7 @@ export function ConversationTabs() {
|
||||
tabValue: "terminal",
|
||||
isActive: isTabActive("terminal"),
|
||||
icon: TerminalIcon,
|
||||
onClick: () => onTabSelected("terminal"),
|
||||
onClick: () => selectTab("terminal"),
|
||||
tooltipContent: t(I18nKey.COMMON$TERMINAL),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
|
||||
label: t(I18nKey.COMMON$TERMINAL),
|
||||
@@ -124,7 +98,7 @@ export function ConversationTabs() {
|
||||
tabValue: "served",
|
||||
isActive: isTabActive("served"),
|
||||
icon: ServerIcon,
|
||||
onClick: () => onTabSelected("served"),
|
||||
onClick: () => selectTab("served"),
|
||||
tooltipContent: t(I18nKey.COMMON$APP),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$APP),
|
||||
label: t(I18nKey.COMMON$APP),
|
||||
@@ -133,7 +107,7 @@ export function ConversationTabs() {
|
||||
tabValue: "browser",
|
||||
isActive: isTabActive("browser"),
|
||||
icon: GlobeIcon,
|
||||
onClick: () => onTabSelected("browser"),
|
||||
onClick: () => selectTab("browser"),
|
||||
tooltipContent: t(I18nKey.COMMON$BROWSER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
|
||||
label: t(I18nKey.COMMON$BROWSER),
|
||||
@@ -145,7 +119,7 @@ export function ConversationTabs() {
|
||||
tabValue: "planner",
|
||||
isActive: isTabActive("planner"),
|
||||
icon: LessonPlanIcon,
|
||||
onClick: () => onTabSelected("planner"),
|
||||
onClick: () => selectTab("planner"),
|
||||
tooltipContent: t(I18nKey.COMMON$PLANNER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$PLANNER),
|
||||
label: t(I18nKey.COMMON$PLANNER),
|
||||
@@ -167,6 +141,7 @@ export function ConversationTabs() {
|
||||
{visibleTabs.map(
|
||||
(
|
||||
{
|
||||
tabValue,
|
||||
icon,
|
||||
onClick,
|
||||
isActive,
|
||||
@@ -183,6 +158,7 @@ export function ConversationTabs() {
|
||||
ariaLabel={tooltipAriaLabel}
|
||||
>
|
||||
<ConversationTabNav
|
||||
tabValue={tabValue}
|
||||
icon={icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
|
||||
64
frontend/src/hooks/use-select-conversation-tab.ts
Normal file
64
frontend/src/hooks/use-select-conversation-tab.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
|
||||
import {
|
||||
useConversationStore,
|
||||
type ConversationTab,
|
||||
} from "#/stores/conversation-store";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
/**
|
||||
* Custom hook for selecting conversation tabs with consistent behavior.
|
||||
* Handles panel visibility, state persistence, and tab toggling logic.
|
||||
*/
|
||||
export function useSelectConversationTab() {
|
||||
const { conversationId } = useConversationId();
|
||||
const {
|
||||
selectedTab,
|
||||
isRightPanelShown,
|
||||
setHasRightPanelToggled,
|
||||
setSelectedTab,
|
||||
} = useConversationStore();
|
||||
|
||||
const {
|
||||
setSelectedTab: setPersistedSelectedTab,
|
||||
setRightPanelShown: setPersistedRightPanelShown,
|
||||
} = useConversationLocalStorageState(conversationId);
|
||||
|
||||
const onTabChange = (value: ConversationTab | null) => {
|
||||
setSelectedTab(value);
|
||||
setPersistedSelectedTab(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects a tab with proper panel visibility handling.
|
||||
* - If clicking the same active tab while panel is open, closes the panel
|
||||
* - If clicking a different tab or panel is closed, opens panel and selects tab
|
||||
*/
|
||||
const selectTab = (tab: ConversationTab) => {
|
||||
if (selectedTab === tab && isRightPanelShown) {
|
||||
// If clicking the same active tab, close the drawer
|
||||
setHasRightPanelToggled(false);
|
||||
setPersistedRightPanelShown(false);
|
||||
} else {
|
||||
// If clicking a different tab or drawer is closed, open drawer and select tab
|
||||
onTabChange(tab);
|
||||
if (!isRightPanelShown) {
|
||||
setHasRightPanelToggled(true);
|
||||
setPersistedRightPanelShown(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a specific tab is currently active (selected and panel is visible).
|
||||
*/
|
||||
const isTabActive = (tab: ConversationTab) =>
|
||||
isRightPanelShown && selectedTab === tab;
|
||||
|
||||
return {
|
||||
selectTab,
|
||||
isTabActive,
|
||||
onTabChange,
|
||||
selectedTab,
|
||||
isRightPanelShown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user