import { memo, useEffect, useMemo, useState, } from "react"; import { Archive, ArchiveRestore, Folder, MoreHorizontal, Pencil, Pin, PinOff, Plus, Trash2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { deriveTitle, relativeTime } from "@/lib/format"; import { COLLAPSED_CHATS_VISIBLE_COUNT, displayTitle, groupSessions, isCollapsedProject, isFoldableChatsGroup, isFoldedChatsGroup, limitGroups, visibleSessionsForGroup, type ChatGroupLabels, } from "@/lib/chat-groups"; import { cn } from "@/lib/utils"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; const INITIAL_VISIBLE_SESSIONS = 160; const VISIBLE_SESSIONS_INCREMENT = 160; const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]"; const ACTION_MENU_ITEM_CLASS = "grid w-[7.75rem] grid-cols-[1rem_minmax(0,1fr)] items-center gap-2"; interface ChatListProps { sessions: ChatSummary[]; activeKey: string | null; onSelect: (key: string) => void; onRequestDelete: (key: string, label: string) => void; onTogglePin: (key: string) => void; onRequestRename: (key: string, label: string) => void; onToggleArchive: (key: string) => void; onToggleGroup?: (groupId: string) => void; onRequestRenameProject?: (projectKey: string, label: string) => void; onNewChatInProject?: (projectPath: string, projectName: string) => void; pinnedKeys?: string[]; archivedKeys?: string[]; titleOverrides?: Record; projectNameOverrides?: Record; collapsedGroups?: Record; runningChatIds?: string[]; updatedChatIds?: string[]; density?: SidebarDensity; showPreviews?: boolean; showTimestamps?: boolean; sort?: SidebarSortMode; showArchived?: boolean; defaultWorkspacePath?: string | null; actionMenuPortalContainer?: HTMLElement | null; loading?: boolean; emptyLabel?: string; } export const ChatList = memo(function ChatList({ sessions, activeKey, onSelect, onRequestDelete, onTogglePin, onRequestRename, onToggleArchive, onToggleGroup, onRequestRenameProject, onNewChatInProject, pinnedKeys = [], archivedKeys = [], titleOverrides = {}, projectNameOverrides = {}, collapsedGroups = {}, runningChatIds = [], updatedChatIds = [], density = "comfortable", showPreviews = false, showTimestamps = false, sort = "updated_desc", showArchived = false, defaultWorkspacePath, actionMenuPortalContainer, loading, emptyLabel, }: ChatListProps) { const { t } = useTranslation(); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const labels = useMemo(() => ({ pinned: t("chat.groups.pinned"), all: t("chat.groups.all"), today: t("chat.groups.today"), yesterday: t("chat.groups.yesterday"), earlier: t("chat.groups.earlier"), archived: t("chat.groups.archived"), projects: t("chat.groups.projects"), fallbackTitle: t("chat.newChat"), }), [t]); const groups = useMemo( () => groupSessions(sessions, labels, { pinnedKeys, archivedKeys, titleOverrides, projectNameOverrides, showArchived, sort, defaultWorkspacePath, }), [ archivedKeys, labels, pinnedKeys, sessions, showArchived, sort, titleOverrides, projectNameOverrides, defaultWorkspacePath, ], ); const limitedGroups = useMemo( () => limitGroups(groups, visibleLimit, activeKey, collapsedGroups), [activeKey, collapsedGroups, groups, visibleLimit], ); const totalSessionCount = useMemo( () => groups.reduce( (total, group) => total + (isCollapsedProject(group, collapsedGroups) ? 0 : group.sessions.length), 0, ), [collapsedGroups, groups], ); const visibleSessionCount = useMemo( () => limitedGroups.reduce((total, group) => total + group.sessions.length, 0), [limitedGroups], ); const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount); useEffect(() => { setVisibleLimit(INITIAL_VISIBLE_SESSIONS); }, [showArchived, sort]); if (loading && sessions.length === 0) { return (
{t("chat.loading")}
); } if (sessions.length === 0) { return (
{emptyLabel ?? t("chat.noSessions")}
); } const pinned = new Set(pinnedKeys); const archived = new Set(archivedKeys); const running = new Set(runningChatIds); const updated = new Set(updatedChatIds); const compact = density === "compact"; const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); return (
{limitedGroups.map((group, index) => { const foldableChatsGroup = isFoldableChatsGroup(group); const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups); const visibleSessions = visibleSessionsForGroup( group, activeKey, collapsedGroups, ); const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length); const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT; return (
{index === firstProjectGroupIndex ? (
{labels.projects}
) : null} {group.kind === "project" ? ( onToggleGroup?.(group.id)} onRequestRename={ group.projectKey && onRequestRenameProject ? () => onRequestRenameProject(group.projectKey ?? "", group.label) : undefined } onNewChat={ group.projectPath && onNewChatInProject ? () => onNewChatInProject(group.projectPath ?? "", group.label) : undefined } actionMenuPortalContainer={actionMenuPortalContainer} updatedAt={showTimestamps ? group.updatedAt : null} /> ) : ( )} {group.kind === "project" && collapsedGroups[group.id] ? null : (
    {visibleSessions.map((s) => { const active = s.key === activeKey; const fallbackTitle = t("chat.fallbackTitle", { id: s.chatId.slice(0, 6), }); const generatedTitle = s.title?.trim() || ""; const title = displayTitle(s, titleOverrides, t("chat.newChat")); const tooltipTitle = titleOverrides[s.key]?.trim() || generatedTitle || deriveTitle(s.preview, fallbackTitle); const isPinned = pinned.has(s.key); const isArchived = archived.has(s.key); const preview = s.preview.trim(); const showPreview = showPreviews && preview && preview !== title; const timestamp = showTimestamps ? relativeTime(s.updatedAt ?? s.createdAt) : ""; const projectMode = group.kind === "project"; const activityState = running.has(s.chatId) ? "running" : updated.has(s.chatId) && !active ? "updated" : null; return (
  • event.preventDefault()} > onTogglePin(s.key)} className={ACTION_MENU_ITEM_CLASS} > {isPinned ? ( ) : ( )} {isPinned ? t("chat.unpin") : t("chat.pin")} onRequestRename(s.key, title)} className={ACTION_MENU_ITEM_CLASS} > {t("chat.rename")} onToggleArchive(s.key)} className={ACTION_MENU_ITEM_CLASS} > {isArchived ? ( ) : ( )} {isArchived ? t("chat.unarchive") : t("chat.archive")} { window.setTimeout(() => onRequestDelete(s.key, title), 0); }} className={cn( ACTION_MENU_ITEM_CLASS, "text-destructive focus:text-destructive", )} > {t("chat.delete")}
  • ); })}
)} {foldableChatsGroup && canToggleFold ? ( onToggleGroup?.(group.id)} /> ) : null}
); })} {hiddenSessionCount > 0 ? (
) : null}
); }); function ProjectGroupHeader({ label, path, collapsed, onToggle, onRequestRename, onNewChat, actionMenuPortalContainer, updatedAt, }: { label: string; path?: string; collapsed: boolean; onToggle: () => void; onRequestRename?: () => void; onNewChat?: () => void; actionMenuPortalContainer?: HTMLElement | null; updatedAt?: string | null; }) { const { t } = useTranslation(); return (
{updatedAt ? ( {relativeTime(updatedAt)} ) : null} {onRequestRename ? ( event.stopPropagation()} > event.preventDefault()} > {t("chat.rename")} ) : null} {onNewChat ? ( ) : null}
); } function ChatsGroupHeader({ label }: { label: string }) { return (
{label}
); } function PinnedChatIndicator({ label }: { label: string }) { return ( ); } function ChatsFoldFooter({ folded, hiddenCount, onToggle, }: { folded: boolean; hiddenCount: number; onToggle: () => void; }) { const { t, i18n } = useTranslation(); const collapsedFallback = i18n.resolvedLanguage?.startsWith("zh") ? `已折叠 ${hiddenCount} 个对话` : `${hiddenCount} hidden chats`; return (
); } function SessionActivityIndicator({ state, }: { state: "running" | "updated" | null; }) { const { t } = useTranslation(); if (state === "running") { const label = t("chat.activity.running"); return ( ); } if (state === "updated") { const label = t("chat.activity.updated"); return ( ); } return