"use client"; import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { useEffect, useState, useCallback, useMemo } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import { Delete02Icon, Search01Icon, Notification02Icon, FileImportIcon, Folder01Icon, ArrowDown01Icon, ArrowRight01Icon, PlusSignIcon, FolderOpenIcon, } from "@hugeicons/core-free-icons"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { usePanel } from "@/hooks/usePanel"; import { useTranslation } from "@/hooks/useTranslation"; import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker"; import { ConnectionStatus } from "./ConnectionStatus"; import { ImportSessionDialog } from "./ImportSessionDialog"; import { FolderPicker } from "@/components/chat/FolderPicker"; import type { ChatSession } from "@/types"; interface ChatListPanelProps { open: boolean; width?: number; } function formatRelativeTime(dateStr: string, t: (key: import('@/i18n').TranslationKey, params?: Record) => string): string { const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z"); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMin = Math.floor(diffMs / 60000); const diffHr = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHr / 24); if (diffMin < 1) return t('chatList.justNow'); if (diffMin < 60) return t('chatList.minutesAgo', { n: diffMin }); if (diffHr < 24) return t('chatList.hoursAgo', { n: diffHr }); if (diffDay < 7) return t('chatList.daysAgo', { n: diffDay }); return date.toLocaleDateString(); } const COLLAPSED_PROJECTS_KEY = "codepilot:collapsed-projects"; const COLLAPSED_INITIALIZED_KEY = "codepilot:collapsed-initialized"; function loadCollapsedProjects(): Set { if (typeof window === 'undefined') return new Set(); try { const raw = localStorage.getItem(COLLAPSED_PROJECTS_KEY); if (raw) return new Set(JSON.parse(raw)); } catch { // ignore } return new Set(); } function saveCollapsedProjects(collapsed: Set) { localStorage.setItem(COLLAPSED_PROJECTS_KEY, JSON.stringify([...collapsed])); } interface ProjectGroup { workingDirectory: string; displayName: string; sessions: ChatSession[]; latestUpdatedAt: number; } function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] { const map = new Map(); for (const session of sessions) { const key = session.working_directory || ""; if (!map.has(key)) map.set(key, []); map.get(key)!.push(session); } const groups: ProjectGroup[] = []; for (const [wd, groupSessions] of map) { // Sort sessions within group by updated_at DESC groupSessions.sort( (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ); const displayName = wd === "" ? "No Project" : groupSessions[0]?.project_name || wd.split("/").pop() || wd; const latestUpdatedAt = new Date(groupSessions[0].updated_at).getTime(); groups.push({ workingDirectory: wd, displayName, sessions: groupSessions, latestUpdatedAt, }); } // Sort groups by most recently active first groups.sort((a, b) => b.latestUpdatedAt - a.latestUpdatedAt); return groups; } const MODE_BADGE_CONFIG = { code: { label: "Code", className: "bg-blue-500/10 text-blue-500" }, plan: { label: "Plan", className: "bg-sky-500/10 text-sky-500" }, } as const; export function ChatListPanel({ open, width }: ChatListPanelProps) { const pathname = usePathname(); const router = useRouter(); const { streamingSessionId, pendingApprovalSessionId, workingDirectory } = usePanel(); const { t } = useTranslation(); const { isElectron, openNativePicker } = useNativeFolderPicker(); const [sessions, setSessions] = useState([]); const [hoveredSession, setHoveredSession] = useState(null); const [deletingSession, setDeletingSession] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [importDialogOpen, setImportDialogOpen] = useState(false); const [folderPickerOpen, setFolderPickerOpen] = useState(false); const [collapsedProjects, setCollapsedProjects] = useState>( () => loadCollapsedProjects() ); const [hoveredFolder, setHoveredFolder] = useState(null); const [creatingChat, setCreatingChat] = useState(false); const handleFolderSelect = useCallback(async (path: string) => { try { const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ working_directory: path }), }); if (res.ok) { const data = await res.json(); window.dispatchEvent(new CustomEvent("session-created")); router.push(`/chat/${data.session.id}`); } } catch { // Silently fail } }, [router]); const openFolderPicker = useCallback(async (defaultPath?: string) => { if (isElectron) { const path = await openNativePicker({ defaultPath, title: t('folderPicker.title') }); if (path) handleFolderSelect(path); } else { setFolderPickerOpen(true); } }, [isElectron, openNativePicker, t, handleFolderSelect]); const handleNewChat = useCallback(async () => { const lastDir = workingDirectory || (typeof window !== 'undefined' ? localStorage.getItem("codepilot:last-working-directory") : null); if (!lastDir) { // No saved directory — let user pick one openFolderPicker(); return; } // Validate the saved directory still exists setCreatingChat(true); try { const checkRes = await fetch( `/api/files/browse?dir=${encodeURIComponent(lastDir)}` ); if (!checkRes.ok) { // Directory is gone — clear stale value and prompt user localStorage.removeItem("codepilot:last-working-directory"); openFolderPicker(); return; } const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ working_directory: lastDir, model: localStorage.getItem('codepilot:last-model') || '' }), }); if (!res.ok) { // Backend rejected it (e.g. INVALID_DIRECTORY) — prompt user localStorage.removeItem("codepilot:last-working-directory"); openFolderPicker(); return; } const data = await res.json(); router.push(`/chat/${data.session.id}`); window.dispatchEvent(new CustomEvent("session-created")); } catch { openFolderPicker(); } finally { setCreatingChat(false); } }, [router, workingDirectory, openFolderPicker]); const toggleProject = useCallback((wd: string) => { setCollapsedProjects((prev) => { const next = new Set(prev); if (next.has(wd)) next.delete(wd); else next.add(wd); saveCollapsedProjects(next); return next; }); }, []); const fetchSessions = useCallback(async () => { try { const res = await fetch("/api/chat/sessions"); if (res.ok) { const data = await res.json(); setSessions(data.sessions || []); } } catch { // API may not be available yet } }, []); useEffect(() => { fetchSessions(); }, [fetchSessions]); // Refresh session list when navigating useEffect(() => { fetchSessions(); }, [pathname, fetchSessions]); // Refresh session list when a session is created or updated useEffect(() => { const handler = () => fetchSessions(); window.addEventListener("session-created", handler); window.addEventListener("session-updated", handler); return () => { window.removeEventListener("session-created", handler); window.removeEventListener("session-updated", handler); }; }, [fetchSessions]); const handleDeleteSession = async ( e: React.MouseEvent, sessionId: string ) => { e.preventDefault(); e.stopPropagation(); if (!confirm("Delete this conversation?")) return; setDeletingSession(sessionId); try { const res = await fetch(`/api/chat/sessions/${sessionId}`, { method: "DELETE", }); if (res.ok) { setSessions((prev) => prev.filter((s) => s.id !== sessionId)); if (pathname === `/chat/${sessionId}`) { router.push("/chat"); } } } catch { // Silently fail } finally { setDeletingSession(null); } }; const handleCreateSessionInProject = async ( e: React.MouseEvent, workingDirectory: string ) => { e.stopPropagation(); try { const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ working_directory: workingDirectory }), }); if (res.ok) { const data = await res.json(); window.dispatchEvent(new CustomEvent("session-created")); router.push(`/chat/${data.session.id}`); } } catch { // Silently fail } }; const isSearching = searchQuery.length > 0; const filteredSessions = searchQuery ? sessions.filter( (s) => s.title.toLowerCase().includes(searchQuery.toLowerCase()) || (s.project_name && s.project_name.toLowerCase().includes(searchQuery.toLowerCase())) ) : sessions; const projectGroups = useMemo( () => groupSessionsByProject(filteredSessions), [filteredSessions] ); // On first use, auto-collapse all project groups except the most recent one useEffect(() => { if (projectGroups.length <= 1) return; if (localStorage.getItem(COLLAPSED_INITIALIZED_KEY)) return; const toCollapse = new Set( projectGroups.slice(1).map((g) => g.workingDirectory) ); setCollapsedProjects(toCollapse); saveCollapsedProjects(toCollapse); localStorage.setItem(COLLAPSED_INITIALIZED_KEY, "1"); }, [projectGroups]); if (!open) return null; return ( ); }