import { useEffect, useMemo, useRef, useState } from "react"; import { useSessionSearch } from "@/hooks/useSessionSearch"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Copy, RefreshCw, Search, Play, Trash2, MessageSquare, Clock, FolderOpen, X, } 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 { data, isLoading, refetch } = useSessionsQuery(); const sessions = data ?? []; const detailRef = useRef(null); const messagesEndRef = useRef(null); const messageRefs = useRef>(new Map()); const [activeMessageIndex, setActiveMessageIndex] = useState( null, ); const [tocDialogOpen, setTocDialogOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); 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 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) => { const el = messageRefs.current.get(index); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); setActiveMessageIndex(index); setTocDialogOpen(false); // 关闭弹窗 // 清除高亮状态 setTimeout(() => setActiveMessageIndex(null), 2000); } }; // 清理定时器 useEffect(() => { return () => { // 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。 // 如果要严格清理,需要用 useRef 存 timer id。 // 但对于 2秒的高亮清除,通常不清理也没大问题。 // 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理) }; }, []); const handleCopy = 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" }), ); } }; 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 (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) { return; } setDeleteTarget(null); await deleteSessionMutation.mutateAsync({ providerId: deleteTarget.providerId, sessionId: deleteTarget.sessionId, sourcePath: deleteTarget.sourcePath, }); }; return (
{/* 主内容区域 - 左右分栏 */}
{/* 左侧会话列表 */} {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); } }} />
) : (
{t("sessionManager.sessionList")} {filteredSessions.length}
{t("sessionManager.searchSessions")} {t("common.refresh")}
)}
{isLoading ? (
) : filteredSessions.length === 0 ? (

{t("sessionManager.noSessions")}

) : (
{filteredSessions.map((session) => { const isSelected = selectedKey !== null && getSessionKey(session) === selectedKey; return ( ); })}
)}
{/* 右侧会话详情 */} {!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")}

) : (
{messages.map((message, index) => ( { if (el) messageRefs.current.set(index, el); }} onCopy={(content) => handleCopy( content, t("sessionManager.messageCopied", { defaultValue: "已复制消息内容", }), ) } /> ))}
)}
{/* 右侧目录 - 类似少数派 (大屏幕) */}
{/* 浮动目录按钮 (小屏幕) */} )}
void handleDeleteConfirm()} onCancel={() => { if (!deleteSessionMutation.isPending) { setDeleteTarget(null); } }} /> ); }