import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import type { MouseEvent as ReactMouseEvent, ReactElement } from "react"; import { Archive, ArchiveRestore, ChevronDown, Folder, FolderTree, 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; 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 { clearDraggedSession, hasDraggedSession, readDraggedSession, writeDraggedSession, } from "@/lib/session-drag"; 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"; const COLLAPSED_PANE_GROUPS_STORAGE_KEY = "nanobot-webui.collapsed-pane-groups.v1"; const DETACH_PANE_DROP_TARGET = "__sidebar-standalone__"; interface SidebarActionMenuController { openId: string | null; onOpenChange: (id: string, open: boolean) => void; openFromContextMenu: (event: ReactMouseEvent, id: string) => void; } function SidebarItemTooltip({ label, children, }: { label: string; children: ReactElement; }) { return ( {children} {label} ); } function SidebarSelectionTrack({ active, }: { active: boolean; }) { return ( ); } function readCollapsedPaneGroups(): Set { try { const value = JSON.parse(window.localStorage.getItem( COLLAPSED_PANE_GROUPS_STORAGE_KEY, ) ?? "[]") as unknown; return new Set(Array.isArray(value) ? value.filter((key): key is string => typeof key === "string") : []); } catch { return new Set(); } } function writeCollapsedPaneGroups(groups: ReadonlySet): void { try { window.localStorage.setItem( COLLAPSED_PANE_GROUPS_STORAGE_KEY, JSON.stringify(Array.from(groups)), ); } catch { // Local UI preferences should not block the sidebar. } } 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; } function droppablePaneKey( dataTransfer: DataTransfer, group: SidebarPaneGroup, ): string | null { if (group.panes.length >= MAX_WORKBENCH_PANES || !hasDraggedSession(dataTransfer)) return null; const paneKey = readDraggedSession(dataTransfer); if (!paneKey || group.panes.some((pane) => pane.key === paneKey)) return null; return paneKey; } function detachablePaneSource( dataTransfer: DataTransfer, groups: Record, ): { paneKey: string; tabKey: string } | null { if (!hasDraggedSession(dataTransfer)) return null; const paneKey = readDraggedSession(dataTransfer); if (!paneKey) return null; const source = Object.values(groups).find((group) => ( (group.visible ?? group.panes.length > 1) && group.panes.some((pane) => pane.key === paneKey) )); return source ? { paneKey, tabKey: source.tabKey } : null; } function isWorkbenchTabSurface(target: EventTarget | null): boolean { return target instanceof Element && target.closest("[data-workbench-tab-surface]") !== null; } 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?: (paneKey: 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 layoutRowRefs = useRef(new Map()); const pendingLayoutRectsRef = useRef | null>(null); const layoutAnimationsRef = useRef(new Map()); const [collapsedPaneGroups, setCollapsedPaneGroups] = useState>( readCollapsedPaneGroups, ); const [paneDropTarget, setPaneDropTarget] = useState(null); const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); const [openActionMenuId, setOpenActionMenuId] = useState(null); const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( () => new Set(), ); useEffect(() => { const clearPaneDropTarget = () => setPaneDropTarget(null); window.addEventListener("dragend", clearPaneDropTarget); window.addEventListener("drop", clearPaneDropTarget); return () => { window.removeEventListener("dragend", clearPaneDropTarget); window.removeEventListener("drop", clearPaneDropTarget); }; }, []); 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); const handleActionMenuOpenChange = useCallback((id: string, open: boolean) => { setOpenActionMenuId((current) => { if (open) return id; return current === id ? null : current; }); }, []); const openActionMenuFromContextMenu = useCallback(( event: ReactMouseEvent, id: string, ) => { event.preventDefault(); event.stopPropagation(); if (deleteSelectionMode) return; setOpenActionMenuId(id); }, [deleteSelectionMode]); const actionMenus: SidebarActionMenuController = { openId: openActionMenuId, onOpenChange: handleActionMenuOpenChange, openFromContextMenu: openActionMenuFromContextMenu, }; useEffect(() => { setVisibleLimit(INITIAL_VISIBLE_SESSIONS); }, [showArchived, sort]); useEffect(() => { if (!deleteSelectionMode) return; setOpenActionMenuId(null); 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(() => { writeCollapsedPaneGroups(collapsedPaneGroups); }, [collapsedPaneGroups]); useEffect(() => { if (loading) return; setCollapsedPaneGroups((current) => { const next = new Set(Array.from(current).filter((key) => ( paneGroups[key]?.visible ?? ((paneGroups[key]?.panes.length ?? 0) > 1) ))); if (next.size === current.size && Array.from(next).every((key) => current.has(key))) { return current; } return next; }); }, [loading, paneGroups]); const measureLayoutRows = useCallback(() => { const rects = new Map(); for (const [key, row] of layoutRowRefs.current) { rects.set(key, row.getBoundingClientRect()); } return rects; }, []); const captureLayout = useCallback(() => { for (const animation of layoutAnimationsRef.current.values()) animation.cancel(); layoutAnimationsRef.current.clear(); pendingLayoutRectsRef.current = measureLayoutRows(); }, [measureLayoutRows]); const togglePaneGroup = useCallback((key: string) => { captureLayout(); setCollapsedPaneGroups((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, [captureLayout]); const toggleProjectGroup = useCallback((key: string) => { if (!onToggleGroup) return; captureLayout(); onToggleGroup(key); }, [captureLayout, onToggleGroup]); useLayoutEffect(() => { const previousRects = pendingLayoutRectsRef.current; if (!previousRects) return; pendingLayoutRectsRef.current = null; const nextRects = measureLayoutRows(); 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 = layoutRowRefs.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)", }, ); layoutAnimationsRef.current.set(key, animation); animation.addEventListener("finish", () => { if (layoutAnimationsRef.current.get(key) === animation) { layoutAnimationsRef.current.delete(key); } }, { once: true }); } }, [collapsedGroups, collapsedPaneGroups, measureLayoutRows]); useEffect(() => () => { for (const animation of layoutAnimationsRef.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 (
{ if (isWorkbenchTabSurface(event.target)) { setPaneDropTarget((current) => ( current === DETACH_PANE_DROP_TARGET ? null : current )); return; } const source = onDetachPane && !deleteSelectionMode ? detachablePaneSource(event.dataTransfer, paneGroups) : null; if (!source) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; setPaneDropTarget(DETACH_PANE_DROP_TARGET); }} onDragOver={(event) => { if (isWorkbenchTabSurface(event.target)) { setPaneDropTarget((current) => ( current === DETACH_PANE_DROP_TARGET ? null : current )); return; } const source = onDetachPane && !deleteSelectionMode ? detachablePaneSource(event.dataTransfer, paneGroups) : null; if (!source) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; setPaneDropTarget(DETACH_PANE_DROP_TARGET); }} onDragLeave={(event) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; setPaneDropTarget((current) => ( current === DETACH_PANE_DROP_TARGET ? null : current )); }} onDrop={(event) => { if (isWorkbenchTabSurface(event.target)) return; const source = onDetachPane && !deleteSelectionMode ? detachablePaneSource(event.dataTransfer, paneGroups) : null; if (!source || !onDetachPane) return; event.preventDefault(); setPaneDropTarget(null); clearDraggedSession(); onDetachPane(source.tabKey, source.paneKey); }} className={cn( "relative min-w-0 space-y-3 rounded-panel px-2 py-1.5", "transition-[background-color,box-shadow]", paneDropTarget === DETACH_PANE_DROP_TARGET && "bg-primary/[0.05] ring-1 ring-inset ring-primary/25", )} > {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; const projectCollapsed = group.kind === "project" && Boolean(collapsedGroups[group.id]); return (
{index === firstProjectGroupIndex ? (
{labels.projects}
) : null}
{ const key = `group:${group.id}`; if (element) layoutRowRefs.current.set(key, element); else layoutRowRefs.current.delete(key); }} data-sidebar-group-header={group.id} > {group.kind === "project" ? ( toggleProjectGroup(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} /> ) : ( )}
{projectCollapsed ? 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) layoutRowRefs.current.set(s.key, element); else layoutRowRefs.current.delete(s.key); }} data-sidebar-tab-group="true" data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} data-pane-drop-target={ paneDropTarget === resolvedPaneGroup.tabKey ? "true" : undefined } className="relative my-1.5 min-w-0" >
    { const paneKey = onAttachPane && !deleteSelectionMode ? droppablePaneKey(event.dataTransfer, resolvedPaneGroup) : null; if (!paneKey) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = "move"; setPaneDropTarget(resolvedPaneGroup.tabKey); }} onDragOver={(event) => { const paneKey = onAttachPane && !deleteSelectionMode ? droppablePaneKey(event.dataTransfer, resolvedPaneGroup) : null; if (!paneKey) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = "move"; setPaneDropTarget(resolvedPaneGroup.tabKey); }} onDragLeave={(event) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; setPaneDropTarget((current) => ( current === resolvedPaneGroup.tabKey ? null : current )); }} onDrop={(event) => { const paneKey = onAttachPane && !deleteSelectionMode ? droppablePaneKey(event.dataTransfer, resolvedPaneGroup) : null; if (!paneKey || !onAttachPane) return; event.preventDefault(); event.stopPropagation(); setPaneDropTarget(null); clearDraggedSession(); onAttachPane(paneKey, resolvedPaneGroup.tabKey); }} className={cn( "min-w-0 transition-[background-color,box-shadow]", projectMode && "-ms-0.5", deleteSelectionMode && (tabSelected || tabPartiallySelected) && "ring-1 ring-inset ring-sidebar-foreground/25", paneDropTarget === resolvedPaneGroup.tabKey && "bg-primary/[0.09] ring-2 ring-inset ring-primary/35 dark:bg-primary/[0.14]", )} > 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} actionMenus={actionMenus} /> ) : 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; const hasPaneMoveTarget = Boolean(onAttachPane) && paneGroupTargets.some((target) => ( target.key !== paneGroup?.tabKey && !target.atCapacity )); const canDragSession = !deleteSelectionMode && (!topicActive || hasPaneMoveTarget); const actionMenuId = `session:${s.key}`; return (
  • { if (element) layoutRowRefs.current.set(s.key, element); else layoutRowRefs.current.delete(s.key); }} className="relative min-w-0" >
    ( actionMenus.openFromContextMenu(event, actionMenuId) )} className={cn( "group flex min-w-0 max-w-full items-center gap-1 rounded-control px-2 text-[13px]", SIDEBAR_SELECTION_ITEM_CLASS, compact ? "min-h-7" : "min-h-8", topicActive ? "text-sidebar-foreground" : "text-sidebar-foreground/82 hover:text-sidebar-foreground", deleteSelectionMode && (tabSelected || tabPartiallySelected) && "bg-sidebar-accent/55 text-sidebar-accent-foreground", )} > {!deleteSelectionMode ? ( ( actionMenus.onOpenChange(actionMenuId, open) )} > 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(s.key)}> {t("workbench.createGroup")} ) : 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, actionMenuId, actionMenus, controlsId, collapsed, deleteSelectionMode, selected, partiallySelected, onToggle, onToggleSelection, onRequestRename, onDissolve, onRequestDelete, actionMenuPortalContainer, }: { title: string; actionMenuId: string; actionMenus: SidebarActionMenuController; 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 (
actionMenus.openFromContextMenu(event, actionMenuId)} className={cn( "group/tab flex min-w-0 items-center gap-0.5 rounded-control px-1.5 text-sidebar-foreground/85", collapsed ? "min-h-6" : "min-h-7", )} > {!deleteSelectionMode ? ( <> actionMenus.onOpenChange(actionMenuId, open)} > event.preventDefault()} > {onRequestRename ? ( {t("chat.rename")} ) : null} {onDissolve ? ( {t("workbench.dissolveTab")} ) : null} window.setTimeout(onRequestDelete, 0)} className="whitespace-nowrap" > {t("workbench.deleteConversations")} ) : 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, actionMenus, }: { 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; actionMenus: SidebarActionMenuController; }) { 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", { title: pane.title }); const selected = selectedDeleteKeys.has(pane.key); const isPinned = pinned.has(pane.key); const isArchived = archived.has(pane.key); const hasPaneMoveTarget = Boolean(onAttachPane) && moveTargets.some((target) => !target.atCapacity); const canDragSession = !deleteSelectionMode && (!active || Boolean(onDetachPane) || hasPaneMoveTarget); const actionMenuId = `pane:${pane.key}`; return (
  • ( actionMenus.openFromContextMenu(event, actionMenuId) )} className={cn( "group/pane flex min-w-0 max-w-full items-center gap-1 rounded-control px-2 text-[13px]", SIDEBAR_SELECTION_ITEM_CLASS, compact ? "min-h-7" : "min-h-8", active ? "text-sidebar-foreground" : "text-sidebar-foreground/82 hover:text-sidebar-foreground", deleteSelectionMode && selected && "bg-sidebar-accent/55 text-sidebar-accent-foreground", )} > {!deleteSelectionMode ? actionMenus.onOpenChange(actionMenuId, open)} > 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", { 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")} {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, actionMenuId, actionMenus, collapsed, onToggle, onRequestRename, onNewChat, actionMenuPortalContainer, updatedAt, }: { label: string; path?: string; actionMenuId: string; actionMenus: SidebarActionMenuController; collapsed: boolean; onToggle: () => void; onRequestRename?: () => void; onNewChat?: () => void; actionMenuPortalContainer?: HTMLElement | null; updatedAt?: string | null; }) { const { t } = useTranslation(); const projectButton = ( ); const disclosureLabel = `${t("chat.groups.projects")}: ${label}`; return (
actionMenus.openFromContextMenu(event, actionMenuId) : undefined} className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78" > {path ? ( {projectButton} {path} ) : projectButton} {updatedAt ? ( {relativeTime(updatedAt)} ) : null} {onRequestRename || onNewChat ? ( actionMenus.onOpenChange(actionMenuId, open)} > event.stopPropagation()} > event.preventDefault()} > {onNewChat ? ( {t("sidebar.newChat")} ) : null} {onRequestRename ? ( {t("chat.rename")} ) : null} ) : null}
); } function ChatsGroupHeader({ label }: { label: string }) { return (
{label}
); } function PinnedChatIndicator() { 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