import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSessionSearch } from "@/hooks/useSessionSearch"; import { useTranslation } from "react-i18next"; import { useVirtualizer } from "@tanstack/react-virtual"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { Copy, RefreshCw, Search, Play, Trash2, MessageSquare, Clock, FolderOpen, X, CheckSquare, } from "lucide-react"; import { useDeleteSessionMutation, useSessionMessagesQuery, useSessionsQuery, } from "@/lib/query"; import { sessionsApi } from "@/lib/api"; import type { SessionMeta } from "@/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, } from "@/components/ui/select"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { extractErrorMessage } from "@/utils/errorUtils"; import { isMac } from "@/lib/platform"; import { ProviderIcon } from "@/components/ProviderIcon"; import { SessionItem } from "./SessionItem"; import { SessionMessageItem } from "./SessionMessageItem"; import { SessionTocDialog, SessionTocSidebar } from "./SessionToc"; import { formatSessionTitle, formatTimestamp, getBaseName, getProviderIconName, getProviderLabel, getSessionKey, } from "./utils"; type ProviderFilter = | "all" | "codex" | "claude" | "opencode" | "openclaw" | "gemini"; export function SessionManagerPage({ appId }: { appId: string }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { data, isLoading, refetch } = useSessionsQuery(); const sessions = data ?? []; const detailRef = useRef(null); const scrollContainerRef = useRef(null); const [activeMessageIndex, setActiveMessageIndex] = useState( null, ); const [tocDialogOpen, setTocDialogOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [deleteTargets, setDeleteTargets] = useState( null, ); const [selectedSessionKeys, setSelectedSessionKeys] = useState>( () => new Set(), ); const [isBatchDeleting, setIsBatchDeleting] = useState(false); const [selectionMode, setSelectionMode] = useState(false); const searchInputRef = useRef(null); const [search, setSearch] = useState(""); const [providerFilter, setProviderFilter] = useState( appId as ProviderFilter, ); const [selectedKey, setSelectedKey] = useState(null); // 使用 FlexSearch 全文搜索 const { search: searchSessions } = useSessionSearch({ sessions, providerFilter, }); const filteredSessions = useMemo(() => { return searchSessions(search); }, [searchSessions, search]); useEffect(() => { if (filteredSessions.length === 0) { setSelectedKey(null); return; } const exists = selectedKey ? filteredSessions.some( (session) => getSessionKey(session) === selectedKey, ) : false; if (!exists) { setSelectedKey(getSessionKey(filteredSessions[0])); } }, [filteredSessions, selectedKey]); const selectedSession = useMemo(() => { if (!selectedKey) return null; return ( filteredSessions.find( (session) => getSessionKey(session) === selectedKey, ) || null ); }, [filteredSessions, selectedKey]); const { data: messages = [], isLoading: isLoadingMessages } = useSessionMessagesQuery( selectedSession?.providerId, selectedSession?.sourcePath, ); const deleteSessionMutation = useDeleteSessionMutation(); const isDeleting = deleteSessionMutation.isPending || isBatchDeleting; const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 120, overscan: 5, gap: 12, }); useEffect(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } }, [selectedKey]); useEffect(() => { const validKeys = new Set( sessions.map((session) => getSessionKey(session)), ); setSelectedSessionKeys((current) => { let changed = false; const next = new Set(); current.forEach((key) => { if (validKeys.has(key)) { next.add(key); } else { changed = true; } }); return changed ? next : current; }); }, [sessions]); // 提取用户消息用于目录 const userMessagesToc = useMemo(() => { return messages .map((msg, index) => ({ msg, index })) .filter(({ msg }) => msg.role.toLowerCase() === "user") .map(({ msg, index }) => ({ index, preview: msg.content.slice(0, 50) + (msg.content.length > 50 ? "..." : ""), ts: msg.ts, })); }, [messages]); const scrollToMessage = (index: number) => { virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); setActiveMessageIndex(index); setTocDialogOpen(false); setTimeout(() => setActiveMessageIndex(null), 2000); }; const handleCopy = useCallback( async (text: string, successMessage: string) => { try { await navigator.clipboard.writeText(text); toast.success(successMessage); } catch (error) { toast.error( extractErrorMessage(error) || t("common.error", { defaultValue: "Copy failed" }), ); } }, [t], ); const handleMessageCopy = useCallback( (content: string) => { void handleCopy( content, t("sessionManager.messageCopied", { defaultValue: "已复制消息内容" }), ); }, [handleCopy, t], ); const handleResume = async () => { if (!selectedSession?.resumeCommand) return; if (!isMac()) { await handleCopy( selectedSession.resumeCommand, t("sessionManager.resumeCommandCopied"), ); return; } try { await sessionsApi.launchTerminal({ command: selectedSession.resumeCommand, cwd: selectedSession.projectDir ?? undefined, }); toast.success(t("sessionManager.terminalLaunched")); } catch (error) { const fallback = selectedSession.resumeCommand; await handleCopy(fallback, t("sessionManager.resumeFallbackCopied")); toast.error(extractErrorMessage(error) || t("sessionManager.openFailed")); } }; const handleDeleteConfirm = async () => { if (!deleteTargets || deleteTargets.length === 0 || isDeleting) { return; } 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(["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(); 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 (
e.stopPropagation()} >
{/* 主内容区域 - 左右分栏 */}
{/* 左侧会话列表 */} {isSearchOpen ? (
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); } }} />
{selectionMode && ( {t("sessionManager.exitBatchModeTooltip", { defaultValue: "退出批量管理", })} )}
) : (
{t("sessionManager.sessionList")} {filteredSessions.length}
{(selectionMode || deletableFilteredSessions.length > 0) && ( {selectionMode ? t("sessionManager.exitBatchModeTooltip", { defaultValue: "退出批量管理", }) : t("sessionManager.manageBatchTooltip", { defaultValue: "批量管理", })} )} {t("sessionManager.searchSessions")} {t("common.refresh")}
{selectionMode && (
{t("sessionManager.selectedCount", { defaultValue: "已选 {{count}} 项", count: selectedDeletableSessions.length, })} {t("sessionManager.batchModeHint", { defaultValue: "勾选要删除的会话", })}
{deletableFilteredSessions.length > 0 && ( )}
)}
)}
{isLoading ? (
) : filteredSessions.length === 0 ? (

{t("sessionManager.noSessions")}

) : (
{filteredSessions.map((session) => { const isSelected = selectedKey !== null && getSessionKey(session) === selectedKey; return ( toggleSessionChecked(session, checked) } /> ); })}
)}
{/* 右侧会话详情 */} {!selectedSession ? (

{t("sessionManager.selectSession")}

) : ( <> {/* 详情头部 */}
{/* 左侧:会话信息 */}
{getProviderLabel(selectedSession.providerId, t)}

{formatSessionTitle(selectedSession)}

{/* 元信息 */}
{formatTimestamp( selectedSession.lastActiveAt ?? selectedSession.createdAt, )}
{selectedSession.projectDir && (

{selectedSession.projectDir}

{t("sessionManager.clickToCopyPath")}

)}
{/* 右侧:操作按钮组 */}
{isMac() && ( {selectedSession.resumeCommand ? t("sessionManager.resumeTooltip", { defaultValue: "在终端中恢复此会话", }) : t("sessionManager.noResumeCommand", { defaultValue: "此会话无法恢复", })} )} {t("sessionManager.deleteTooltip", { defaultValue: "永久删除此本地会话记录", })}
{/* 恢复命令预览 */} {selectedSession.resumeCommand && (
{selectedSession.resumeCommand}
{t("sessionManager.copyCommand", { defaultValue: "复制命令", })}
)}
{/* 消息列表区域 */}
{/* 消息列表 */}
{t("sessionManager.conversationHistory", { defaultValue: "对话记录", })} {messages.length}
{isLoadingMessages ? (
) : messages.length === 0 ? (

{t("sessionManager.emptySession")}

) : (
{virtualizer .getVirtualItems() .map((virtualRow) => (
))}
)}
{/* 右侧目录 - 类似少数派 (大屏幕) */}
{/* 浮动目录按钮 (小屏幕) */}
)}
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: "删除会话", }) } cancelText={t("common.cancel", { defaultValue: "取消" })} variant="destructive" onConfirm={() => void handleDeleteConfirm()} onCancel={() => { if (!isDeleting) { setDeleteTargets(null); } }} />
); }