mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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:
@@ -74,3 +74,12 @@ pub async fn delete_session(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_sessions(
|
||||||
|
items: Vec<session_manager::DeleteSessionRequest>,
|
||||||
|
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to delete sessions: {e}"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1021,6 +1021,7 @@ pub fn run() {
|
|||||||
commands::list_sessions,
|
commands::list_sessions,
|
||||||
commands::get_session_messages,
|
commands::get_session_messages,
|
||||||
commands::delete_session,
|
commands::delete_session,
|
||||||
|
commands::delete_sessions,
|
||||||
commands::launch_session_terminal,
|
commands::launch_session_terminal,
|
||||||
commands::get_tool_versions,
|
commands::get_tool_versions,
|
||||||
// Provider terminal
|
// Provider terminal
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||||
@@ -36,6 +36,25 @@ pub struct SessionMessage {
|
|||||||
pub ts: Option<i64>,
|
pub ts: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteSessionRequest {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub source_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteSessionOutcome {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub source_path: String,
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
||||||
let h1 = s.spawn(codex::scan_sessions);
|
let h1 = s.spawn(codex::scan_sessions);
|
||||||
@@ -99,6 +118,16 @@ pub fn delete_session(
|
|||||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec<DeleteSessionOutcome> {
|
||||||
|
collect_delete_session_outcomes(requests, |request| {
|
||||||
|
delete_session(
|
||||||
|
&request.provider_id,
|
||||||
|
&request.session_id,
|
||||||
|
&request.source_path,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn delete_session_with_root(
|
fn delete_session_with_root(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -147,6 +176,41 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, Strin
|
|||||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_delete_session_outcomes<F>(
|
||||||
|
requests: &[DeleteSessionRequest],
|
||||||
|
mut deleter: F,
|
||||||
|
) -> Vec<DeleteSessionOutcome>
|
||||||
|
where
|
||||||
|
F: FnMut(&DeleteSessionRequest) -> Result<bool, String>,
|
||||||
|
{
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.map(|request| match deleter(request) {
|
||||||
|
Ok(true) => DeleteSessionOutcome {
|
||||||
|
provider_id: request.provider_id.clone(),
|
||||||
|
session_id: request.session_id.clone(),
|
||||||
|
source_path: request.source_path.clone(),
|
||||||
|
success: true,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Ok(false) => DeleteSessionOutcome {
|
||||||
|
provider_id: request.provider_id.clone(),
|
||||||
|
session_id: request.session_id.clone(),
|
||||||
|
source_path: request.source_path.clone(),
|
||||||
|
success: false,
|
||||||
|
error: Some("Session was not deleted".to_string()),
|
||||||
|
},
|
||||||
|
Err(error) => DeleteSessionOutcome {
|
||||||
|
provider_id: request.provider_id.clone(),
|
||||||
|
session_id: request.session_id.clone(),
|
||||||
|
source_path: request.source_path.clone(),
|
||||||
|
success: false,
|
||||||
|
error: Some(error),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -175,4 +239,44 @@ mod tests {
|
|||||||
|
|
||||||
assert!(err.contains("session source not found"));
|
assert!(err.contains("session source not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_delete_collects_successes_and_failures_in_order() {
|
||||||
|
let requests = vec![
|
||||||
|
DeleteSessionRequest {
|
||||||
|
provider_id: "codex".to_string(),
|
||||||
|
session_id: "s1".to_string(),
|
||||||
|
source_path: "/tmp/s1".to_string(),
|
||||||
|
},
|
||||||
|
DeleteSessionRequest {
|
||||||
|
provider_id: "claude".to_string(),
|
||||||
|
session_id: "s2".to_string(),
|
||||||
|
source_path: "/tmp/s2".to_string(),
|
||||||
|
},
|
||||||
|
DeleteSessionRequest {
|
||||||
|
provider_id: "gemini".to_string(),
|
||||||
|
session_id: "s3".to_string(),
|
||||||
|
source_path: "/tmp/s3".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let outcomes = collect_delete_session_outcomes(&requests, |request| {
|
||||||
|
match request.session_id.as_str() {
|
||||||
|
"s1" => Ok(true),
|
||||||
|
"s2" => Err("boom".to_string()),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(outcomes.len(), 3);
|
||||||
|
assert!(outcomes[0].success);
|
||||||
|
assert_eq!(outcomes[0].error, None);
|
||||||
|
assert!(!outcomes[1].success);
|
||||||
|
assert_eq!(outcomes[1].error.as_deref(), Some("boom"));
|
||||||
|
assert!(!outcomes[2].success);
|
||||||
|
assert_eq!(
|
||||||
|
outcomes[2].error.as_deref(),
|
||||||
|
Some("Session was not deleted")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChevronRight, Clock } from "lucide-react";
|
import { ChevronRight, Clock } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -19,13 +20,21 @@ import {
|
|||||||
interface SessionItemProps {
|
interface SessionItemProps {
|
||||||
session: SessionMeta;
|
session: SessionMeta;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
selectionMode: boolean;
|
||||||
|
isChecked: boolean;
|
||||||
|
isCheckDisabled?: boolean;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
|
onToggleChecked: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionItem({
|
export function SessionItem({
|
||||||
session,
|
session,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
selectionMode,
|
||||||
|
isChecked,
|
||||||
|
isCheckDisabled = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggleChecked,
|
||||||
}: SessionItemProps) {
|
}: SessionItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const title = formatSessionTitle(session);
|
const title = formatSessionTitle(session);
|
||||||
@@ -33,46 +42,64 @@ export function SessionItem({
|
|||||||
const sessionKey = getSessionKey(session);
|
const sessionKey = getSessionKey(session);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(sessionKey)}
|
|
||||||
className={cn(
|
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
|
isSelected
|
||||||
? "bg-primary/10 border border-primary/30"
|
? "bg-primary/10 border border-primary/30"
|
||||||
: "hover:bg-muted/60 border border-transparent",
|
: "hover:bg-muted/60 border border-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
{selectionMode && (
|
||||||
<Tooltip>
|
<div className="shrink-0 pt-0.5">
|
||||||
<TooltipTrigger asChild>
|
<Checkbox
|
||||||
<span className="shrink-0">
|
checked={isChecked}
|
||||||
<ProviderIcon
|
disabled={isCheckDisabled}
|
||||||
icon={getProviderIconName(session.providerId)}
|
aria-label={t("sessionManager.selectForBatch", {
|
||||||
name={session.providerId}
|
defaultValue: "选择会话",
|
||||||
size={18}
|
})}
|
||||||
/>
|
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
|
||||||
</span>
|
/>
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<TooltipContent>
|
)}
|
||||||
{getProviderLabel(session.providerId, t)}
|
<button
|
||||||
</TooltipContent>
|
type="button"
|
||||||
</Tooltip>
|
onClick={() => onSelect(sessionKey)}
|
||||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
className="min-w-0 flex-1 text-left"
|
||||||
<ChevronRight
|
>
|
||||||
className={cn(
|
<div className="flex items-center gap-2 mb-1">
|
||||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
<Tooltip>
|
||||||
isSelected && "text-primary rotate-90",
|
<TooltipTrigger asChild>
|
||||||
)}
|
<span className="shrink-0">
|
||||||
/>
|
<ProviderIcon
|
||||||
</div>
|
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">
|
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
<span>
|
<span>
|
||||||
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
{lastActive
|
||||||
</span>
|
? formatRelativeTime(lastActive, t)
|
||||||
</div>
|
: t("common.unknown")}
|
||||||
</button>
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
X,
|
X,
|
||||||
|
CheckSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useDeleteSessionMutation,
|
useDeleteSessionMutation,
|
||||||
@@ -63,6 +65,7 @@ type ProviderFilter =
|
|||||||
|
|
||||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data, isLoading, refetch } = useSessionsQuery();
|
const { data, isLoading, refetch } = useSessionsQuery();
|
||||||
const sessions = data ?? [];
|
const sessions = data ?? [];
|
||||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -73,7 +76,14 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
);
|
);
|
||||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = 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 searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -122,6 +132,25 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
selectedSession?.sourcePath,
|
selectedSession?.sourcePath,
|
||||||
);
|
);
|
||||||
const deleteSessionMutation = useDeleteSessionMutation();
|
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(() => {
|
const userMessagesToc = useMemo(() => {
|
||||||
@@ -194,16 +223,195 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeleteTarget(null);
|
const targets = deleteTargets.filter((session) => session.sourcePath);
|
||||||
await deleteSessionMutation.mutateAsync({
|
setDeleteTargets(null);
|
||||||
providerId: deleteTarget.providerId,
|
|
||||||
sessionId: deleteTarget.sessionId,
|
if (targets.length === 0) {
|
||||||
sourcePath: deleteTarget.sourcePath,
|
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 (
|
return (
|
||||||
@@ -219,174 +427,315 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<CardHeader className="py-2 px-3 border-b">
|
<CardHeader className="py-2 px-3 border-b">
|
||||||
{isSearchOpen ? (
|
{isSearchOpen ? (
|
||||||
<div className="relative flex-1">
|
<div className="flex items-center gap-2">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
<div className="relative flex-1">
|
||||||
<Input
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
ref={searchInputRef}
|
<Input
|
||||||
value={search}
|
ref={searchInputRef}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
value={search}
|
||||||
placeholder={t("sessionManager.searchPlaceholder")}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
className="h-8 pl-8 pr-8 text-sm"
|
placeholder={t("sessionManager.searchPlaceholder")}
|
||||||
autoFocus
|
className="h-8 pl-8 pr-8 text-sm"
|
||||||
onKeyDown={(e) => {
|
autoFocus
|
||||||
if (e.key === "Escape") {
|
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);
|
setIsSearchOpen(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
onBlur={() => {
|
<X className="size-3" />
|
||||||
if (search.trim() === "") {
|
</Button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
{selectionMode && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7"
|
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"
|
||||||
onClick={() => {
|
aria-label={t("sessionManager.exitBatchModeTooltip", {
|
||||||
setIsSearchOpen(true);
|
defaultValue: "退出批量管理",
|
||||||
setTimeout(
|
})}
|
||||||
() => searchInputRef.current?.focus(),
|
onClick={exitSelectionMode}
|
||||||
0,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Search className="size-3.5" />
|
<CheckSquare className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("sessionManager.searchSessions")}
|
{t("sessionManager.exitBatchModeTooltip", {
|
||||||
|
defaultValue: "退出批量管理",
|
||||||
|
})}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Select
|
</div>
|
||||||
value={providerFilter}
|
) : (
|
||||||
onValueChange={(value) =>
|
<div className="flex flex-col gap-2">
|
||||||
setProviderFilter(value as ProviderFilter)
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
<Button
|
||||||
<ProviderIcon
|
variant="ghost"
|
||||||
icon={
|
size="icon"
|
||||||
providerFilter === "all"
|
className="size-7"
|
||||||
? "apps"
|
onClick={() => {
|
||||||
: getProviderIconName(providerFilter)
|
setIsSearchOpen(true);
|
||||||
}
|
setTimeout(
|
||||||
name={providerFilter}
|
() => searchInputRef.current?.focus(),
|
||||||
size={14}
|
0,
|
||||||
/>
|
);
|
||||||
</SelectTrigger>
|
}}
|
||||||
|
>
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{providerFilter === "all"
|
{t("sessionManager.searchSessions")}
|
||||||
? t("sessionManager.providerFilterAll")
|
|
||||||
: providerFilter}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Select
|
||||||
<TooltipTrigger asChild>
|
value={providerFilter}
|
||||||
<Button
|
onValueChange={(value) =>
|
||||||
variant="ghost"
|
setProviderFilter(value as ProviderFilter)
|
||||||
size="icon"
|
}
|
||||||
className="size-7"
|
>
|
||||||
onClick={() => void refetch()}
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<RefreshCw className="size-3.5" />
|
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||||
</Button>
|
<ProviderIcon
|
||||||
</TooltipTrigger>
|
icon={
|
||||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
providerFilter === "all"
|
||||||
</Tooltip>
|
? "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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -416,7 +765,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
key={getSessionKey(session)}
|
key={getSessionKey(session)}
|
||||||
session={session}
|
session={session}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
isChecked={selectedSessionKeys.has(
|
||||||
|
getSessionKey(session),
|
||||||
|
)}
|
||||||
|
isCheckDisabled={!session.sourcePath}
|
||||||
onSelect={setSelectedKey}
|
onSelect={setSelectedKey}
|
||||||
|
onToggleChecked={(checked) =>
|
||||||
|
toggleSessionChecked(session, checked)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -548,15 +905,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={() => setDeleteTarget(selectedSession)}
|
onClick={() =>
|
||||||
|
setDeleteTargets([selectedSession])
|
||||||
|
}
|
||||||
disabled={
|
disabled={
|
||||||
!selectedSession.sourcePath ||
|
!selectedSession.sourcePath || isDeleting
|
||||||
deleteSessionMutation.isPending
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{deleteSessionMutation.isPending
|
{isDeleting
|
||||||
? t("sessionManager.deleting", {
|
? t("sessionManager.deleting", {
|
||||||
defaultValue: "删除中...",
|
defaultValue: "删除中...",
|
||||||
})
|
})
|
||||||
@@ -685,29 +1043,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={Boolean(deleteTarget)}
|
isOpen={Boolean(deleteTargets)}
|
||||||
title={t("sessionManager.deleteConfirmTitle", {
|
title={
|
||||||
defaultValue: "删除会话",
|
deleteTargets && deleteTargets.length > 1
|
||||||
})}
|
? t("sessionManager.batchDeleteConfirmTitle", {
|
||||||
message={
|
defaultValue: "批量删除会话",
|
||||||
deleteTarget
|
})
|
||||||
? t("sessionManager.deleteConfirmMessage", {
|
: t("sessionManager.deleteConfirmTitle", {
|
||||||
defaultValue:
|
defaultValue: "删除会话",
|
||||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
})
|
||||||
title: formatSessionTitle(deleteTarget),
|
}
|
||||||
sessionId: deleteTarget.sessionId,
|
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: "取消" })}
|
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onConfirm={() => void handleDeleteConfirm()}
|
onConfirm={() => void handleDeleteConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (!deleteSessionMutation.isPending) {
|
if (!isDeleting) {
|
||||||
setDeleteTarget(null);
|
setDeleteTargets(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -613,6 +613,16 @@
|
|||||||
"searchSessions": "Search sessions",
|
"searchSessions": "Search sessions",
|
||||||
"providerFilterAll": "All",
|
"providerFilterAll": "All",
|
||||||
"sessionList": "Sessions",
|
"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...",
|
"loadingSessions": "Loading sessions...",
|
||||||
"noSessions": "No sessions found",
|
"noSessions": "No sessions found",
|
||||||
"selectSession": "Select a session to view details",
|
"selectSession": "Select a session to view details",
|
||||||
@@ -641,6 +651,12 @@
|
|||||||
"deleteConfirmAction": "Delete session",
|
"deleteConfirmAction": "Delete session",
|
||||||
"sessionDeleted": "Session deleted",
|
"sessionDeleted": "Session deleted",
|
||||||
"deleteFailed": "Failed to delete session: {{error}}",
|
"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...",
|
"loadingMessages": "Loading transcript...",
|
||||||
"emptySession": "No messages available",
|
"emptySession": "No messages available",
|
||||||
"clickToCopyPath": "Click to copy path",
|
"clickToCopyPath": "Click to copy path",
|
||||||
|
|||||||
@@ -613,6 +613,16 @@
|
|||||||
"searchSessions": "セッションを検索",
|
"searchSessions": "セッションを検索",
|
||||||
"providerFilterAll": "すべて",
|
"providerFilterAll": "すべて",
|
||||||
"sessionList": "セッション一覧",
|
"sessionList": "セッション一覧",
|
||||||
|
"manageBatchTooltip": "一括管理に入る",
|
||||||
|
"exitBatchModeTooltip": "一括管理を終了",
|
||||||
|
"batchModeHint": "削除するセッションを選択",
|
||||||
|
"selectForBatch": "セッションを選択",
|
||||||
|
"selectedCount": "{{count}} 件を選択中",
|
||||||
|
"selectAllFiltered": "一覧を全選択",
|
||||||
|
"clearFilteredSelection": "全選択を解除",
|
||||||
|
"clearSelection": "クリア",
|
||||||
|
"deleteSelected": "削除",
|
||||||
|
"batchDeleting": "削除中...",
|
||||||
"loadingSessions": "セッションを読み込み中...",
|
"loadingSessions": "セッションを読み込み中...",
|
||||||
"noSessions": "セッションが見つかりません",
|
"noSessions": "セッションが見つかりません",
|
||||||
"selectSession": "セッションを選択してください",
|
"selectSession": "セッションを選択してください",
|
||||||
@@ -641,6 +651,12 @@
|
|||||||
"deleteConfirmAction": "セッションを削除",
|
"deleteConfirmAction": "セッションを削除",
|
||||||
"sessionDeleted": "セッションを削除しました",
|
"sessionDeleted": "セッションを削除しました",
|
||||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||||
|
"batchDeleteConfirmTitle": "選択したセッションを削除",
|
||||||
|
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
|
||||||
|
"batchDeleteConfirmAction": "選択した項目を削除",
|
||||||
|
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
|
||||||
|
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
|
||||||
|
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
|
||||||
"loadingMessages": "内容を読み込み中...",
|
"loadingMessages": "内容を読み込み中...",
|
||||||
"emptySession": "表示できる内容がありません",
|
"emptySession": "表示できる内容がありません",
|
||||||
"clickToCopyPath": "クリックしてパスをコピー",
|
"clickToCopyPath": "クリックしてパスをコピー",
|
||||||
|
|||||||
@@ -613,6 +613,16 @@
|
|||||||
"searchSessions": "搜索会话",
|
"searchSessions": "搜索会话",
|
||||||
"providerFilterAll": "全部",
|
"providerFilterAll": "全部",
|
||||||
"sessionList": "会话列表",
|
"sessionList": "会话列表",
|
||||||
|
"manageBatchTooltip": "进入批量管理",
|
||||||
|
"exitBatchModeTooltip": "退出批量管理",
|
||||||
|
"batchModeHint": "勾选要删除的会话",
|
||||||
|
"selectForBatch": "选择会话",
|
||||||
|
"selectedCount": "已选 {{count}} 项",
|
||||||
|
"selectAllFiltered": "全选当前",
|
||||||
|
"clearFilteredSelection": "取消全选",
|
||||||
|
"clearSelection": "清空已选",
|
||||||
|
"deleteSelected": "批量删除",
|
||||||
|
"batchDeleting": "删除中...",
|
||||||
"loadingSessions": "加载会话中...",
|
"loadingSessions": "加载会话中...",
|
||||||
"noSessions": "未发现会话",
|
"noSessions": "未发现会话",
|
||||||
"selectSession": "请选择会话查看详情",
|
"selectSession": "请选择会话查看详情",
|
||||||
@@ -641,6 +651,12 @@
|
|||||||
"deleteConfirmAction": "删除会话",
|
"deleteConfirmAction": "删除会话",
|
||||||
"sessionDeleted": "会话已删除",
|
"sessionDeleted": "会话已删除",
|
||||||
"deleteFailed": "删除会话失败: {{error}}",
|
"deleteFailed": "删除会话失败: {{error}}",
|
||||||
|
"batchDeleteConfirmTitle": "批量删除会话",
|
||||||
|
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||||
|
"batchDeleteConfirmAction": "删除所选会话",
|
||||||
|
"batchDeleteSuccess": "已删除 {{count}} 个会话",
|
||||||
|
"batchDeleteFailed": "{{failed}} 个会话删除失败",
|
||||||
|
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
|
||||||
"loadingMessages": "加载会话内容中...",
|
"loadingMessages": "加载会话内容中...",
|
||||||
"emptySession": "该会话暂无可展示内容",
|
"emptySession": "该会话暂无可展示内容",
|
||||||
"clickToCopyPath": "点击复制路径",
|
"clickToCopyPath": "点击复制路径",
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface DeleteSessionOptions {
|
|||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteSessionResult extends DeleteSessionOptions {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const sessionsApi = {
|
export const sessionsApi = {
|
||||||
async list(): Promise<SessionMeta[]> {
|
async list(): Promise<SessionMeta[]> {
|
||||||
return await invoke("list_sessions");
|
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: {
|
async launchTerminal(options: {
|
||||||
command: string;
|
command: string;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||||
|
import { sessionsApi } from "@/lib/api/sessions";
|
||||||
import type { SessionMessage, SessionMeta } from "@/types";
|
import type { SessionMessage, SessionMeta } from "@/types";
|
||||||
import { setSessionFixtures } from "../msw/state";
|
import { setSessionFixtures } from "../msw/state";
|
||||||
|
|
||||||
@@ -62,16 +64,19 @@ const renderPage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return render(
|
return {
|
||||||
<QueryClientProvider client={client}>
|
client,
|
||||||
<SessionManagerPage appId="codex" />
|
...render(
|
||||||
</QueryClientProvider>,
|
<QueryClientProvider client={client}>
|
||||||
);
|
<SessionManagerPage appId="codex" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSearch = () => {
|
const openSearch = () => {
|
||||||
const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
|
const searchButton = Array.from(screen.getAllByRole("button")).find(
|
||||||
button.querySelector(".lucide-search"),
|
(button) => button.querySelector(".lucide-search"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!searchButton) {
|
if (!searchButton) {
|
||||||
@@ -81,10 +86,23 @@ const openSearch = () => {
|
|||||||
fireEvent.click(searchButton);
|
fireEvent.click(searchButton);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
const closeButton = Array.from(screen.getAllByRole("button")).find(
|
||||||
|
(button) => button.querySelector(".lucide-x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!closeButton) {
|
||||||
|
throw new Error("Search close button not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
};
|
||||||
|
|
||||||
describe("SessionManagerPage", () => {
|
describe("SessionManagerPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
toastSuccessMock.mockReset();
|
toastSuccessMock.mockReset();
|
||||||
toastErrorMock.mockReset();
|
toastErrorMock.mockReset();
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
|
||||||
const sessions: SessionMeta[] = [
|
const sessions: SessionMeta[] = [
|
||||||
{
|
{
|
||||||
@@ -178,11 +196,136 @@ describe("SessionManagerPage", () => {
|
|||||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText("sessionManager.selectSession"),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("sessionManager.emptySession"),
|
screen.queryByText("sessionManager.emptySession"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
expect(toastSuccessMock).toHaveBeenCalled();
|
expect(toastSuccessMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores batch delete controls when deleteMany rejects", async () => {
|
||||||
|
const deleteManySpy = vi
|
||||||
|
.spyOn(sessionsApi, "deleteMany")
|
||||||
|
.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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(toastErrorMock).toHaveBeenCalledWith("network error"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /批量删除/i }),
|
||||||
|
).not.toBeDisabled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
deleteManySpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||||
|
openSearch();
|
||||||
|
fireEvent.change(screen.getByRole("textbox"), {
|
||||||
|
target: { value: "NoSuchSession" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops hidden selections when search narrows the result set", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
||||||
|
|
||||||
|
openSearch();
|
||||||
|
fireEvent.change(screen.getByRole("textbox"), {
|
||||||
|
target: { value: "Alpha" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
closeSearch();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
|
||||||
|
const view = renderPage();
|
||||||
|
let resolveInvalidate!: () => void;
|
||||||
|
const invalidateSpy = vi
|
||||||
|
.spyOn(view.client, "invalidateQueries")
|
||||||
|
.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveInvalidate = () => resolve(undefined);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveInvalidate();
|
||||||
|
});
|
||||||
|
invalidateSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,6 +129,29 @@ export const handlers = [
|
|||||||
return success(deleteSession(providerId, sessionId, sourcePath));
|
return success(deleteSession(providerId, sessionId, sourcePath));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => {
|
||||||
|
const { items = [] } = await withJson<{
|
||||||
|
items?: {
|
||||||
|
providerId: string;
|
||||||
|
sessionId: string;
|
||||||
|
sourcePath: string;
|
||||||
|
}[];
|
||||||
|
}>(request);
|
||||||
|
|
||||||
|
return success(
|
||||||
|
items.map((item) => ({
|
||||||
|
providerId: item.providerId,
|
||||||
|
sessionId: item.sessionId,
|
||||||
|
sourcePath: item.sourcePath,
|
||||||
|
success: deleteSession(
|
||||||
|
item.providerId,
|
||||||
|
item.sessionId,
|
||||||
|
item.sourcePath,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
// MCP APIs
|
// MCP APIs
|
||||||
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||||
const { app } = await withJson<{ app: AppId }>(request);
|
const { app } = await withJson<{ app: AppId }>(request);
|
||||||
|
|||||||
Reference in New Issue
Block a user