import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { Archive, ArchiveRestore, ChevronDown, Folder, ListChecks, MessageCircleDashed, MoreHorizontal, MoveRight, PanelsTopLeft, Pencil, Pin, PinOff, Plus, Square, SquareCheckBig, SquareMinus, Trash2, Ungroup, Unplug, X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model"; import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight"; import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { COLLAPSED_CHATS_VISIBLE_COUNT, displayTitle, groupSessions, isCollapsedProject, isFoldableChatsGroup, isFoldedChatsGroup, limitGroups, visibleSessionsForGroup, type ChatGroupLabels, } from "@/lib/chat-groups"; import { deriveTemporaryChatTitle } from "@/lib/temporary-chat"; 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-[11rem] min-w-[11rem] whitespace-nowrap"; interface PaneGroupTarget { key: string; title: string; paneCount: number; atCapacity: boolean; } export interface SidebarPaneGroup { tabKey: string; title: string; activePaneKey: string; visible?: boolean; panes: Array<{ key: string; chatId: string; title: string; }>; } export interface SidebarDeleteItem { key: string; label: string; } interface ChatListProps { sessions: ChatSummary[]; temporarySessions?: ChatSummary[]; activeKey: string | null; onSelect: (key: string) => void; onCloseTemporaryChat?: (key: string) => void; onRequestDelete: (key: string, label: string) => void; onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void; onTogglePin: (key: string) => void; onRequestRename: (key: string, label: string) => void; onRequestRenameTab?: (key: string, label: string) => void; onToggleArchive: (key: string) => void; paneGroups?: Record; onSelectPane?: (tabKey: string, paneKey: string) => void; onCreateTab?: (tabKey: string) => void; onDetachPane?: (tabKey: string, paneKey: string) => void; onDissolveTab?: (tabKey: string) => void; onAttachPane?: ( paneKey: string, tabKey: string, ) => void; onToggleGroup?: (groupId: string) => void; onRequestRenameProject?: (projectKey: string, label: string) => void; onNewChatInProject?: (projectPath: string, projectName: string) => void; pinnedKeys?: string[]; archivedKeys?: string[]; pinnedPaneKeys?: string[]; archivedPaneKeys?: string[]; sessionOrder?: 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, temporarySessions = [], activeKey, onSelect, onCloseTemporaryChat, onRequestDelete, onRequestDeleteMany, onTogglePin, onRequestRename, onRequestRenameTab, onToggleArchive, paneGroups = {}, onSelectPane, onCreateTab, onDetachPane, onDissolveTab, onAttachPane, onToggleGroup, onRequestRenameProject, onNewChatInProject, pinnedKeys = [], archivedKeys = [], pinnedPaneKeys = [], archivedPaneKeys = [], sessionOrder = [], 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 tabRowRefs = useRef(new Map()); const pendingTabRectsRef = useRef | null>(null); const tabLayoutAnimationsRef = useRef(new Map()); const [collapsedPaneGroups, setCollapsedPaneGroups] = useState>( () => new Set(), ); const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( () => new Set(), ); const deleteItemsByKey = useMemo(() => { const items = new Map(); for (const group of Object.values(paneGroups)) { for (const pane of group.panes) { items.set(pane.key, { key: pane.key, label: pane.title }); } } for (const session of sessions) { if (items.has(session.key)) continue; items.set(session.key, { key: session.key, label: displayTitle(session, titleOverrides, t("chat.newChat")), }); } return items; }, [paneGroups, sessions, t, titleOverrides]); const paneGroupTargets = useMemo(() => Array.from(new Map( Object.values(paneGroups) .filter((group) => group.visible ?? group.panes.length > 1) .map((group) => [group.tabKey, { key: group.tabKey, title: group.title, paneCount: group.panes.length, atCapacity: group.panes.length >= MAX_WORKBENCH_PANES, }]), ).values()), [paneGroups]); 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, sessionOrder, showArchived, sort, defaultWorkspacePath, }), [ archivedKeys, labels, pinnedKeys, sessions, showArchived, sort, titleOverrides, projectNameOverrides, sessionOrder, 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 pinned = useMemo(() => new Set(pinnedKeys), [pinnedKeys]); const archived = useMemo(() => new Set(archivedKeys), [archivedKeys]); const pinnedPanes = useMemo(() => new Set(pinnedPaneKeys), [pinnedPaneKeys]); const archivedPanes = useMemo(() => new Set(archivedPaneKeys), [archivedPaneKeys]); const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount); useEffect(() => { setVisibleLimit(INITIAL_VISIBLE_SESSIONS); }, [showArchived, sort]); useEffect(() => { if (!deleteSelectionMode) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setDeleteSelectionMode(false); setSelectedDeleteKeys(new Set()); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [deleteSelectionMode]); useEffect(() => { setCollapsedPaneGroups((current) => { const next = new Set(Array.from(current).filter((key) => ( (paneGroups[key]?.panes.length ?? 0) > 1 ))); if (next.size === current.size && Array.from(next).every((key) => current.has(key))) { return current; } return next; }); }, [paneGroups]); const measureTabRows = useCallback(() => { const rects = new Map(); for (const [key, row] of tabRowRefs.current) { rects.set(key, row.getBoundingClientRect()); } return rects; }, []); const captureTabLayout = useCallback(() => { for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); tabLayoutAnimationsRef.current.clear(); pendingTabRectsRef.current = measureTabRows(); }, [measureTabRows]); const togglePaneGroup = useCallback((key: string) => { captureTabLayout(); setCollapsedPaneGroups((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, [captureTabLayout]); useLayoutEffect(() => { const previousRects = pendingTabRectsRef.current; if (!previousRects) return; pendingTabRectsRef.current = null; const nextRects = measureTabRows(); const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduceMotion) return; for (const [key, nextRect] of nextRects) { const previousRect = previousRects.get(key); const row = tabRowRefs.current.get(key); if (!previousRect || !row || typeof row.animate !== "function") continue; const deltaY = previousRect.top - nextRect.top; if (Math.abs(deltaY) < 0.5) continue; const animation = row.animate( [ { transform: `translateY(${deltaY}px)` }, { transform: "translateY(0)" }, ], { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)", }, ); tabLayoutAnimationsRef.current.set(key, animation); animation.addEventListener("finish", () => { if (tabLayoutAnimationsRef.current.get(key) === animation) { tabLayoutAnimationsRef.current.delete(key); } }, { once: true }); } }, [collapsedPaneGroups, measureTabRows]); useEffect(() => () => { for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); }, []); if (loading && sessions.length === 0 && temporarySessions.length === 0) { return (
{t("chat.loading")}
); } if (sessions.length === 0 && temporarySessions.length === 0) { return (
{emptyLabel ?? t("chat.noSessions")}
); } const running = new Set(runningChatIds); const updated = new Set(updatedChatIds); const compact = density === "compact"; const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); const beginDeleteSelection = (keys: string[]) => { setDeleteSelectionMode(true); setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key)))); }; const toggleDeleteSelection = (keys: string[]) => { setSelectedDeleteKeys((current) => { const next = new Set(current); const validKeys = keys.filter((key) => deleteItemsByKey.has(key)); const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key)); for (const key of validKeys) { if (remove) next.delete(key); else next.add(key); } return next; }); }; const closeDeleteSelection = () => { setDeleteSelectionMode(false); setSelectedDeleteKeys(new Set()); }; const requestDeleteItems = (items: SidebarDeleteItem[]) => { if (items.length === 0) return; if (onRequestDeleteMany) onRequestDeleteMany(items); else if (items.length === 1) onRequestDelete(items[0].key, items[0].label); }; const requestDeleteKeys = (keys: string[]) => { requestDeleteItems(keys .map((key) => deleteItemsByKey.get(key)) .filter((item): item is SidebarDeleteItem => item !== undefined)); }; const confirmDeleteSelection = () => { requestDeleteKeys(Array.from(selectedDeleteKeys)); closeDeleteSelection(); }; return (
{temporarySessions.length > 0 ? ( ) : null} {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 topicActive = s.key === activeKey; const paneGroup = paneGroups[s.key]; const title = displayTitle(s, titleOverrides, t("chat.newChat")); const resolvedPaneGroup = paneGroup ?? { tabKey: s.key, title, activePaneKey: s.key, panes: [{ key: s.key, chatId: s.chatId, title }], }; const isWorkbenchTab = paneGroup?.visible ?? ((paneGroup?.panes.length ?? 0) > 1); const paneGroupCollapsed = isWorkbenchTab && collapsedPaneGroups.has(s.key); const paneGroupId = `sidebar-pane-group-${s.key.replace( /[^a-zA-Z0-9_-]/g, "-", )}`; const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key); const tabSelected = tabDeleteKeys.every((key) => ( selectedDeleteKeys.has(key) )); const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => ( selectedDeleteKeys.has(key) )); const projectMode = group.kind === "project"; if (isWorkbenchTab) { return (
  • { if (element) tabRowRefs.current.set(s.key, element); else tabRowRefs.current.delete(s.key); }} data-sidebar-tab-group="true" data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} className="relative my-1.5 min-w-0" >
    togglePaneGroup(s.key)} onToggleSelection={() => toggleDeleteSelection(tabDeleteKeys)} onRequestRename={onRequestRenameTab ? () => onRequestRenameTab(s.key, title) : undefined} onDissolve={onDissolveTab ? () => onDissolveTab(resolvedPaneGroup.tabKey) : undefined} onRequestDelete={() => requestDeleteKeys(tabDeleteKeys)} actionMenuPortalContainer={actionMenuPortalContainer} /> {!paneGroupCollapsed ? ( ( target.key !== resolvedPaneGroup.tabKey ))} onAttachPane={onAttachPane} deleteSelectionMode={deleteSelectionMode} selectedDeleteKeys={selectedDeleteKeys} onToggleDeleteSelection={toggleDeleteSelection} onBeginDeleteSelection={beginDeleteSelection} actionMenuPortalContainer={actionMenuPortalContainer} /> ) : null}
  • ); } const fallbackTitle = t("chat.fallbackTitle", { id: s.chatId.slice(0, 6), }); const generatedTitle = s.title?.trim() || ""; 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 = visibleSessionPreview(s.preview); const showPreview = showPreviews && preview && preview !== title; const timestamp = showTimestamps ? relativeTime(s.updatedAt ?? s.createdAt) : ""; const activityState = running.has(s.chatId) ? "running" : updated.has(s.chatId) && !topicActive ? "updated" : null; return (
  • { if (element) tabRowRefs.current.set(s.key, element); else tabRowRefs.current.delete(s.key); }} className="relative min-w-0" >
    {!deleteSelectionMode ? ( event.preventDefault()} > onTogglePin(s.key)}> {isPinned ? ( ) : ( )} {isPinned ? t("chat.unpin") : t("chat.pin")} onRequestRename(s.key, title)} > {t("chat.rename")} onToggleArchive(s.key)}> {isArchived ? ( ) : ( )} {isArchived ? t("chat.unarchive") : t("chat.archive")} {paneGroup && onCreateTab ? ( onCreateTab(paneGroup.tabKey)}> {t("workbench.createGroup", { defaultValue: "Create group", })} ) : null} {paneGroup && onAttachPane ? ( ( target.key !== paneGroup.tabKey ))} onMove={(targetKey) => onAttachPane(s.key, targetKey)} /> ) : null} beginDeleteSelection(tabDeleteKeys)} > {t("chat.select", { defaultValue: "Select" })} { window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0); }} > {t("chat.delete")} ) : null}
  • ); })}
)} {foldableChatsGroup && canToggleFold ? ( onToggleGroup?.(group.id)} /> ) : null}
); })} {hiddenSessionCount > 0 ? (
) : null} {deleteSelectionMode ? (
{t("chat.selectedCount", { defaultValue: "{{count}} selected", count: selectedDeleteKeys.size, })}
) : null}
); }); function WorkbenchTabHeader({ title, controlsId, collapsed, deleteSelectionMode, selected, partiallySelected, onToggle, onToggleSelection, onRequestRename, onDissolve, onRequestDelete, actionMenuPortalContainer, }: { title: string; controlsId: string; collapsed: boolean; deleteSelectionMode: boolean; selected: boolean; partiallySelected: boolean; onToggle: () => void; onToggleSelection: () => void; onRequestRename?: () => void; onDissolve?: () => void; onRequestDelete: () => void; actionMenuPortalContainer?: HTMLElement | null; }) { const { t } = useTranslation(); const disclosureLabel = t( collapsed ? "workbench.expandTabGroup" : "workbench.collapseTabGroup", { title }, ); return (
{!deleteSelectionMode ? ( <> event.preventDefault()} > {onRequestRename ? ( {t("chat.rename")} ) : null} {onDissolve ? ( {t("workbench.dissolveTab", { defaultValue: "Dissolve group" })} ) : null} window.setTimeout(onRequestDelete, 0)} > {t("chat.delete")} ) : null}
); } function ActivePaneRows({ id, group, tabTitle, tabActive, compact, running, updated, onSelectPane, onRequestDelete, onRequestRename, onTogglePin, onToggleArchive, pinned, archived, onDetachPane, moveTargets, onAttachPane, deleteSelectionMode, selectedDeleteKeys, onToggleDeleteSelection, onBeginDeleteSelection, actionMenuPortalContainer, }: { id: string; group: SidebarPaneGroup; tabTitle: string; tabActive: boolean; compact: boolean; running: ReadonlySet; updated: ReadonlySet; onSelectPane?: (tabKey: string, paneKey: string) => void; onRequestDelete: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void; onTogglePin: (key: string) => void; onToggleArchive: (key: string) => void; pinned: ReadonlySet; archived: ReadonlySet; onDetachPane?: (tabKey: string, paneKey: string) => void; moveTargets: PaneGroupTarget[]; onAttachPane?: ( paneKey: string, tabKey: string, ) => void; deleteSelectionMode: boolean; selectedDeleteKeys: ReadonlySet; onToggleDeleteSelection: (keys: string[]) => void; onBeginDeleteSelection: (keys: string[]) => void; actionMenuPortalContainer?: HTMLElement | null; }) { const { t } = useTranslation(); const panes = group.panes; return (
    {panes.map((pane) => { const active = tabActive && pane.key === group.activePaneKey; const activityState = running.has(pane.chatId) ? "running" : updated.has(pane.chatId) && !active ? "updated" : null; const paneActionsLabel = t("workbench.paneActions", { defaultValue: "{{title}} pane actions", title: pane.title, }); const selected = selectedDeleteKeys.has(pane.key); const isPinned = pinned.has(pane.key); const isArchived = archived.has(pane.key); return (
  • {!deleteSelectionMode ? event.preventDefault()} > onTogglePin(pane.key)}> {isPinned ? ( ) : ( )} {isPinned ? t("chat.unpin") : t("chat.pin")} onRequestRename(pane.key, pane.title)} > {t("chat.rename")} onToggleArchive(pane.key)}> {isArchived ? ( ) : ( )} {isArchived ? t("chat.unarchive") : t("chat.archive")} {onDetachPane ? ( onDetachPane(group.tabKey, pane.key)}> {t("workbench.detachPane", { defaultValue: "Remove", title: pane.title, })} ) : null} {onAttachPane ? ( onAttachPane(pane.key, targetKey)} /> ) : null} onBeginDeleteSelection([pane.key])} > {t("chat.select", { defaultValue: "Select" })} { window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0); }} > {t("chat.delete")} : null}
  • ); })}
); } function SelectionIndicator({ checked, partial, }: { checked: boolean; partial: boolean; }) { const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square; return ( ); } function MoveToGroupSubmenu({ targets, onMove, }: { targets: PaneGroupTarget[]; onMove: (targetKey: string) => void; }) { const { t } = useTranslation(); if (targets.length === 0) return null; return ( {t("workbench.moveTo", { defaultValue: "Move to" })} {targets.map((target) => ( onMove(target.key)} > {target.title} · {target.paneCount}/{MAX_WORKBENCH_PANES} ))} ); } function TemporaryChatSection({ sessions, activeKey, running, onSelect, onClose, }: { sessions: ChatSummary[]; activeKey: string | null; running: ReadonlySet; onSelect: (key: string) => void; onClose?: (key: string) => void; }) { const { t } = useTranslation(); return (
    {sessions.map((session) => { const active = session.key === activeKey; const title = deriveTemporaryChatTitle(session.preview, t("temporaryChat.title")); return (
  • {onClose ? ( ) : 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 topics`; 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