"use client"; import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { Trash, MagnifyingGlass, Bell, FileArrowDown, Folder, CaretDown, CaretRight, Plus, FolderOpen, UserCircle, } from "@phosphor-icons/react"; import { Columns2, X } from "lucide-react"; 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, parseDBDate } from "@/lib/utils"; import { usePanel } from "@/hooks/usePanel"; import { useSplit } from "@/hooks/useSplit"; 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 { useAssistantWorkspace } from "@/hooks/useAssistantWorkspace"; 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 = parseDBDate(dateStr); 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) => parseDBDate(b.updated_at).getTime() - parseDBDate(a.updated_at).getTime() ); const displayName = wd === "" ? "No Project" : groupSessions[0]?.project_name || wd.split("/").pop() || wd; const latestUpdatedAt = parseDBDate(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; } export function ChatListPanel({ open, width }: ChatListPanelProps) { const pathname = usePathname(); const router = useRouter(); const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory, sessionId: currentSessionId } = usePanel(); const { splitSessions, isSplitActive, activeColumnId, addToSplit, removeFromSplit, setActiveColumn, isInSplit } = useSplit(); 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 { workspacePath } = useAssistantWorkspace(); /** Read current model + provider_id from localStorage for new session creation */ const getCurrentModelAndProvider = useCallback(() => { const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : ''; const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : ''; return { model, provider_id }; }, []); const handleFolderSelect = useCallback(async (path: string) => { try { const { model, provider_id } = getCurrentModelAndProvider(); const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ working_directory: path, model, provider_id }), }); if (res.ok) { const data = await res.json(); window.dispatchEvent(new CustomEvent("session-created")); router.push(`/chat/${data.session.id}`); } } catch { // Silently fail } }, [router, getCurrentModelAndProvider]); 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 { model, provider_id } = getCurrentModelAndProvider(); const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ working_directory: lastDir, model, provider_id }), }); 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; }); }, []); // AbortController ref for cancelling in-flight requests const abortRef = useRef(null); const debounceRef = useRef | null>(null); const fetchSessions = useCallback(async () => { // Cancel any in-flight request abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; try { const res = await fetch("/api/chat/sessions", { signal: controller.signal }); if (res.ok) { const data = await res.json(); setSessions(data.sessions || []); } } catch (e) { // Ignore abort errors; log others if (e instanceof DOMException && e.name === 'AbortError') return; } }, []); const debouncedFetchSessions = useCallback(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { fetchSessions(); }, 300); }, [fetchSessions]); // Fetch on mount useEffect(() => { fetchSessions(); return () => { abortRef.current?.abort(); if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [fetchSessions]); // Refresh session list when a session is created or updated (debounced) useEffect(() => { const handler = () => debouncedFetchSessions(); window.addEventListener("session-created", handler); window.addEventListener("session-updated", handler); return () => { window.removeEventListener("session-created", handler); window.removeEventListener("session-updated", handler); }; }, [debouncedFetchSessions]); // Periodic poll to catch sessions created server-side (e.g. bridge) useEffect(() => { const interval = setInterval(() => { fetchSessions(); }, 5000); return () => clearInterval(interval); }, [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)); // Remove from split if it's there if (isInSplit(sessionId)) { removeFromSplit(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 { model, provider_id } = getCurrentModelAndProvider(); const res = await fetch("/api/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ working_directory: workingDirectory, model, provider_id }), }); 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 splitSessionIds = useMemo( () => new Set(splitSessions.map((s) => s.sessionId)), [splitSessions] ); const filteredSessions = useMemo(() => { let result = sessions; if (searchQuery) { result = result.filter( (s) => s.title.toLowerCase().includes(searchQuery.toLowerCase()) || (s.project_name && s.project_name.toLowerCase().includes(searchQuery.toLowerCase())) ); } // Exclude sessions in split group (they are shown in the split section) if (isSplitActive) { result = result.filter((s) => !splitSessionIds.has(s.id)); } return result; }, [sessions, searchQuery, isSplitActive, splitSessionIds]); 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 ( ); }