import { useState, useEffect, useCallback } from "react"; import { useConfigStore } from "@/store/config-store"; import { useTranslation } from "@/lib/i18n"; import { History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle, Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search, } from "lucide-react"; import { Modal } from "@/components/ui/Modal"; function isRecent(isoDate: string): boolean { if (!isoDate) return false; const then = new Date(isoDate).getTime(); const now = Date.now(); const threeDaysMs = 3 * 24 * 60 * 60 * 1000; return now - then < threeDaysMs; } interface SessionInfo { id: string; fileName: string; filePath: string; timestamp: string; lastActive: string; name?: string; provider?: string; model?: string; messageCount: number; duration?: number; } interface ProjectGroup { projectPath: string; projectName: string; sessions: SessionInfo[]; totalSessions: number; lastActive: string; } interface TrashEntry { trashPath: string; originalPath: string; fileName: string; trashedAt: string; sessionId: string; sessionName: string; lastActive: string; messageCount: number; } interface PreviewMessage { role: string; text: string; timestamp: string; } interface TreeNode { id: string; type: "directory" | "project" | "session"; name: string; fullPath?: string; // For directory nodes, the path prefix data?: ProjectGroup | SessionInfo; // Only for project/session nodes children?: TreeNode[]; sessionCount?: number; // Aggregate count for directory nodes lastActive?: string; // Latest activity for sorting } const SESSIONS_PER_GROUP = 50; function formatDuration(ms?: number): string { if (!ms) return "—"; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ${seconds % 60}s`; const hours = Math.floor(minutes / 60); return `${hours}h ${minutes % 60}m`; } /** Relative date: today / yesterday / Nd ago / short date */ function formatRelativeDate(iso: string, t: (key: string, ...args: string[]) => string): string { if (!iso) return "—"; const d = new Date(iso); const days = Math.floor((Date.now() - d.getTime()) / 86_400_000); if (days <= 0) return t("common.today"); if (days === 1) return t("common.yesterday"); if (days < 7) return t("common.days_ago", String(days)); return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function sessionDisplayName(s: { name?: string; fileName: string; id?: string }): string { return s.name || s.fileName.replace(/\.jsonl$/, "").split("_").pop() || s.id?.slice(0, 12) || s.fileName; } /** Full timestamp for tooltips — accepts ISO strings, guards invalid dates */ function formatFullTimestamp(iso: string): string { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d.getTime())) return "—"; return d.toLocaleString(); } /** Build a proper directory tree from project groups */ function buildDirectoryTree(groups: ProjectGroup[]): TreeNode[] { const root: TreeNode[] = []; for (const group of groups) { // Split project path into segments // e.g., "Users-mac-2312-r-workspace-wwwroot-my-notes" -> ["Users", "mac-2312-r", "workspace", "wwwroot", "my-notes"] const segments = group.projectPath.split("-").filter(Boolean); // Insert into tree, creating intermediate directories as needed let currentLevel = root; let currentPath = ""; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; if (!segment) continue; const parentPath = currentPath; currentPath = currentPath ? `${currentPath}-${segment}` : segment; const isLastSegment = i === segments.length - 1; if (isLastSegment) { // This is the actual project node - add it with sessions as children currentLevel.push({ id: group.projectPath, type: "project", name: segment, // Use just the last segment as display name data: group, children: group.sessions.map((session) => ({ id: session.id || session.fileName, type: "session" as const, name: sessionDisplayName(session), data: session, })), sessionCount: group.totalSessions, lastActive: group.lastActive, }); } else { // This is an intermediate directory - find or create it let dirNode = currentLevel.find( (node) => node.type === "directory" && node.name === segment && node.fullPath === currentPath ); if (!dirNode) { dirNode = { id: currentPath, type: "directory", name: segment, fullPath: currentPath, children: [], sessionCount: 0, lastActive: "", }; currentLevel.push(dirNode); } // Update aggregate stats dirNode.sessionCount = (dirNode.sessionCount || 0) + group.totalSessions; if (!dirNode.lastActive || group.lastActive > dirNode.lastActive) { dirNode.lastActive = group.lastActive; } currentLevel = dirNode.children!; } } } // Sort root level by lastActive descending return sortTreeByLastActive(root); } /** Recursively sort tree nodes by lastActive */ function sortTreeByLastActive(nodes: TreeNode[]): TreeNode[] { return nodes .sort((a, b) => (b.lastActive || "").localeCompare(a.lastActive || "")) .map((node) => { if (node.children && node.children.length > 0) { return { ...node, children: sortTreeByLastActive(node.children) }; } return node; }); } /** Filter directory tree by search query */ function filterDirectoryTree(nodes: TreeNode[], query: string): TreeNode[] { if (!query) return nodes; const q = query.toLowerCase(); return nodes .map((node) => { // Check if this node name matches const nameMatches = node.name.toLowerCase().includes(q); if (nameMatches) { // Node matches, include all children and mark as expanded return { ...node, _forceExpanded: true } as TreeNode & { _forceExpanded?: boolean }; } // If has children, recursively filter them if (node.children && node.children.length > 0) { const filteredChildren = filterDirectoryTree(node.children, q); if (filteredChildren.length > 0) { return { ...node, children: filteredChildren, _forceExpanded: true, } as TreeNode & { _forceExpanded?: boolean }; } } // For project nodes, check sessions if (node.type === "project" && node.data) { const group = node.data as ProjectGroup; const matchingSessions = group.sessions.filter((s) => sessionDisplayName(s).toLowerCase().includes(q) ); if (matchingSessions.length > 0) { return { ...node, children: matchingSessions.map((session) => ({ id: session.id || session.fileName, type: "session" as const, name: sessionDisplayName(session), data: session, })), _forceExpanded: true, } as TreeNode & { _forceExpanded?: boolean }; } } return null; }) .filter((n): n is TreeNode => n !== null); } /** Tree Node Component - supports directory, project, and session nodes */ function TreeNodeItem({ node, level, expandedNodes, forceExpanded, onToggle, onDelete, onPreview, t, }: { node: TreeNode & { _forceExpanded?: boolean }; level: number; expandedNodes: Set; forceExpanded?: boolean; onToggle: (id: string) => void; onDelete: (session: SessionInfo, groupPath: string) => void; onPreview: (session: SessionInfo) => void; t: (key: string, ...args: string[]) => string; }) { const isExpanded = forceExpanded || node._forceExpanded || expandedNodes.has(node.id); const hasChildren = node.children && node.children.length > 0; const isDirectory = node.type === "directory"; const isProject = node.type === "project"; const isSession = node.type === "session"; const session = isSession ? (node.data as SessionInfo) : null; const project = isProject ? (node.data as ProjectGroup) : null; // Directory and project nodes can be toggled; sessions cannot const canToggle = isDirectory || isProject; const paddingLeft = level * 16 + 12; return (
{/* Node Row */}
{ if (canToggle) { onToggle(node.id); } else if (session) { onPreview(session); } }} > {/* Expand/Collapse Icon */} {canToggle && hasChildren ? ( ) : ( )} {/* Icon */}
{isDirectory ? ( isExpanded ? ( ) : ( ) ) : isProject ? ( isExpanded ? ( ) : ( ) ) : ( )}
{/* Name and Info */}
{node.name} {(isDirectory || isProject) && node.sessionCount !== undefined && node.sessionCount > 0 && ( {node.sessionCount} )}
{isSession && session && (
{formatRelativeDate(session.timestamp, t)} {session.messageCount} {session.duration && ( {formatDuration(session.duration)} )}
)} {isProject && project && (
{t("sessions.last_active", formatRelativeDate(project.lastActive, t))}
)} {isDirectory && node.lastActive && (
{t("sessions.last_active", formatRelativeDate(node.lastActive, t))}
)}
{/* Actions - only for sessions */} {isSession && session && (
{session.provider && session.provider !== "unknown" && ( {session.provider}/{session.model?.split("-").slice(0, 2).join("-") || session.model} )} {isRecent(session.lastActive) ? ( ) : ( )}
)}
{/* Children */} {(isDirectory || isProject) && isExpanded && hasChildren && (
{node.children!.map((child) => ( ))}
)}
); } /** Find the parent project path for a session node */ function findParentProjectPath(node: TreeNode): string { // Walk up to find the nearest project ancestor // Note: In our current structure, the parent should be a project or directory // We need to pass this info differently - for now return empty string // The actual fix would require restructuring how we track parent paths return ""; } export function SessionsPage() { const { t } = useTranslation(); const { initialized } = useConfigStore(); const [tab, setTab] = useState<"sessions" | "trash">("sessions"); const [groups, setGroups] = useState([]); const [trash, setTrash] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [autoTrashed, setAutoTrashed] = useState(0); const [error, setError] = useState(null); const [filter, setFilter] = useState(""); const [deleteTarget, setDeleteTarget] = useState<{ session: SessionInfo; groupPath: string } | null>(null); const [deleting, setDeleting] = useState(false); // Trash tab state const [selectedTrash, setSelectedTrash] = useState>(new Set()); const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null); const [purging, setPurging] = useState(false); // Preview modal state const [previewTarget, setPreviewTarget] = useState(null); const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null); const [previewError, setPreviewError] = useState(false); // Tree state const [expandedNodes, setExpandedNodes] = useState>(new Set()); const loadAll = useCallback(() => { if (!initialized) return; setRefreshing(true); fetch("/api/pi/sessions/auto-trash", { method: "POST" }) .then((r) => r.json()) .catch(() => ({ moved: 0 })) .then((cleanup) => { setAutoTrashed(Number(cleanup?.moved) || 0); return Promise.all([ fetch("/api/pi/sessions").then((r) => r.json()), fetch("/api/pi/trash").then((r) => r.json()), ]); }) .then(([sessionData, trashData]) => { setGroups(sessionData); setTrash(trashData); setError(null); // Auto-expand first 3 projects const firstThree = sessionData.slice(0, 3).map((g: ProjectGroup) => g.projectPath); setExpandedNodes(new Set(firstThree)); }) .catch((e) => setError(e.message)) .finally(() => { setLoading(false); setRefreshing(false); }); }, [initialized]); useEffect(() => { loadAll(); }, [loadAll]); // Move session to trash (recoverable) const handleDelete = async () => { if (!deleteTarget) return; setDeleting(true); try { const res = await fetch( `/api/pi/session?path=${encodeURIComponent(deleteTarget.session.filePath)}`, { method: "DELETE" } ); const result = await res.json(); if (result.success) { setDeleteTarget(null); loadAll(); } else { alert(t("sessions.delete_failed")); } } catch { alert(t("sessions.delete_error")); } finally { setDeleting(false); } }; const handleRestore = async (trashPath: string) => { try { await fetch("/api/pi/session/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ trashPath }), }); } catch { /* reload below reflects the actual state */ } setSelectedTrash((prev) => { const next = new Set(prev); next.delete(trashPath); return next; }); loadAll(); }; const handlePurge = async () => { if (!purgeTarget) return; setPurging(true); const paths = purgeTarget === "batch" ? [...selectedTrash] : [purgeTarget.trashPath]; for (const p of paths) { try { await fetch(`/api/pi/trash?path=${encodeURIComponent(p)}`, { method: "DELETE" }); } catch { /* continue */ } } setPurging(false); setPurgeTarget(null); setSelectedTrash(new Set()); loadAll(); }; const handleBatchRestore = async () => { for (const p of selectedTrash) { try { await fetch("/api/pi/session/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ trashPath: p }), }); } catch { /* continue */ } } setSelectedTrash(new Set()); loadAll(); }; const openPreview = (session: SessionInfo) => { setPreviewTarget(session); setPreview(null); setPreviewError(false); fetch(`/api/pi/session-preview?path=${encodeURIComponent(session.filePath)}`) .then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then(setPreview) .catch(() => setPreviewError(true)); }; // Toggle node expansion const toggleNode = (id: string) => { setExpandedNodes((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; // Expand/collapse all - collect all directory and project node IDs const expandAll = () => { const allIds: string[] = []; const collectIds = (nodes: TreeNode[]) => { for (const node of nodes) { if (node.type === "directory" || node.type === "project") { allIds.push(node.id); } if (node.children) { collectIds(node.children); } } }; collectIds(buildDirectoryTree(groups)); setExpandedNodes(new Set(allIds)); }; const collapseAll = () => { setExpandedNodes(new Set()); }; // Build and filter tree const tree = buildDirectoryTree(groups); const filteredTree = filterDirectoryTree(tree, filter.trim()); const toggleTrashSelect = (path: string) => { setSelectedTrash((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; const allTrashSelected = trash.length > 0 && trash.every((e) => selectedTrash.has(e.trashPath)); if (loading) { return (
); } if (error) { return (

{t("sessions.load_failed")}: {error}

); } const totalSessions = groups.reduce((s, g) => s + g.totalSessions, 0); // Check if all expandable nodes (directories and projects) are expanded const allExpanded = (() => { if (groups.length === 0) return false; const totalExpandable = countExpandableNodes(tree); return expandedNodes.size >= totalExpandable && totalExpandable > 0; })(); /** Count all directory and project nodes in tree */ function countExpandableNodes(nodes: TreeNode[]): number { let count = 0; for (const node of nodes) { if (node.type === "directory" || node.type === "project") { count++; } if (node.children) { count += countExpandableNodes(node.children); } } return count; } return (

