mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(sessions): 新增会话分类视图与分组管理 (#4776)
* feat(sessions): 新增会话分类查看方式 * test(sessions): 补充会话分类视图覆盖 * feat(sessions): 适配分类视图批量管理 * test(sessions): 补充分类批量选择覆盖 * feat(sessions): 新增分类视图收起状态控制 * test(sessions): 补充分类收起状态覆盖
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import { sessionsApi } from "@/lib/api/sessions";
|
||||
@@ -15,6 +16,8 @@ import { setSessionFixtures } from "../msw/state";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
const GROUP_EXPANSION_STORAGE_KEY =
|
||||
"cc-switch.sessionManager.groupExpansionState";
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
@@ -56,7 +59,7 @@ vi.mock("@/components/ConfirmDialog", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const renderPage = () => {
|
||||
const renderPage = (appId = "codex") => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
@@ -68,7 +71,7 @@ const renderPage = () => {
|
||||
client,
|
||||
...render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
<SessionManagerPage appId={appId} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
@@ -87,8 +90,8 @@ const openSearch = () => {
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
const closeButton = Array.from(screen.getAllByRole("button")).find(
|
||||
(button) => button.querySelector(".lucide-x"),
|
||||
const closeButton = Array.from(screen.getAllByRole("button")).find((button) =>
|
||||
button.querySelector(".lucide-x"),
|
||||
);
|
||||
|
||||
if (!closeButton) {
|
||||
@@ -98,11 +101,61 @@ const closeSearch = () => {
|
||||
fireEvent.click(closeButton);
|
||||
};
|
||||
|
||||
const openViewModeMenu = async () => {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /查看方式/i }));
|
||||
};
|
||||
|
||||
const switchToGroupedView = async () => {
|
||||
await openViewModeMenu();
|
||||
const groupedOption = await screen.findByRole("option", { name: /分类/i });
|
||||
await userEvent.click(groupedOption);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("option", { name: /分类/i }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
};
|
||||
|
||||
const switchProviderFilter = async (providerLabel: RegExp) => {
|
||||
const providerFilterTrigger = screen.getByRole("combobox", {
|
||||
name: /供应商筛选/i,
|
||||
});
|
||||
|
||||
await userEvent.click(providerFilterTrigger);
|
||||
await userEvent.click(
|
||||
await screen.findByRole("option", { name: providerLabel }),
|
||||
);
|
||||
};
|
||||
|
||||
const enterGroupedBatchMode = async () => {
|
||||
await switchToGroupedView();
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
};
|
||||
|
||||
const collapseAllGroups = () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /全部收起/i }));
|
||||
};
|
||||
|
||||
const expandDirectoryGroup = (provider: string, directory: string) => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: new RegExp(`展开或折叠 ${provider} 供应商分组`),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: new RegExp(`展开或折叠 ${directory} 目录分组`),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe("SessionManagerPage", () => {
|
||||
beforeEach(() => {
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
window.localStorage.removeItem("cc-switch.sessionManager.listViewMode");
|
||||
window.localStorage.removeItem(GROUP_EXPANSION_STORAGE_KEY);
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{
|
||||
@@ -127,6 +180,28 @@ describe("SessionManagerPage", () => {
|
||||
sourcePath: "/mock/codex/session-2.jsonl",
|
||||
resumeCommand: "codex resume codex-session-2",
|
||||
},
|
||||
{
|
||||
providerId: "claude",
|
||||
sessionId: "claude-session-1",
|
||||
title: "Claude Session",
|
||||
summary: "Claude summary",
|
||||
projectDir: "/mock/claude",
|
||||
createdAt: 3,
|
||||
lastActiveAt: 30,
|
||||
sourcePath: "/mock/claude/session-1.jsonl",
|
||||
resumeCommand: "claude --resume claude-session-1",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-3",
|
||||
title: "Gamma Session",
|
||||
summary: "Gamma summary",
|
||||
projectDir: null,
|
||||
createdAt: 0,
|
||||
lastActiveAt: 5,
|
||||
sourcePath: "/mock/codex/session-3.jsonl",
|
||||
resumeCommand: "codex resume codex-session-3",
|
||||
},
|
||||
];
|
||||
const messages: Record<string, SessionMessage[]> = {
|
||||
"codex:/mock/codex/session-1.jsonl": [
|
||||
@@ -135,6 +210,12 @@ describe("SessionManagerPage", () => {
|
||||
"codex:/mock/codex/session-2.jsonl": [
|
||||
{ role: "user", content: "beta", ts: 10 },
|
||||
],
|
||||
"codex:/mock/codex/session-3.jsonl": [
|
||||
{ role: "user", content: "gamma", ts: 5 },
|
||||
],
|
||||
"claude:/mock/claude/session-1.jsonl": [
|
||||
{ role: "user", content: "claude", ts: 30 },
|
||||
],
|
||||
};
|
||||
|
||||
setSessionFixtures(sessions, messages);
|
||||
@@ -275,7 +356,7 @@ describe("SessionManagerPage", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
|
||||
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
||||
expect(screen.getByText("已选 3 项")).toBeInTheDocument();
|
||||
|
||||
openSearch();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
@@ -330,4 +411,273 @@ describe("SessionManagerPage", () => {
|
||||
});
|
||||
invalidateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("switches to grouped view collapsed by default and shows collapse control", async () => {
|
||||
renderPage("all");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Claude Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await switchToGroupedView();
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /全部收起/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /展开或折叠 codex 供应商分组/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /展开或折叠 claude 供应商分组/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /展开或折叠 codex 目录分组/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Alpha Session/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists manual expansion and collapses all grouped sessions", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await switchToGroupedView();
|
||||
expandDirectoryGroup("codex", "codex");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /展开或折叠 codex 目录分组/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Alpha Session/ }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem(GROUP_EXPANSION_STORAGE_KEY)!),
|
||||
).toEqual({
|
||||
expandedProviderIds: ["codex"],
|
||||
expandedDirectoryKeys: ["codex:/mock/codex"],
|
||||
}),
|
||||
);
|
||||
|
||||
collapseAllGroups();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /展开或折叠 codex 目录分组/ }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem(GROUP_EXPANSION_STORAGE_KEY)!),
|
||||
).toEqual({
|
||||
expandedProviderIds: [],
|
||||
expandedDirectoryKeys: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps filtered grouped sessions collapsed until expanding the group", async () => {
|
||||
renderPage("all");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Alpha Session")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Alpha Session/ }));
|
||||
await switchToGroupedView();
|
||||
await switchProviderFilter(/Claude Code/i);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Claude Session" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /展开或折叠 claude 供应商分组/,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /展开或折叠 claude 目录分组/ }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expandDirectoryGroup("claude", "claude");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /展开或折叠 claude 目录分组/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Claude Session/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Gamma Session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports batch deletion from grouped view", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await switchToGroupedView();
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Gamma Session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selects visible deletable sessions by provider group in grouped batch mode", async () => {
|
||||
renderPage("all");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Claude Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await enterGroupedBatchMode();
|
||||
|
||||
const codexProviderCheckbox = screen.getByRole("checkbox", {
|
||||
name: /选择 codex 供应商分组内会话/,
|
||||
});
|
||||
const claudeProviderCheckbox = screen.getByRole("checkbox", {
|
||||
name: /选择 claude 供应商分组内会话/,
|
||||
});
|
||||
|
||||
fireEvent.click(codexProviderCheckbox);
|
||||
|
||||
expect(codexProviderCheckbox).toBeChecked();
|
||||
expect(claudeProviderCheckbox).not.toBeChecked();
|
||||
expect(screen.getByText("已选 3 项")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(codexProviderCheckbox);
|
||||
|
||||
expect(codexProviderCheckbox).not.toBeChecked();
|
||||
expect(screen.getByText("已选 0 项")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects visible deletable sessions by directory group and marks the provider as mixed", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await enterGroupedBatchMode();
|
||||
expandDirectoryGroup("codex", "codex");
|
||||
|
||||
const providerCheckbox = screen.getByRole("checkbox", {
|
||||
name: /选择 codex 供应商分组内会话/,
|
||||
});
|
||||
const codexDirectoryCheckbox = screen.getByRole("checkbox", {
|
||||
name: /选择 codex 目录分组内会话/,
|
||||
});
|
||||
|
||||
fireEvent.click(codexDirectoryCheckbox);
|
||||
|
||||
expect(codexDirectoryCheckbox).toBeChecked();
|
||||
expect(providerCheckbox).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks grouped batch checkboxes as mixed when only one session is selected", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await enterGroupedBatchMode();
|
||||
expandDirectoryGroup("codex", "codex");
|
||||
|
||||
fireEvent.click(screen.getAllByRole("checkbox", { name: "选择会话" })[0]);
|
||||
|
||||
expect(
|
||||
screen.getByRole("checkbox", {
|
||||
name: /选择 codex 供应商分组内会话/,
|
||||
}),
|
||||
).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: /选择 codex 目录分组内会话/ }),
|
||||
).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(screen.getByText("已选 1 项")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("batch deletes only sessions selected from a grouped directory", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await enterGroupedBatchMode();
|
||||
expandDirectoryGroup("codex", "codex");
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: /选择 codex 目录分组内会话/,
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /展开或折叠 未知目录 目录分组/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("checkbox", { name: "选择会话" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /展开或折叠 未知目录 目录分组/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: "选择会话" }),
|
||||
).toBeInTheDocument();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user