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>
));