{t("sessions.title")}

{t("sessions.summary", String(totalSessions), String(groups.length))}

{/* Expand/Collapse All button */} {tab === "sessions" && groups.length > 0 && ( )}
{autoTrashed > 0 && (
{t("sessions.auto_trashed", String(autoTrashed))}
)} {/* Tabs: Sessions / Trash */}
{([ { key: "sessions" as const, label: t("sessions.tab_sessions"), count: totalSessions }, { key: "trash" as const, label: t("sessions.tab_trash"), count: trash.length }, ]).map((item) => ( ))}
{tab === "sessions" && ( <> {/* Search */}
setFilter(e.target.value)} placeholder={t("sessions.filter_placeholder")} className="w-full rounded-lg border px-4 py-2.5 pl-10 text-sm" style={{ backgroundColor: "var(--input-bg)", borderColor: "var(--input-border)", color: "var(--input-text)", }} />
{/* Tree View */}
{filteredTree.length === 0 ? (

{filter ? t("sessions.no_search_results") : t("sessions.no_sessions")}

) : (
{filteredTree.map((node) => ( setDeleteTarget({ session, groupPath })} onPreview={openPreview} t={t} /> ))}
)}
)} {tab === "trash" && (

{t("sessions.trash_desc")}

{/* Batch action bar */} {trash.length > 0 && (
{selectedTrash.size > 0 && ( <> {t("sessions.selected_count", String(selectedTrash.size))} )}
)} {trash.length === 0 ? (

{t("sessions.trash_empty")}

) : ( trash.map((entry) => (
toggleTrashSelect(entry.trashPath)} />

{entry.sessionName || entry.fileName.replace(/\.jsonl$/, "")}

{t("sessions.trashed_at", formatRelativeDate(entry.trashedAt, t))} {entry.messageCount} {entry.fileName}
)) )}
)} {/* Move-to-Trash Confirmation Modal */} !deleting && setDeleteTarget(null)} title={t("sessions.delete_title")} >

