mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(sessions): 新增会话分类视图与分组管理 (#4776)
* feat(sessions): 新增会话分类查看方式 * test(sessions): 补充会话分类视图覆盖 * feat(sessions): 适配分类视图批量管理 * test(sessions): 补充分类批量选择覆盖 * feat(sessions): 新增分类视图收起状态控制 * test(sessions): 补充分类收起状态覆盖
This commit is contained in:
@@ -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>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user