mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 04:22:58 +08:00
feat(sessions): 新增会话分类视图与分组管理 (#4776)
* feat(sessions): 新增会话分类查看方式 * test(sessions): 补充会话分类视图覆盖 * feat(sessions): 适配分类视图批量管理 * test(sessions): 补充分类批量选择覆盖 * feat(sessions): 新增分类视图收起状态控制 * test(sessions): 补充分类收起状态覆盖
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "削除するセッションを選択",
|
||||
|
||||
@@ -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": "勾選要刪除的工作階段",
|
||||
|
||||
@@ -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": "勾选要删除的会话",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user