{t("sessions.delete_confirm")}

{deleteTarget?.session.name || deleteTarget?.session.fileName}

{deleteTarget?.session.messageCount && (

{t("sessions.messages", String(deleteTarget.session.messageCount))} · {formatFullTimestamp(deleteTarget.session.lastActive || deleteTarget.session.timestamp)}

)}

{t("sessions.delete_to_trash_note")}

{/* Permanent-Delete Confirmation Modal */} !purging && setPurgeTarget(null)} title={t("sessions.delete_forever")} >

{t("sessions.delete_forever_confirm")}

{purgeTarget === "batch" ? t("sessions.selected_count", String(selectedTrash.size)) : purgeTarget?.sessionName || purgeTarget?.fileName}

{/* Session Preview Modal */} setPreviewTarget(null)} title={previewTarget ? sessionDisplayName(previewTarget) : t("sessions.preview_title")} size="lg" >
{previewError ? (

{t("sessions.preview_failed")}

) : !preview ? (
) : preview.messages.length === 0 ? (

{t("sessions.preview_empty")}

) : ( <>

{t("sessions.preview_total", String(preview.total), String(preview.messages.length))}

{preview.messages.map((m, i) => (
{m.role} {m.timestamp && ( {new Date(m.timestamp).toLocaleString()} )}

{m.text || "—"}

))} )}
); }