mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-28 17:20:20 +08:00
feat: add bulk delete for session manager (#1693)
* feat: add bulk delete for session manager * fix: address batch delete review issues * fix: keep session list in sync after batch delete
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ChevronRight, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -19,13 +20,21 @@ import {
|
||||
interface SessionItemProps {
|
||||
session: SessionMeta;
|
||||
isSelected: boolean;
|
||||
selectionMode: boolean;
|
||||
isChecked: boolean;
|
||||
isCheckDisabled?: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
onToggleChecked: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function SessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
selectionMode,
|
||||
isChecked,
|
||||
isCheckDisabled = false,
|
||||
onSelect,
|
||||
onToggleChecked,
|
||||
}: SessionItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const title = formatSessionTitle(session);
|
||||
@@ -33,46 +42,64 @@ export function SessionItem({
|
||||
const sessionKey = getSessionKey(session);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||
"flex items-start gap-2 rounded-lg px-3 py-2.5 transition-all group",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/60 border border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className="shrink-0 pt-0.5">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={isCheckDisabled}
|
||||
aria-label={t("sessionManager.selectForBatch", {
|
||||
defaultValue: "选择会话",
|
||||
})}
|
||||
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive
|
||||
? formatRelativeTime(lastActive, t)
|
||||
: t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Copy,
|
||||
RefreshCw,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Clock,
|
||||
FolderOpen,
|
||||
X,
|
||||
CheckSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteSessionMutation,
|
||||
@@ -63,6 +65,7 @@ type ProviderFilter =
|
||||
|
||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, refetch } = useSessionsQuery();
|
||||
const sessions = data ?? [];
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -73,7 +76,14 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
|
||||
const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -122,6 +132,25 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
selectedSession?.sourcePath,
|
||||
);
|
||||
const deleteSessionMutation = useDeleteSessionMutation();
|
||||
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
|
||||
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(
|
||||
sessions.map((session) => getSessionKey(session)),
|
||||
);
|
||||
setSelectedSessionKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
current.forEach((key) => {
|
||||
if (validKeys.has(key)) {
|
||||
next.add(key);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
@@ -194,16 +223,195 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
||||
if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: deleteTarget.providerId,
|
||||
sessionId: deleteTarget.sessionId,
|
||||
sourcePath: deleteTarget.sourcePath,
|
||||
const targets = deleteTargets.filter((session) => session.sourcePath);
|
||||
setDeleteTargets(null);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 1) {
|
||||
const [target] = targets;
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: target.providerId,
|
||||
sessionId: target.sessionId,
|
||||
sourcePath: target.sourcePath!,
|
||||
});
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(getSessionKey(target));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchDeleting(true);
|
||||
try {
|
||||
const results = await sessionsApi.deleteMany(
|
||||
targets.map((session) => ({
|
||||
providerId: session.providerId,
|
||||
sessionId: session.sessionId,
|
||||
sourcePath: session.sourcePath!,
|
||||
})),
|
||||
);
|
||||
|
||||
const deletedKeys = results
|
||||
.filter((result) => result.success)
|
||||
.map(
|
||||
(result) =>
|
||||
`${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`,
|
||||
);
|
||||
|
||||
const failedErrors = results
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error || t("common.unknown"));
|
||||
|
||||
if (deletedKeys.length > 0) {
|
||||
const deletedKeySet = new Set(deletedKeys);
|
||||
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
|
||||
(current ?? []).filter(
|
||||
(session) => !deletedKeySet.has(getSessionKey(session)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
results
|
||||
.filter((result) => result.success)
|
||||
.forEach((result) => {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["sessionMessages", result.providerId, result.sourcePath],
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
deletedKeys.forEach((key) => next.delete(key));
|
||||
return next;
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
|
||||
if (deletedKeys.length > 0) {
|
||||
toast.success(
|
||||
t("sessionManager.batchDeleteSuccess", {
|
||||
defaultValue: "已删除 {{count}} 个会话",
|
||||
count: deletedKeys.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (failedErrors.length > 0) {
|
||||
toast.error(
|
||||
t("sessionManager.batchDeleteFailed", {
|
||||
defaultValue: "{{failed}} 个会话删除失败",
|
||||
failed: failedErrors.length,
|
||||
}),
|
||||
{
|
||||
description: failedErrors[0],
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractErrorMessage(error) ||
|
||||
t("sessionManager.batchDeleteRequestFailed", {
|
||||
defaultValue: "批量删除失败,请稍后重试",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsBatchDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletableFilteredSessions = useMemo(
|
||||
() => filteredSessions.filter((session) => Boolean(session.sourcePath)),
|
||||
[filteredSessions],
|
||||
);
|
||||
|
||||
const selectedSessions = useMemo(
|
||||
() =>
|
||||
sessions.filter((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
),
|
||||
[sessions, selectedSessionKeys],
|
||||
);
|
||||
|
||||
const selectedDeletableSessions = useMemo(
|
||||
() => selectedSessions.filter((session) => Boolean(session.sourcePath)),
|
||||
[selectedSessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMode) return;
|
||||
|
||||
const visibleKeys = new Set(
|
||||
deletableFilteredSessions.map((session) => getSessionKey(session)),
|
||||
);
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
|
||||
current.forEach((key) => {
|
||||
if (visibleKeys.has(key)) {
|
||||
next.add(key);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [deletableFilteredSessions, selectionMode]);
|
||||
|
||||
const allFilteredSelected =
|
||||
deletableFilteredSessions.length > 0 &&
|
||||
deletableFilteredSessions.every((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
);
|
||||
|
||||
const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
|
||||
if (!session.sourcePath) return;
|
||||
const key = getSessionKey(session);
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelectAll = () => {
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (allFilteredSelected) {
|
||||
deletableFilteredSessions.forEach((session) =>
|
||||
next.delete(getSessionKey(session)),
|
||||
);
|
||||
} else {
|
||||
deletableFilteredSessions.forEach((session) =>
|
||||
next.add(getSessionKey(session)),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const openBatchDeleteDialog = () => {
|
||||
if (selectedDeletableSessions.length === 0) return;
|
||||
setDeleteTargets(selectedDeletableSessions);
|
||||
};
|
||||
|
||||
const exitSelectionMode = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedSessionKeys(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -219,174 +427,315 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<CardHeader className="py-2 px-3 border-b">
|
||||
{isSearchOpen ? (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectionMode && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
className="size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
||||
aria-label={t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})}
|
||||
onClick={exitSelectionMode}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
<CheckSquare className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.searchSessions")}
|
||||
{t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CardTitle className="text-sm font-medium whitespace-nowrap">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{(selectionMode ||
|
||||
deletableFilteredSessions.length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={selectionMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={
|
||||
selectionMode
|
||||
? "size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
||||
: "size-7"
|
||||
}
|
||||
aria-label={
|
||||
selectionMode
|
||||
? t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})
|
||||
: t("sessionManager.manageBatchTooltip", {
|
||||
defaultValue: "批量管理",
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectionMode
|
||||
? t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})
|
||||
: t("sessionManager.manageBatchTooltip", {
|
||||
defaultValue: "批量管理",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
{t("sessionManager.searchSessions")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon icon="apps" name="all" size={14} />
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="apps"
|
||||
name="all"
|
||||
size={14}
|
||||
/>
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className="grid gap-3 rounded-md border bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("sessionManager.selectedCount", {
|
||||
defaultValue: "已选 {{count}} 项",
|
||||
count: selectedDeletableSessions.length,
|
||||
})}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{t("sessionManager.batchModeHint", {
|
||||
defaultValue: "勾选要删除的会话",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 min-[520px]:grid-cols-[minmax(0,1fr)_auto] min-[520px]:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{deletableFilteredSessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
||||
onClick={handleToggleSelectAll}
|
||||
>
|
||||
{allFilteredSelected
|
||||
? t("sessionManager.clearFilteredSelection", {
|
||||
defaultValue: "取消全选",
|
||||
})
|
||||
: t("sessionManager.selectAllFiltered", {
|
||||
defaultValue: "全选当前",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
||||
onClick={() => setSelectedSessionKeys(new Set())}
|
||||
>
|
||||
{t("sessionManager.clearSelection", {
|
||||
defaultValue: "清空已选",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
|
||||
onClick={openBatchDeleteDialog}
|
||||
disabled={
|
||||
isDeleting ||
|
||||
selectedDeletableSessions.length === 0
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="text-xs">
|
||||
{isBatchDeleting
|
||||
? t("sessionManager.batchDeleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
: t("sessionManager.deleteSelected", {
|
||||
defaultValue: "批量删除",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -416,7 +765,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
key={getSessionKey(session)}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedSessionKeys.has(
|
||||
getSessionKey(session),
|
||||
)}
|
||||
isCheckDisabled={!session.sourcePath}
|
||||
onSelect={setSelectedKey}
|
||||
onToggleChecked={(checked) =>
|
||||
toggleSessionChecked(session, checked)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -548,15 +905,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1.5"
|
||||
onClick={() => setDeleteTarget(selectedSession)}
|
||||
onClick={() =>
|
||||
setDeleteTargets([selectedSession])
|
||||
}
|
||||
disabled={
|
||||
!selectedSession.sourcePath ||
|
||||
deleteSessionMutation.isPending
|
||||
!selectedSession.sourcePath || isDeleting
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{deleteSessionMutation.isPending
|
||||
{isDeleting
|
||||
? t("sessionManager.deleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
@@ -685,29 +1043,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={Boolean(deleteTarget)}
|
||||
title={t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
message={
|
||||
deleteTarget
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTarget),
|
||||
sessionId: deleteTarget.sessionId,
|
||||
isOpen={Boolean(deleteTargets)}
|
||||
title={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmTitle", {
|
||||
defaultValue: "批量删除会话",
|
||||
})
|
||||
: t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})
|
||||
}
|
||||
message={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
count: deleteTargets.length,
|
||||
})
|
||||
: deleteTargets?.[0]
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTargets[0]),
|
||||
sessionId: deleteTargets[0].sessionId,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmAction", {
|
||||
defaultValue: "删除所选会话",
|
||||
})
|
||||
: t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||
variant="destructive"
|
||||
onConfirm={() => void handleDeleteConfirm()}
|
||||
onCancel={() => {
|
||||
if (!deleteSessionMutation.isPending) {
|
||||
setDeleteTarget(null);
|
||||
if (!isDeleting) {
|
||||
setDeleteTargets(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
"sessionList": "Sessions",
|
||||
"manageBatchTooltip": "Enter batch management",
|
||||
"exitBatchModeTooltip": "Exit batch management",
|
||||
"batchModeHint": "Select sessions to delete",
|
||||
"selectForBatch": "Select session",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectAllFiltered": "Select all",
|
||||
"clearFilteredSelection": "Clear selection",
|
||||
"clearSelection": "Clear",
|
||||
"deleteSelected": "Delete",
|
||||
"batchDeleting": "Deleting...",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"noSessions": "No sessions found",
|
||||
"selectSession": "Select a session to view details",
|
||||
@@ -641,6 +651,12 @@
|
||||
"deleteConfirmAction": "Delete session",
|
||||
"sessionDeleted": "Session deleted",
|
||||
"deleteFailed": "Failed to delete session: {{error}}",
|
||||
"batchDeleteConfirmTitle": "Delete selected sessions",
|
||||
"batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.",
|
||||
"batchDeleteConfirmAction": "Delete selected",
|
||||
"batchDeleteSuccess": "Deleted {{count}} sessions",
|
||||
"batchDeleteFailed": "{{failed}} sessions could not be deleted",
|
||||
"batchDeleteRequestFailed": "Batch delete failed. Please try again later.",
|
||||
"loadingMessages": "Loading transcript...",
|
||||
"emptySession": "No messages available",
|
||||
"clickToCopyPath": "Click to copy path",
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
"sessionList": "セッション一覧",
|
||||
"manageBatchTooltip": "一括管理に入る",
|
||||
"exitBatchModeTooltip": "一括管理を終了",
|
||||
"batchModeHint": "削除するセッションを選択",
|
||||
"selectForBatch": "セッションを選択",
|
||||
"selectedCount": "{{count}} 件を選択中",
|
||||
"selectAllFiltered": "一覧を全選択",
|
||||
"clearFilteredSelection": "全選択を解除",
|
||||
"clearSelection": "クリア",
|
||||
"deleteSelected": "削除",
|
||||
"batchDeleting": "削除中...",
|
||||
"loadingSessions": "セッションを読み込み中...",
|
||||
"noSessions": "セッションが見つかりません",
|
||||
"selectSession": "セッションを選択してください",
|
||||
@@ -641,6 +651,12 @@
|
||||
"deleteConfirmAction": "セッションを削除",
|
||||
"sessionDeleted": "セッションを削除しました",
|
||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||
"batchDeleteConfirmTitle": "選択したセッションを削除",
|
||||
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
|
||||
"batchDeleteConfirmAction": "選択した項目を削除",
|
||||
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
|
||||
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
|
||||
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
|
||||
"loadingMessages": "内容を読み込み中...",
|
||||
"emptySession": "表示できる内容がありません",
|
||||
"clickToCopyPath": "クリックしてパスをコピー",
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
"sessionList": "会话列表",
|
||||
"manageBatchTooltip": "进入批量管理",
|
||||
"exitBatchModeTooltip": "退出批量管理",
|
||||
"batchModeHint": "勾选要删除的会话",
|
||||
"selectForBatch": "选择会话",
|
||||
"selectedCount": "已选 {{count}} 项",
|
||||
"selectAllFiltered": "全选当前",
|
||||
"clearFilteredSelection": "取消全选",
|
||||
"clearSelection": "清空已选",
|
||||
"deleteSelected": "批量删除",
|
||||
"batchDeleting": "删除中...",
|
||||
"loadingSessions": "加载会话中...",
|
||||
"noSessions": "未发现会话",
|
||||
"selectSession": "请选择会话查看详情",
|
||||
@@ -641,6 +651,12 @@
|
||||
"deleteConfirmAction": "删除会话",
|
||||
"sessionDeleted": "会话已删除",
|
||||
"deleteFailed": "删除会话失败: {{error}}",
|
||||
"batchDeleteConfirmTitle": "批量删除会话",
|
||||
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
"batchDeleteConfirmAction": "删除所选会话",
|
||||
"batchDeleteSuccess": "已删除 {{count}} 个会话",
|
||||
"batchDeleteFailed": "{{failed}} 个会话删除失败",
|
||||
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
|
||||
"loadingMessages": "加载会话内容中...",
|
||||
"emptySession": "该会话暂无可展示内容",
|
||||
"clickToCopyPath": "点击复制路径",
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface DeleteSessionOptions {
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionResult extends DeleteSessionOptions {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
return await invoke("list_sessions");
|
||||
@@ -28,6 +33,12 @@ export const sessionsApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async deleteMany(
|
||||
items: DeleteSessionOptions[],
|
||||
): Promise<DeleteSessionResult[]> {
|
||||
return await invoke("delete_sessions", { items });
|
||||
},
|
||||
|
||||
async launchTerminal(options: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user