feat(sessions): 新增会话分类视图与分组管理 (#4776)

* feat(sessions): 新增会话分类查看方式

* test(sessions): 补充会话分类视图覆盖

* feat(sessions): 适配分类视图批量管理

* test(sessions): 补充分类批量选择覆盖

* feat(sessions): 新增分类视图收起状态控制

* test(sessions): 补充分类收起状态覆盖
This commit is contained in:
Nothing
2026-07-01 12:23:46 +08:00
committed by GitHub
parent f21d6f7a42
commit 251b13e5d2
10 changed files with 1188 additions and 28 deletions
+593 -21
View File
@@ -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<string>;
expandedDirectoryKeys: Set<string>;
};
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<string>,
expandedDirectoryGroups: Set<string>,
) =>
JSON.stringify({
expandedProviderIds: Array.from(expandedProviderGroups).sort(),
expandedDirectoryKeys: Array.from(expandedDirectoryGroups).sort(),
});
const filterSetToAllowedValues = (
current: Set<string>,
allowedValues: Set<string>,
) => {
let changed = false;
const next = new Set<string>();
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<string | null>(null);
const [listViewMode, setListViewMode] = useState<SessionListViewMode>(
readInitialSessionListViewMode,
);
const [initialGroupExpansionState] = useState(
readInitialSessionGroupExpansionState,
);
const [expandedProviderGroups, setExpandedProviderGroups] = useState<
Set<string>
>(() => initialGroupExpansionState.expandedProviderIds);
const [expandedDirectoryGroups, setExpandedDirectoryGroups] = useState<
Set<string>
>(() => 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 (
<SessionItem
key={sessionKey}
session={session}
isSelected={isSelected}
selectionMode={selectionMode}
searchQuery={search}
isChecked={selectedSessionKeys.has(sessionKey)}
isCheckDisabled={!session.sourcePath}
onSelect={setSelectedKey}
onToggleChecked={(checked) => toggleSessionChecked(session, checked)}
/>
);
};
const renderGroupSelectionBadge = (
selectionState: GroupSelectionState,
totalCount: number,
variant: "secondary" | "outline",
) => (
<Badge variant={variant} className="shrink-0 text-xs">
{selectionMode
? `${selectionState.selectedCount}/${selectionState.selectableCount}`
: totalCount}
</Badge>
);
const renderProviderGroupCheckbox = (
providerGroup: SessionProviderGroup,
providerLabel: string,
selectionState: GroupSelectionState,
) => {
if (!selectionMode) return null;
return (
<Checkbox
checked={selectionState.checked}
disabled={selectionState.selectableCount === 0}
aria-label={t("sessionManager.selectProviderGroupForBatch", {
defaultValue: "选择 {{provider}} 供应商分组内会话",
provider: providerLabel,
})}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() =>
toggleSessionGroupChecked(
providerGroup.sessions,
!selectionState.isSelected,
)
}
/>
);
};
const renderDirectoryGroupCheckbox = (
directoryGroup: SessionDirectoryGroup,
selectionState: GroupSelectionState,
) => {
if (!selectionMode) return null;
return (
<Checkbox
checked={selectionState.checked}
disabled={selectionState.selectableCount === 0}
aria-label={t("sessionManager.selectDirectoryGroupForBatch", {
defaultValue: "选择 {{directory}} 目录分组内会话",
directory: directoryGroup.label,
})}
onClick={(event) => 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 }) {
</TooltipContent>
</Tooltip>
)}
<Select
value={listViewMode}
onValueChange={(value) =>
setListViewMode(value as SessionListViewMode)
}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted"
aria-label={t(
"sessionManager.viewModeTooltip",
{
defaultValue: "查看方式",
},
)}
>
<span className="sr-only">
{t("sessionManager.viewModeTooltip", {
defaultValue: "查看方式",
})}
</span>
{listViewMode === "grouped" ? (
<ListTree className="size-3.5" />
) : (
<List className="size-3.5" />
)}
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>{listViewModeLabel}</TooltipContent>
</Tooltip>
<SelectContent className="w-40">
<SelectItem value="flat">
<div className="flex items-center gap-2">
<List className="size-3.5" />
<span>
{t("sessionManager.viewModeFlat", {
defaultValue: "列表",
})}
</span>
</div>
</SelectItem>
<SelectItem value="grouped">
<div className="flex items-center gap-2">
<ListTree className="size-3.5" />
<span>
{t("sessionManager.viewModeGrouped", {
defaultValue: "分类",
})}
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
{listViewMode === "grouped" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t(
"sessionManager.collapseAllGroups",
{
defaultValue: "全部收起",
},
)}
onClick={handleCollapseAllGroups}
>
<ChevronsDownUp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("sessionManager.collapseAllGroups", {
defaultValue: "全部收起",
})}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -600,7 +1023,20 @@ export function SessionManagerPage({ appId }: { appId: string }) {
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
<SelectTrigger
className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted"
aria-label={t(
"sessionManager.providerFilterTooltip",
{
defaultValue: "供应商筛选",
},
)}
>
<span className="sr-only">
{t("sessionManager.providerFilterTooltip", {
defaultValue: "供应商筛选",
})}
</span>
<ProviderIcon
icon={
providerFilter === "all"
@@ -784,32 +1220,168 @@ export function SessionManagerPage({ appId }: { appId: string }) {
{t("sessionManager.noSessions")}
</p>
</div>
) : (
<div className="space-y-1">
{filteredSessions.map((session) => {
const isSelected =
selectedKey !== null &&
getSessionKey(session) === selectedKey;
) : listViewMode === "grouped" ? (
<div className="space-y-2">
{groupedSessions.map((providerGroup) => {
const providerOpen = expandedProviderGroups.has(
providerGroup.providerId,
);
const providerLabel = getProviderLabel(
providerGroup.providerId,
t,
);
const providerSelectionState = getGroupSelectionState(
providerGroup.sessions,
);
return (
<SessionItem
key={getSessionKey(session)}
session={session}
isSelected={isSelected}
selectionMode={selectionMode}
searchQuery={search}
isChecked={selectedSessionKeys.has(
getSessionKey(session),
)}
isCheckDisabled={!session.sourcePath}
onSelect={setSelectedKey}
onToggleChecked={(checked) =>
toggleSessionChecked(session, checked)
<Collapsible
key={providerGroup.providerId}
open={providerOpen}
onOpenChange={() =>
toggleProviderGroup(providerGroup.providerId)
}
/>
>
<div className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2.5 py-2 transition-colors hover:bg-muted">
{renderProviderGroupCheckbox(
providerGroup,
providerLabel,
providerSelectionState,
)}
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-label={t(
"sessionManager.toggleProviderGroup",
{
defaultValue:
"展开或折叠 {{provider}} 供应商分组",
provider: providerLabel,
},
)}
>
{providerOpen ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<ProviderIcon
icon={getProviderIconName(
providerGroup.providerId,
)}
name={providerGroup.providerId}
size={16}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{providerLabel}
</span>
{renderGroupSelectionBadge(
providerSelectionState,
providerGroup.sessions.length,
"secondary",
)}
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-1 space-y-1 pl-2">
{providerGroup.directories.map(
(directoryGroup) => {
const directoryOpen =
expandedDirectoryGroups.has(
directoryGroup.key,
);
const directorySelectionState =
getGroupSelectionState(
directoryGroup.sessions,
);
return (
<Collapsible
key={directoryGroup.key}
open={directoryOpen}
onOpenChange={() =>
toggleDirectoryGroup(
directoryGroup.key,
)
}
>
<div className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
{renderDirectoryGroupCheckbox(
directoryGroup,
directorySelectionState,
)}
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-label={t(
"sessionManager.toggleDirectoryGroup",
{
defaultValue:
"展开或折叠 {{directory}} 目录分组",
directory:
directoryGroup.label,
},
)}
>
{directoryOpen ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
<FolderOpen className="size-3.5 shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{directoryGroup.label}
</span>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-xs"
>
<p className="font-mono text-xs break-all">
{directoryGroup.projectDir ??
t(
"sessionManager.unknownDirectory",
{
defaultValue:
"未知目录",
},
)}
</p>
</TooltipContent>
</Tooltip>
{renderGroupSelectionBadge(
directorySelectionState,
directoryGroup.sessions.length,
"outline",
)}
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-1 space-y-1 pl-3">
{directoryGroup.sessions.map(
(session) =>
renderSessionItem(session),
)}
</CollapsibleContent>
</Collapsible>
);
},
)}
</CollapsibleContent>
</Collapsible>
);
})}
</div>
) : (
<div className="space-y-1">
{filteredSessions.map((session) =>
renderSessionItem(session),
)}
</div>
)}
</div>
</ScrollArea>
+75
View File
@@ -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<string, SessionProviderGroup>();
const directoryGroupMaps = new Map<
string,
Map<string, SessionDirectoryGroup>
>();
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 (
+7 -2
View File
@@ -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<
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
{props.checked === "indeterminate" ? (
<Minus className="h-4 w-4" />
) : (
<Check className="h-4 w-4" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
+10
View File
@@ -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",
+10
View File
@@ -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": "削除するセッションを選択",
+10
View File
@@ -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": "勾選要刪除的工作階段",
+10
View File
@@ -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": "勾选要删除的会话",
+355 -5
View File
@@ -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();
});
});
+106
View File
@@ -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"]);
});
});
+12
View File
@@ -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 = () => {};
}