mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Co-authored-by: mamoodi <mamoodiha@gmail.com> Co-authored-by: hieptl <hieptl.developer@gmail.com> Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
d7218925c4
commit
9d405243b8
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
|
||||
function renderWithRouter(conversationId: string, onClose: () => void) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/conversations/:conversationId"
|
||||
element={<ConversationTabsContextMenu isOpen onClose={onClose} />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConversationTabsContextMenu", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("should use per-conversation localStorage key for unpinned tabs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = () => {};
|
||||
|
||||
// Render for conversation-1
|
||||
const { unmount } = renderWithRouter("conversation-1", onClose);
|
||||
|
||||
// Unpin the terminal tab in conversation-1
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
// Verify localStorage key is per-conversation
|
||||
const stored1 = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored1).toContain("terminal");
|
||||
|
||||
unmount();
|
||||
|
||||
// Switch to conversation-2
|
||||
renderWithRouter("conversation-2", onClose);
|
||||
|
||||
// conversation-2 should have its own empty state
|
||||
const stored2 = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-2") || "[]",
|
||||
);
|
||||
expect(stored2).toEqual([]);
|
||||
|
||||
// conversation-1 state should still have terminal unpinned
|
||||
const stored1Again = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored1Again).toContain("terminal");
|
||||
});
|
||||
|
||||
it("should toggle tab pin state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = () => {};
|
||||
|
||||
renderWithRouter("conversation-1", onClose);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
|
||||
// Click to unpin
|
||||
await user.click(terminalItem);
|
||||
let stored = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored).toContain("terminal");
|
||||
|
||||
// Click again to pin
|
||||
await user.click(terminalItem);
|
||||
stored = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored).not.toContain("terminal");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import PillIcon from "#/icons/pill.svg?react";
|
||||
import PillFillIcon from "#/icons/pill-fill.svg?react";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
interface ConversationTabsContextMenuProps {
|
||||
isOpen: boolean;
|
||||
@@ -25,39 +26,21 @@ export function ConversationTabsContextMenu({
|
||||
}: ConversationTabsContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage<string[]>(
|
||||
"conversation-unpinned-tabs",
|
||||
`conversation-unpinned-tabs-${conversationId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: "editor",
|
||||
icon: GitChanges,
|
||||
i18nKey: I18nKey.COMMON$CHANGES,
|
||||
},
|
||||
{
|
||||
tab: "vscode",
|
||||
icon: VSCodeIcon,
|
||||
i18nKey: I18nKey.COMMON$CODE,
|
||||
},
|
||||
{
|
||||
tab: "terminal",
|
||||
icon: TerminalIcon,
|
||||
i18nKey: I18nKey.COMMON$TERMINAL,
|
||||
},
|
||||
{
|
||||
tab: "served",
|
||||
icon: ServerIcon,
|
||||
i18nKey: I18nKey.COMMON$APP,
|
||||
},
|
||||
{
|
||||
tab: "browser",
|
||||
icon: GlobeIcon,
|
||||
i18nKey: I18nKey.COMMON$BROWSER,
|
||||
},
|
||||
{ tab: "editor", icon: GitChanges, i18nKey: I18nKey.COMMON$CHANGES },
|
||||
{ tab: "vscode", icon: VSCodeIcon, i18nKey: I18nKey.COMMON$CODE },
|
||||
{ tab: "terminal", icon: TerminalIcon, i18nKey: I18nKey.COMMON$TERMINAL },
|
||||
{ tab: "served", icon: ServerIcon, i18nKey: I18nKey.COMMON$APP },
|
||||
{ tab: "browser", icon: GlobeIcon, i18nKey: I18nKey.COMMON$BROWSER },
|
||||
];
|
||||
|
||||
if (shouldUsePlanningAgent) {
|
||||
@@ -71,30 +54,22 @@ export function ConversationTabsContextMenu({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleTabClick = (tab: string) => {
|
||||
const tabString = tab;
|
||||
if (unpinnedTabs.includes(tabString)) {
|
||||
// Tab is unpinned, pin it (remove from unpinned list)
|
||||
setUnpinnedTabs(
|
||||
unpinnedTabs.filter((unpinnedTab) => unpinnedTab !== tabString),
|
||||
);
|
||||
} else {
|
||||
// Tab is pinned, unpin it (add to unpinned list)
|
||||
setUnpinnedTabs([...unpinnedTabs, tabString]);
|
||||
}
|
||||
setUnpinnedTabs((prev) =>
|
||||
prev.includes(tab)
|
||||
? prev.filter((tabItem) => tabItem !== tab)
|
||||
: [...prev, tab],
|
||||
);
|
||||
};
|
||||
|
||||
const isTabPinned = (tab: string) => !unpinnedTabs.includes(tab as string);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
testId="conversation-tabs-context-menu"
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
position="bottom"
|
||||
className="mt-2 w-fit z-[9999]"
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, i18nKey }) => {
|
||||
const pinned = isTabPinned(tab);
|
||||
const pinned = !unpinnedTabs.includes(tab);
|
||||
return (
|
||||
<ContextMenuListItem
|
||||
key={tab}
|
||||
@@ -104,9 +79,9 @@ export function ConversationTabsContextMenu({
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-white text-sm">{t(i18nKey)}</span>
|
||||
{pinned ? (
|
||||
<PillFillIcon className="w-7 h-7 ml-auto flex-shrink-0 text-white -mr-[5px]" />
|
||||
<PillFillIcon className="w-7 h-7 ml-auto -mr-[5px]" />
|
||||
) : (
|
||||
<PillIcon className="w-4.5 h-4.5 ml-auto flex-shrink-0 text-white" />
|
||||
<PillIcon className="w-4.5 h-4.5 ml-auto" />
|
||||
)}
|
||||
</ContextMenuListItem>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user