diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index d4d040907..844970fb5 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -7,11 +7,13 @@ import { useRef, useState, } from "react"; +import type { MouseEvent as ReactMouseEvent, ReactElement } from "react"; import { Archive, ArchiveRestore, ChevronDown, Folder, + FolderTree, ListChecks, MessageCircleDashed, MoreHorizontal, @@ -40,6 +42,12 @@ import { 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"; @@ -64,6 +72,48 @@ 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"; +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( @@ -200,13 +250,14 @@ export const ChatList = memo(function ChatList({ }: 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 layoutRowRefs = useRef(new Map()); + const pendingLayoutRectsRef = useRef | null>(null); + const layoutAnimationsRef = useRef(new Map()); const [collapsedPaneGroups, setCollapsedPaneGroups] = useState>( readCollapsedPaneGroups, ); const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); + const [openActionMenuId, setOpenActionMenuId] = useState(null); const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( () => new Set(), ); @@ -291,6 +342,26 @@ export const ChatList = memo(function ChatList({ 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); @@ -298,6 +369,7 @@ export const ChatList = memo(function ChatList({ useEffect(() => { if (!deleteSelectionMode) return; + setOpenActionMenuId(null); const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setDeleteSelectionMode(false); @@ -324,41 +396,47 @@ export const ChatList = memo(function ChatList({ }); }, [loading, paneGroups]); - const measureTabRows = useCallback(() => { + const measureLayoutRows = useCallback(() => { const rects = new Map(); - for (const [key, row] of tabRowRefs.current) { + for (const [key, row] of layoutRowRefs.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 captureLayout = useCallback(() => { + for (const animation of layoutAnimationsRef.current.values()) animation.cancel(); + layoutAnimationsRef.current.clear(); + pendingLayoutRectsRef.current = measureLayoutRows(); + }, [measureLayoutRows]); const togglePaneGroup = useCallback((key: string) => { - captureTabLayout(); + captureLayout(); setCollapsedPaneGroups((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); - }, [captureTabLayout]); + }, [captureLayout]); + + const toggleProjectGroup = useCallback((key: string) => { + if (!onToggleGroup) return; + captureLayout(); + onToggleGroup(key); + }, [captureLayout, onToggleGroup]); useLayoutEffect(() => { - const previousRects = pendingTabRectsRef.current; + const previousRects = pendingLayoutRectsRef.current; if (!previousRects) return; - pendingTabRectsRef.current = null; - const nextRects = measureTabRows(); + 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 = tabRowRefs.current.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; @@ -372,17 +450,17 @@ export const ChatList = memo(function ChatList({ easing: "cubic-bezier(0.2, 0, 0, 1)", }, ); - tabLayoutAnimationsRef.current.set(key, animation); + layoutAnimationsRef.current.set(key, animation); animation.addEventListener("finish", () => { - if (tabLayoutAnimationsRef.current.get(key) === animation) { - tabLayoutAnimationsRef.current.delete(key); + if (layoutAnimationsRef.current.get(key) === animation) { + layoutAnimationsRef.current.delete(key); } }, { once: true }); } - }, [collapsedPaneGroups, measureTabRows]); + }, [collapsedGroups, collapsedPaneGroups, measureLayoutRows]); useEffect(() => () => { - for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); + for (const animation of layoutAnimationsRef.current.values()) animation.cancel(); }, []); if (loading && sessions.length === 0 && temporarySessions.length === 0) { @@ -441,6 +519,7 @@ export const ChatList = memo(function ChatList({ closeDeleteSelection(); }; return ( +
COLLAPSED_CHATS_VISIBLE_COUNT; + const projectCollapsed = group.kind === "project" + && Boolean(collapsedGroups[group.id]); return (
{index === firstProjectGroupIndex ? ( @@ -472,30 +553,49 @@ export const ChatList = memo(function ChatList({ {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 : ( -
    +
    +
    { + 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]; @@ -528,8 +628,8 @@ export const ChatList = memo(function ChatList({
    • { - if (element) tabRowRefs.current.set(s.key, element); - else tabRowRefs.current.delete(s.key); + 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} @@ -538,15 +638,16 @@ export const ChatList = memo(function ChatList({
      -
      ) : null} -
    • ); @@ -618,83 +719,88 @@ export const ChatList = memo(function ChatList({ ? "updated" : null; const canDragSession = !topicActive && !deleteSelectionMode; + const actionMenuId = `session:${s.key}`; return (
    • { - if (element) tabRowRefs.current.set(s.key, element); - else tabRowRefs.current.delete(s.key); + 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-[0.65rem] px-2 text-[13px]", SIDEBAR_SELECTION_ITEM_CLASS, compact ? "min-h-7" : "min-h-8", topicActive - ? "bg-sidebar-selected text-sidebar-accent-foreground" - : "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.075] hover:text-sidebar-foreground dark:hover:bg-white/[0.09]", + ? "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) + )} + > ); })} -
    - )} - {foldableChatsGroup && canToggleFold ? ( - onToggleGroup?.(group.id)} - /> - ) : null} +
+ {foldableChatsGroup && canToggleFold ? ( + onToggleGroup?.(group.id)} + /> + ) : null} +
+ )} + ); })} @@ -849,11 +964,14 @@ export const ChatList = memo(function ChatList({ ) : null} +
); }); function WorkbenchTabHeader({ title, + actionMenuId, + actionMenus, controlsId, collapsed, deleteSelectionMode, @@ -867,6 +985,8 @@ function WorkbenchTabHeader({ actionMenuPortalContainer, }: { title: string; + actionMenuId: string; + actionMenus: SidebarActionMenuController; controlsId: string; collapsed: boolean; deleteSelectionMode: boolean; @@ -888,34 +1008,45 @@ function WorkbenchTabHeader({ return (
actionMenus.openFromContextMenu(event, actionMenuId)} className={cn( "group/tab flex min-w-0 items-center gap-0.5 rounded-[0.65rem] px-1.5 text-sidebar-foreground/85", collapsed ? "min-h-6" : "min-h-7", )} > - + + + {!deleteSelectionMode ? ( <> - + actionMenus.onOpenChange(actionMenuId, open)} + > - + > + + + ) : null}
@@ -1009,6 +1141,7 @@ function ActivePaneRows({ onToggleDeleteSelection, onBeginDeleteSelection, actionMenuPortalContainer, + actionMenus, }: { id: string; group: SidebarPaneGroup; @@ -1035,6 +1168,7 @@ function ActivePaneRows({ onToggleDeleteSelection: (keys: string[]) => void; onBeginDeleteSelection: (keys: string[]) => void; actionMenuPortalContainer?: HTMLElement | null; + actionMenus: SidebarActionMenuController; }) { const { t } = useTranslation(); const panes = group.panes; @@ -1045,7 +1179,7 @@ function ActivePaneRows({ defaultValue: "Panes in {{title}}", title: tabTitle, })} - className="mt-0.5 space-y-0.5" + className="mt-0.5 space-y-0.5 rounded-es-[14px] border-s-2 border-sidebar-foreground/25 pb-1" > {panes.map((pane) => { const active = tabActive && pane.key === group.activePaneKey; @@ -1062,6 +1196,7 @@ function ActivePaneRows({ const isPinned = pinned.has(pane.key); const isArchived = archived.has(pane.key); const canDragSession = !active && !deleteSelectionMode; + 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-[0.65rem] px-2 text-[13px]", SIDEBAR_SELECTION_ITEM_CLASS, compact ? "min-h-7" : "min-h-8", active - ? "bg-sidebar-selected text-sidebar-accent-foreground" - : "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.075] hover:text-sidebar-foreground dark:hover:bg-white/[0.09]", + ? "text-sidebar-foreground" + : "text-sidebar-foreground/82 hover:text-sidebar-foreground", deleteSelectionMode && selected && "bg-sidebar-accent/55 text-sidebar-accent-foreground", )} > - + + + - {!deleteSelectionMode ? + {!deleteSelectionMode ? actionMenus.onOpenChange(actionMenuId, open)} + > - + + + {onClose ? ( + ); + const disclosureLabel = `${t("chat.groups.projects")}: ${label}`; return ( -
    - - {updatedAt ? ( - - {relativeTime(updatedAt)} - - ) : null} - {onRequestRename ? ( - - + {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} + + - ) : null} -
    + + + + ); } @@ -1412,11 +1595,11 @@ function ChatsGroupHeader({ label }: { label: string }) { ); } -function PinnedChatIndicator({ label }: { label: string }) { +function PinnedChatIndicator() { return (