diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index a026a5800..0c900a930 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -16,6 +16,11 @@ import { FileText, X, CheckSquare, + ListTree, + List, + ChevronDown, + ChevronRight, + ChevronsDownUp, } from "lucide-react"; import { useDeleteSessionMutation, @@ -27,6 +32,7 @@ import type { SessionMeta } from "@/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -35,6 +41,11 @@ import { } from "@/components/ui/select"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { Tooltip, @@ -56,10 +67,19 @@ import { getBaseName, getProviderIconName, getProviderLabel, + getSessionDirectoryGroupKey, getSessionKey, + groupSessionsByProviderAndDirectory, + type SessionDirectoryGroup, + type SessionProviderGroup, shouldHideCodexMessageFromToc, } from "./utils"; +const SESSION_LIST_VIEW_MODE_STORAGE_KEY = + "cc-switch.sessionManager.listViewMode"; +const SESSION_GROUP_EXPANSION_STORAGE_KEY = + "cc-switch.sessionManager.groupExpansionState"; + type ProviderFilter = | "all" | "codex" @@ -69,6 +89,102 @@ type ProviderFilter = | "gemini" | "hermes"; +type SessionListViewMode = "flat" | "grouped"; + +type GroupSelectionState = { + checked: boolean | "indeterminate"; + isSelected: boolean; + selectedCount: number; + selectableCount: number; +}; + +type SessionGroupExpansionState = { + expandedProviderIds: Set; + expandedDirectoryKeys: Set; +}; + +const readInitialSessionListViewMode = (): SessionListViewMode => { + if (typeof window === "undefined") return "flat"; + const stored = window.localStorage.getItem( + SESSION_LIST_VIEW_MODE_STORAGE_KEY, + ); + return stored === "grouped" || stored === "flat" ? stored : "flat"; +}; + +const readInitialSessionGroupExpansionState = + (): SessionGroupExpansionState => { + if (typeof window === "undefined") { + return { + expandedProviderIds: new Set(), + expandedDirectoryKeys: new Set(), + }; + } + + try { + const stored = window.localStorage.getItem( + SESSION_GROUP_EXPANSION_STORAGE_KEY, + ); + const parsed = stored ? JSON.parse(stored) : null; + + if (!parsed || typeof parsed !== "object") { + return { + expandedProviderIds: new Set(), + expandedDirectoryKeys: new Set(), + }; + } + + const expandedProviderIds = Array.isArray(parsed.expandedProviderIds) + ? parsed.expandedProviderIds.filter( + (providerId: unknown): providerId is string => + typeof providerId === "string", + ) + : []; + const expandedDirectoryKeys = Array.isArray(parsed.expandedDirectoryKeys) + ? parsed.expandedDirectoryKeys.filter( + (directoryKey: unknown): directoryKey is string => + typeof directoryKey === "string", + ) + : []; + + return { + expandedProviderIds: new Set(expandedProviderIds), + expandedDirectoryKeys: new Set(expandedDirectoryKeys), + }; + } catch { + return { + expandedProviderIds: new Set(), + expandedDirectoryKeys: new Set(), + }; + } + }; + +const serializeSessionGroupExpansionState = ( + expandedProviderGroups: Set, + expandedDirectoryGroups: Set, +) => + JSON.stringify({ + expandedProviderIds: Array.from(expandedProviderGroups).sort(), + expandedDirectoryKeys: Array.from(expandedDirectoryGroups).sort(), + }); + +const filterSetToAllowedValues = ( + current: Set, + allowedValues: Set, +) => { + let changed = false; + const next = new Set(); + + current.forEach((value) => { + if (allowedValues.has(value)) { + next.add(value); + } else { + changed = true; + } + }); + + return changed ? next : current; +}; + export function SessionManagerPage({ appId }: { appId: string }) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -96,6 +212,18 @@ export function SessionManagerPage({ appId }: { appId: string }) { appId as ProviderFilter, ); const [selectedKey, setSelectedKey] = useState(null); + const [listViewMode, setListViewMode] = useState( + readInitialSessionListViewMode, + ); + const [initialGroupExpansionState] = useState( + readInitialSessionGroupExpansionState, + ); + const [expandedProviderGroups, setExpandedProviderGroups] = useState< + Set + >(() => initialGroupExpansionState.expandedProviderIds); + const [expandedDirectoryGroups, setExpandedDirectoryGroups] = useState< + Set + >(() => initialGroupExpansionState.expandedDirectoryKeys); // 使用 FlexSearch 全文搜索 const { search: searchSessions } = useSessionSearch({ @@ -107,6 +235,57 @@ export function SessionManagerPage({ appId }: { appId: string }) { return searchSessions(search); }, [searchSessions, search]); + const groupedSessions = useMemo( + () => + groupSessionsByProviderAndDirectory( + filteredSessions, + t("sessionManager.unknownDirectory", { + defaultValue: "未知目录", + }), + ), + [filteredSessions, t], + ); + + const validGroupExpansionKeys = useMemo( + () => ({ + providerIds: new Set(sessions.map((session) => session.providerId)), + directoryKeys: new Set( + sessions.map((session) => + getSessionDirectoryGroupKey(session.providerId, session.projectDir), + ), + ), + }), + [sessions], + ); + + useEffect(() => { + window.localStorage.setItem( + SESSION_LIST_VIEW_MODE_STORAGE_KEY, + listViewMode, + ); + }, [listViewMode]); + + useEffect(() => { + window.localStorage.setItem( + SESSION_GROUP_EXPANSION_STORAGE_KEY, + serializeSessionGroupExpansionState( + expandedProviderGroups, + expandedDirectoryGroups, + ), + ); + }, [expandedDirectoryGroups, expandedProviderGroups]); + + useEffect(() => { + if (isLoading) return; + + setExpandedProviderGroups((current) => + filterSetToAllowedValues(current, validGroupExpansionKeys.providerIds), + ); + setExpandedDirectoryGroups((current) => + filterSetToAllowedValues(current, validGroupExpansionKeys.directoryKeys), + ); + }, [isLoading, validGroupExpansionKeys]); + useEffect(() => { if (filteredSessions.length === 0) { setSelectedKey(null); @@ -131,6 +310,15 @@ export function SessionManagerPage({ appId }: { appId: string }) { ); }, [filteredSessions, selectedKey]); + const listViewModeLabel = + listViewMode === "grouped" + ? t("sessionManager.viewModeGrouped", { + defaultValue: "分类", + }) + : t("sessionManager.viewModeFlat", { + defaultValue: "列表", + }); + const { data: messages = [], isLoading: isLoadingMessages } = useSessionMessagesQuery( selectedSession?.providerId, @@ -402,6 +590,28 @@ export function SessionManagerPage({ appId }: { appId: string }) { selectedSessionKeys.has(getSessionKey(session)), ); + const getGroupSelectionState = ( + groupSessions: SessionMeta[], + ): GroupSelectionState => { + const selectableSessions = groupSessions.filter((session) => + Boolean(session.sourcePath), + ); + const selectedCount = selectableSessions.filter((session) => + selectedSessionKeys.has(getSessionKey(session)), + ).length; + const isSelected = + selectableSessions.length > 0 && + selectedCount === selectableSessions.length; + + return { + checked: + selectedCount === 0 ? false : isSelected ? true : "indeterminate", + isSelected, + selectedCount, + selectableCount: selectableSessions.length, + }; + }; + const toggleSessionChecked = (session: SessionMeta, checked: boolean) => { if (!session.sourcePath) return; const key = getSessionKey(session); @@ -416,6 +626,140 @@ export function SessionManagerPage({ appId }: { appId: string }) { }); }; + const toggleSessionGroupChecked = ( + groupSessions: SessionMeta[], + checked: boolean, + ) => { + const selectableSessions = groupSessions.filter((session) => + Boolean(session.sourcePath), + ); + if (selectableSessions.length === 0) return; + + setSelectedSessionKeys((current) => { + const next = new Set(current); + selectableSessions.forEach((session) => { + const sessionKey = getSessionKey(session); + if (checked) { + next.add(sessionKey); + } else { + next.delete(sessionKey); + } + }); + return next; + }); + }; + + const toggleProviderGroup = (providerId: string) => { + setExpandedProviderGroups((current) => { + const next = new Set(current); + if (next.has(providerId)) { + next.delete(providerId); + } else { + next.add(providerId); + } + return next; + }); + }; + + const toggleDirectoryGroup = (directoryKey: string) => { + setExpandedDirectoryGroups((current) => { + const next = new Set(current); + if (next.has(directoryKey)) { + next.delete(directoryKey); + } else { + next.add(directoryKey); + } + return next; + }); + }; + + const handleCollapseAllGroups = () => { + setExpandedProviderGroups(new Set()); + setExpandedDirectoryGroups(new Set()); + }; + + const renderSessionItem = (session: SessionMeta) => { + const sessionKey = getSessionKey(session); + const isSelected = selectedKey !== null && sessionKey === selectedKey; + + return ( + toggleSessionChecked(session, checked)} + /> + ); + }; + + const renderGroupSelectionBadge = ( + selectionState: GroupSelectionState, + totalCount: number, + variant: "secondary" | "outline", + ) => ( + + {selectionMode + ? `${selectionState.selectedCount}/${selectionState.selectableCount}` + : totalCount} + + ); + + const renderProviderGroupCheckbox = ( + providerGroup: SessionProviderGroup, + providerLabel: string, + selectionState: GroupSelectionState, + ) => { + if (!selectionMode) return null; + + return ( + event.stopPropagation()} + onCheckedChange={() => + toggleSessionGroupChecked( + providerGroup.sessions, + !selectionState.isSelected, + ) + } + /> + ); + }; + + const renderDirectoryGroupCheckbox = ( + directoryGroup: SessionDirectoryGroup, + selectionState: GroupSelectionState, + ) => { + if (!selectionMode) return null; + + return ( + event.stopPropagation()} + onCheckedChange={() => + toggleSessionGroupChecked( + directoryGroup.sessions, + !selectionState.isSelected, + ) + } + /> + ); + }; + const handleToggleSelectAll = () => { setSelectedSessionKeys((current) => { const next = new Set(current); @@ -570,6 +914,85 @@ export function SessionManagerPage({ appId }: { appId: string }) { )} + + {listViewMode === "grouped" && ( + + + + + + {t("sessionManager.collapseAllGroups", { + defaultValue: "全部收起", + })} + + + )} + + + + {providerGroup.directories.map( + (directoryGroup) => { + const directoryOpen = + expandedDirectoryGroups.has( + directoryGroup.key, + ); + const directorySelectionState = + getGroupSelectionState( + directoryGroup.sessions, + ); + + return ( + + toggleDirectoryGroup( + directoryGroup.key, + ) + } + > +
+ {renderDirectoryGroupCheckbox( + directoryGroup, + directorySelectionState, + )} + + + +
+ + {directoryGroup.sessions.map( + (session) => + renderSessionItem(session), + )} + +
+ ); + }, + )} +
+ ); })} + ) : ( +
+ {filteredSessions.map((session) => + renderSessionItem(session), + )} +
)} diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 7bba10e9f..9d5614a7d 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -4,6 +4,20 @@ import { SessionMeta } from "@/types"; const CODEX_IDE_CONTEXT_PREFIX = "# Context from my IDE setup:"; const CODEX_REQUEST_MARKER = "my request for codex"; +export const UNKNOWN_PROJECT_DIR_KEY = "__unknown_project_dir__"; + +export interface SessionDirectoryGroup { + key: string; + projectDir: string | null; + label: string; + sessions: SessionMeta[]; +} + +export interface SessionProviderGroup { + providerId: string; + sessions: SessionMeta[]; + directories: SessionDirectoryGroup[]; +} const getCodexRequestHeadingPayload = (lineText: string) => { if (!lineText.startsWith("#")) return null; @@ -55,6 +69,14 @@ const extractCodexPromptFromIdeContext = (content: string) => { export const getSessionKey = (session: SessionMeta) => `${session.providerId}:${session.sessionId}:${session.sourcePath ?? ""}`; +export const getSessionDirectoryGroupKey = ( + providerId: string, + projectDir?: string | null, +) => { + const trimmed = projectDir?.trim(); + return `${providerId}:${trimmed || UNKNOWN_PROJECT_DIR_KEY}`; +}; + export const getBaseName = (value?: string | null) => { if (!value) return ""; const trimmed = value.trim(); @@ -131,6 +153,59 @@ export const formatSessionTitle = (session: SessionMeta) => { ); }; +export const groupSessionsByProviderAndDirectory = ( + sessions: SessionMeta[], + unknownDirectoryLabel: string, +): SessionProviderGroup[] => { + const providerGroups: SessionProviderGroup[] = []; + const providerGroupMap = new Map(); + const directoryGroupMaps = new Map< + string, + Map + >(); + + sessions.forEach((session) => { + let providerGroup = providerGroupMap.get(session.providerId); + if (!providerGroup) { + providerGroup = { + providerId: session.providerId, + sessions: [], + directories: [], + }; + providerGroupMap.set(session.providerId, providerGroup); + providerGroups.push(providerGroup); + directoryGroupMaps.set(session.providerId, new Map()); + } + + providerGroup.sessions.push(session); + + const trimmedProjectDir = session.projectDir?.trim() || null; + const directoryKey = getSessionDirectoryGroupKey( + session.providerId, + trimmedProjectDir, + ); + const directoryGroups = directoryGroupMaps.get(session.providerId)!; + + let directoryGroup = directoryGroups.get(directoryKey); + if (!directoryGroup) { + directoryGroup = { + key: directoryKey, + projectDir: trimmedProjectDir, + label: trimmedProjectDir + ? getBaseName(trimmedProjectDir) || trimmedProjectDir + : unknownDirectoryLabel, + sessions: [], + }; + directoryGroups.set(directoryKey, directoryGroup); + providerGroup.directories.push(directoryGroup); + } + + directoryGroup.sessions.push(session); + }); + + return providerGroups; +}; + export const shouldHideCodexMessageFromToc = (content: string) => { const trimmed = content.trim(); return ( diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 85b25489d..3f2367da0 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { Check } from "lucide-react"; +import { Check, Minus } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -12,6 +12,7 @@ const Checkbox = React.forwardRef< ref={ref} className={cn( "grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + "data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground", className, )} {...props} @@ -19,7 +20,11 @@ const Checkbox = React.forwardRef< - + {props.checked === "indeterminate" ? ( + + ) : ( + + )} )); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a27052671..b8c74e8bb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -888,7 +888,17 @@ "searchPlaceholder": "Search by content, directory, or ID", "searchSessions": "Search sessions", "providerFilterAll": "All", + "providerFilterTooltip": "Provider filter", "sessionList": "Sessions", + "viewModeTooltip": "View mode", + "viewModeFlat": "List", + "viewModeGrouped": "Grouped", + "unknownDirectory": "Unknown directory", + "toggleProviderGroup": "Expand or collapse {{provider}} provider group", + "toggleDirectoryGroup": "Expand or collapse {{directory}} directory group", + "collapseAllGroups": "Collapse all", + "selectProviderGroupForBatch": "Select sessions in {{provider}} provider group", + "selectDirectoryGroupForBatch": "Select sessions in {{directory}} directory group", "manageBatchTooltip": "Enter batch management", "exitBatchModeTooltip": "Exit batch management", "batchModeHint": "Select sessions to delete", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 85b789fad..445dae27d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -888,7 +888,17 @@ "searchPlaceholder": "内容・ディレクトリ・ID で検索", "searchSessions": "セッションを検索", "providerFilterAll": "すべて", + "providerFilterTooltip": "プロバイダーフィルター", "sessionList": "セッション一覧", + "viewModeTooltip": "表示方式", + "viewModeFlat": "一覧", + "viewModeGrouped": "分類", + "unknownDirectory": "不明なディレクトリ", + "toggleProviderGroup": "{{provider}} プロバイダーグループを開閉", + "toggleDirectoryGroup": "{{directory}} ディレクトリグループを開閉", + "collapseAllGroups": "すべて折りたたむ", + "selectProviderGroupForBatch": "{{provider}} プロバイダーグループ内のセッションを選択", + "selectDirectoryGroupForBatch": "{{directory}} ディレクトリグループ内のセッションを選択", "manageBatchTooltip": "一括管理に入る", "exitBatchModeTooltip": "一括管理を終了", "batchModeHint": "削除するセッションを選択", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7c8470b2b..796019946 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -859,7 +859,17 @@ "searchPlaceholder": "搜尋對話內容、目錄或 ID", "searchSessions": "搜尋工作階段", "providerFilterAll": "全部", + "providerFilterTooltip": "供應商篩選", "sessionList": "工作階段清單", + "viewModeTooltip": "檢視方式", + "viewModeFlat": "清單", + "viewModeGrouped": "分類", + "unknownDirectory": "未知目錄", + "toggleProviderGroup": "展開或摺疊 {{provider}} 供應商分組", + "toggleDirectoryGroup": "展開或摺疊 {{directory}} 目錄分組", + "collapseAllGroups": "全部收起", + "selectProviderGroupForBatch": "選擇 {{provider}} 供應商分組內工作階段", + "selectDirectoryGroupForBatch": "選擇 {{directory}} 目錄分組內工作階段", "manageBatchTooltip": "進入批次管理", "exitBatchModeTooltip": "退出批次管理", "batchModeHint": "勾選要刪除的工作階段", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c53df90d1..59e80ae56 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -888,7 +888,17 @@ "searchPlaceholder": "搜索会话内容、目录或 ID", "searchSessions": "搜索会话", "providerFilterAll": "全部", + "providerFilterTooltip": "供应商筛选", "sessionList": "会话列表", + "viewModeTooltip": "查看方式", + "viewModeFlat": "列表", + "viewModeGrouped": "分类", + "unknownDirectory": "未知目录", + "toggleProviderGroup": "展开或折叠 {{provider}} 供应商分组", + "toggleDirectoryGroup": "展开或折叠 {{directory}} 目录分组", + "collapseAllGroups": "全部收起", + "selectProviderGroupForBatch": "选择 {{provider}} 供应商分组内会话", + "selectDirectoryGroupForBatch": "选择 {{directory}} 目录分组内会话", "manageBatchTooltip": "进入批量管理", "exitBatchModeTooltip": "退出批量管理", "batchModeHint": "勾选要删除的会话", diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx index 3b095ffd7..40c25cc66 100644 --- a/tests/components/SessionManagerPage.test.tsx +++ b/tests/components/SessionManagerPage.test.tsx @@ -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( - + , ), }; @@ -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 = { "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(); + }); }); diff --git a/tests/components/sessionUtils.test.ts b/tests/components/sessionUtils.test.ts index cbf02c142..f4bcfc3bd 100644 --- a/tests/components/sessionUtils.test.ts +++ b/tests/components/sessionUtils.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { extractCodexPromptPreview, formatSessionMessagePreview, + groupSessionsByProviderAndDirectory, shouldHideCodexMessageFromToc, } from "@/components/sessions/utils"; +import type { SessionMeta } from "@/types"; describe("session utils", () => { it("extracts Codex VS Code prompts after the request marker", () => { @@ -115,4 +117,108 @@ describe("session utils", () => { `${"a".repeat(50)}...`, ); }); + + it("groups sessions by provider and project directory", () => { + const sessions: SessionMeta[] = [ + { + providerId: "codex", + sessionId: "codex-1", + projectDir: "/workspace/app", + }, + { + providerId: "codex", + sessionId: "codex-2", + projectDir: "/workspace/app", + }, + { + providerId: "claude", + sessionId: "claude-1", + projectDir: "/workspace/docs", + }, + ]; + + const groups = groupSessionsByProviderAndDirectory(sessions, "未知目录"); + + expect(groups).toHaveLength(2); + expect(groups[0].providerId).toBe("codex"); + expect(groups[0].sessions.map((session) => session.sessionId)).toEqual([ + "codex-1", + "codex-2", + ]); + expect(groups[0].directories).toHaveLength(1); + expect(groups[0].directories[0]).toMatchObject({ + projectDir: "/workspace/app", + label: "app", + }); + expect( + groups[0].directories[0].sessions.map((session) => session.sessionId), + ).toEqual(["codex-1", "codex-2"]); + expect(groups[1].providerId).toBe("claude"); + expect(groups[1].directories[0].label).toBe("docs"); + }); + + it("uses an unknown directory group for sessions without project directories", () => { + const sessions: SessionMeta[] = [ + { + providerId: "codex", + sessionId: "codex-1", + projectDir: null, + }, + { + providerId: "codex", + sessionId: "codex-2", + projectDir: " ", + }, + ]; + + const groups = groupSessionsByProviderAndDirectory(sessions, "未知目录"); + + expect(groups).toHaveLength(1); + expect(groups[0].directories).toHaveLength(1); + expect(groups[0].directories[0]).toMatchObject({ + projectDir: null, + label: "未知目录", + }); + expect( + groups[0].directories[0].sessions.map((session) => session.sessionId), + ).toEqual(["codex-1", "codex-2"]); + }); + + it("preserves filtered session order inside provider and directory groups", () => { + const sessions: SessionMeta[] = [ + { + providerId: "codex", + sessionId: "newest", + projectDir: "/workspace/app", + lastActiveAt: 30, + }, + { + providerId: "codex", + sessionId: "middle", + projectDir: "/workspace/docs", + lastActiveAt: 20, + }, + { + providerId: "codex", + sessionId: "oldest", + projectDir: "/workspace/app", + lastActiveAt: 10, + }, + ]; + + const groups = groupSessionsByProviderAndDirectory(sessions, "未知目录"); + + expect(groups[0].sessions.map((session) => session.sessionId)).toEqual([ + "newest", + "middle", + "oldest", + ]); + expect(groups[0].directories.map((group) => group.label)).toEqual([ + "app", + "docs", + ]); + expect( + groups[0].directories[0].sessions.map((session) => session.sessionId), + ).toEqual(["newest", "oldest"]); + }); }); diff --git a/tests/setupGlobals.ts b/tests/setupGlobals.ts index 999820436..aa4535dfa 100644 --- a/tests/setupGlobals.ts +++ b/tests/setupGlobals.ts @@ -33,3 +33,15 @@ if ( configurable: true, }); } + +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} + +if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => {}; +} + +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; +}