import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChatList } from "@/components/ChatList"; import { readDraggedSession, SESSION_DRAG_TYPE } from "@/lib/session-drag"; import type { ChatSummary } from "@/lib/types"; function session(overrides: Partial): ChatSummary { const chatId = overrides.chatId ?? "chat"; return { key: `websocket:${chatId}`, channel: "websocket", chatId, createdAt: "2026-05-20T10:00:00Z", updatedAt: "2026-05-20T10:00:00Z", preview: "", ...overrides, }; } function rect(top: number): DOMRect { return { x: 0, y: top, width: 240, height: 32, top, right: 240, bottom: top + 32, left: 0, toJSON: () => ({}), }; } describe("ChatList", () => { const originalAnimate = HTMLElement.prototype.animate; const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; afterEach(() => { HTMLElement.prototype.animate = originalAnimate; HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1"); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it("opens a conversation's existing actions from the row context menu", async () => { const onTogglePin = vi.fn(); render( , ); const row = screen.getByRole("button", { name: "Review the patch" }) .closest("[data-chat-row]")!; fireEvent.contextMenu(row); fireEvent.click(await screen.findByRole("menuitem", { name: "Pin" })); expect(onTogglePin).toHaveBeenCalledWith("websocket:review"); }); it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => { render( , ); expect(screen.getByRole("button", { name: "Group: Root topic" })) .toHaveAttribute("draggable", "false"); expect(screen.getByRole("button", { name: "Root topic" })) .toHaveAttribute("draggable", "false"); const pane = screen.getByRole("button", { name: "Research pane" }); expect(pane).toHaveAttribute("draggable", "true"); const dataTransfer = { effectAllowed: "none", setData: vi.fn(), getData: vi.fn(() => ""), types: [], } as unknown as DataTransfer; fireEvent.dragStart(pane, { dataTransfer }); expect(dataTransfer.setData).toHaveBeenCalledWith( SESSION_DRAG_TYPE, "websocket:child", ); expect(readDraggedSession(dataTransfer)).toBe("websocket:child"); fireEvent.dragEnd(pane, { dataTransfer }); expect(readDraggedSession(dataTransfer)).toBeNull(); expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument(); expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); }); it("opens grouped pane and tab actions from their context menus", async () => { const onRequestRename = vi.fn(); const onDissolveTab = vi.fn(); render( , ); const paneRow = screen.getByRole("button", { name: "Research pane" }) .closest("[data-sidebar-pane]")!; fireEvent.contextMenu(paneRow); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); expect(onRequestRename).toHaveBeenCalledWith("websocket:child", "Research pane"); const tabRow = screen.getByRole("button", { name: "Group: Root topic" }) .closest("[data-workbench-tab]")!; fireEvent.contextMenu(tabRow); fireEvent.click(await screen.findByRole("menuitem", { name: "Dissolve group" })); expect(onDissolveTab).toHaveBeenCalledWith("websocket:root"); }); it("creates a visible tab in place and only moves panes into visible tabs", async () => { const onAttachPane = vi.fn(); const onCreateTab = vi.fn(); render( , ); expect(screen.queryByRole("button", { name: "Group: Solo pane" })) .not.toBeInTheDocument(); expect(screen.getAllByText("Solo pane")).toHaveLength(1); expect(screen.queryByRole("list", { name: "Panes in Solo pane" })) .not.toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "Topic actions for Solo pane", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Create group" })); expect(onCreateTab).toHaveBeenCalledWith("websocket:solo"); fireEvent.pointerDown(screen.getByRole("button", { name: "Topic actions for Solo pane", }), { button: 0, ctrlKey: false }); const moveTo = await screen.findByRole("menuitem", { name: "Move to" }); fireEvent.pointerMove(moveTo, { pointerType: "mouse" }); expect(screen.queryByRole("menuitem", { name: "Target pane" })) .not.toBeInTheDocument(); const fullTarget = await screen.findByRole("menuitem", { name: "Existing group · 4/4", }); expect(fullTarget).toHaveAttribute("aria-disabled", "true"); fireEvent.click(fullTarget); expect(onAttachPane).not.toHaveBeenCalled(); fireEvent.click(await screen.findByRole("menuitem", { name: "Fine group · 1/4" })); expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine"); }); it("moves a dragged topic directly into a visible group", () => { const onAttachPane = vi.fn(); render( ({ key: `websocket:full-${index}`, chatId: `full-${index}`, title: `Full pane ${index}`, })), }, }} onAttachPane={onAttachPane} onSelect={vi.fn()} onRequestDelete={vi.fn()} onTogglePin={vi.fn()} onRequestRename={vi.fn()} onToggleArchive={vi.fn()} />, ); const values = new Map(); const dataTransfer = { effectAllowed: "none", dropEffect: "none", setData: vi.fn((type: string, value: string) => values.set(type, value)), getData: vi.fn((type: string) => values.get(type) ?? ""), types: [SESSION_DRAG_TYPE], } as unknown as DataTransfer; const source = screen.getByRole("button", { name: "Solo topic" }); expect(source).toHaveAttribute("draggable", "true"); const targetGroup = screen.getByRole("button", { name: "Group: Target group" }) .closest("[data-sidebar-tab-group]")!; const targetSurface = targetGroup.querySelector("[data-workbench-tab-surface]")!; const fullGroup = screen.getByRole("button", { name: "Group: Full group" }) .closest("[data-sidebar-tab-group]")!; const fullSurface = fullGroup.querySelector("[data-workbench-tab-surface]")!; fireEvent.dragStart(source, { dataTransfer }); fireEvent.dragEnter(fullSurface, { dataTransfer }); fireEvent.dragOver(fullSurface, { dataTransfer }); fireEvent.drop(fullSurface, { dataTransfer }); expect(fullGroup).not.toHaveAttribute("data-pane-drop-target"); expect(onAttachPane).not.toHaveBeenCalled(); fireEvent.dragEnter(targetSurface, { dataTransfer }); fireEvent.dragOver(targetSurface, { dataTransfer }); expect(targetGroup).toHaveAttribute("data-pane-drop-target", "true"); expect(targetSurface).toHaveClass("ring-2", "ring-primary/35"); fireEvent.drop(targetSurface, { dataTransfer }); expect(onAttachPane).toHaveBeenCalledOnce(); expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:target"); expect(targetGroup).not.toHaveAttribute("data-pane-drop-target"); }); it("detaches a grouped pane when it is dropped back into the standalone list", () => { const onDetachPane = vi.fn(); render( , ); const values = new Map(); const dataTransfer = { effectAllowed: "none", dropEffect: "none", setData: vi.fn((type: string, value: string) => values.set(type, value)), getData: vi.fn((type: string) => values.get(type) ?? ""), types: [SESSION_DRAG_TYPE], } as unknown as DataTransfer; const source = screen.getByRole("button", { name: "Research pane" }); expect(source).toHaveAttribute("aria-current", "true"); expect(source).toHaveAttribute("draggable", "true"); const sourceSurface = screen.getByRole("button", { name: "Group: Root topic" }) .closest("[data-workbench-tab-surface]")!; const standaloneList = document.querySelector("[data-chat-list-content]")!; fireEvent.dragStart(source, { dataTransfer }); fireEvent.dragEnter(sourceSurface, { dataTransfer }); fireEvent.drop(sourceSurface, { dataTransfer }); expect(onDetachPane).not.toHaveBeenCalled(); fireEvent.dragStart(source, { dataTransfer }); fireEvent.dragEnter(standaloneList, { dataTransfer }); fireEvent.dragOver(standaloneList, { dataTransfer }); expect(standaloneList).toHaveAttribute("data-pane-detach-target", "true"); expect(standaloneList).toHaveClass("ring-1", "ring-primary/25"); fireEvent.drop(standaloneList, { dataTransfer }); expect(onDetachPane).toHaveBeenCalledOnce(); expect(onDetachPane).toHaveBeenCalledWith("tab:root", "websocket:child"); expect(standaloneList).not.toHaveAttribute("data-pane-detach-target"); }); it("shows every tab's pane membership in a sidebar tab group", async () => { const onSelect = vi.fn(); const onSelectPane = vi.fn(); const onDetachPane = vi.fn(); const onDissolveTab = vi.fn(); const onRequestRename = vi.fn(); const onAttachPane = vi.fn(); render( , ); const child = screen.getByRole("button", { name: "Research pane" }); expect(child.closest("[data-sidebar-pane]")) .toHaveAttribute("data-sidebar-pane", "websocket:child"); expect(child).toHaveAttribute("aria-current", "true"); const targetTabRow = screen.getByRole("button", { name: "Group: Target tab" }) .closest("li")!; const targetChild = within(targetTabRow).getByRole("button", { name: "Target research", }); expect(targetChild.closest("[data-sidebar-pane]")) .toHaveAttribute("data-sidebar-pane", "websocket:target-child"); expect(targetChild).not.toHaveAttribute("aria-current"); fireEvent.click(targetChild); expect(onSelectPane).toHaveBeenCalledWith( "websocket:target", "websocket:target-child", ); fireEvent.click(child); expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child"); fireEvent.click(screen.getByRole("button", { name: "Root topic" })); expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root"); expect(onSelect).not.toHaveBeenCalled(); onSelectPane.mockClear(); const rootTab = screen.getByRole("button", { name: "Group: Root topic" }); fireEvent.click(rootTab); expect(onSelectPane).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled(); expect(rootTab).toHaveAttribute("aria-expanded", "false"); fireEvent.click(rootTab); expect(rootTab).toHaveAttribute("aria-expanded", "true"); fireEvent.pointerDown(screen.getByRole("button", { name: "Topic actions for Root topic", }), { button: 0, ctrlKey: false }); expect(await screen.findByRole("menuitem", { name: "Dissolve group" })) .toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Delete all chats" })) .toBeInTheDocument(); expect(screen.queryByRole("menuitem", { name: "Select" })) .not.toBeInTheDocument(); fireEvent.click(screen.getByRole("menuitem", { name: "Dissolve group" })); expect(onDissolveTab).toHaveBeenCalledWith("websocket:root"); fireEvent.pointerDown(screen.getByRole("button", { name: "Research pane pane actions", }), { button: 0, ctrlKey: false }); const moveTo = await screen.findByRole("menuitem", { name: "Move to" }); fireEvent.pointerMove(moveTo, { pointerType: "mouse" }); fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab · 2/4" })); expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target"); fireEvent.pointerDown(screen.getByRole("button", { name: "Research pane pane actions", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Remove", })); expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child"); fireEvent.pointerDown(screen.getByRole("button", { name: "Root topic pane actions", }), { button: 0, ctrlKey: false }); expect(screen.getByRole("menuitem", { name: "Move to" })) .toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Remove" })) .toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); expect(child).toHaveAttribute("draggable", "true"); expect(screen.getByRole("button", { name: "Group: Target tab" })) .toHaveAttribute("draggable", "false"); }); it("collapses a multi-pane tab into one Chrome-style group header", () => { render( , ); const tabButton = screen.getByRole("button", { name: "Group: Root topic" }); const tabGroup = tabButton.closest("[data-sidebar-tab-group]")!; const tabHeader = tabButton.closest("[data-workbench-tab]")!; const tabSurface = tabButton.closest("[data-workbench-tab-surface]")!; expect(tabGroup).toHaveAttribute("data-sidebar-tab-group", "true"); expect(tabHeader).not.toHaveAttribute("data-chat-row"); expect(tabHeader).not.toHaveAttribute("data-sidebar-pane"); expect(tabButton).not.toHaveAttribute("aria-current"); expect(tabButton.querySelector(".lucide-folder-tree")).toBeInTheDocument(); const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" }); expect(tabSurface).toContainElement(paneList); const activePane = within(tabGroup).getByRole("button", { name: "Research pane" }); expect(activePane).toHaveAttribute("aria-current", "true"); expect(activePane.closest("[data-sidebar-pane]")).toHaveClass("rounded-control"); expect(activePane.querySelector("[data-sidebar-selection-track]")) .toHaveAttribute("data-active", "true"); expect(screen.getByRole("button", { name: "Research pane pane actions", })).toHaveClass("opacity-0"); expect(within(tabGroup).getByRole("button", { name: "Root topic" })) .not.toHaveAttribute("aria-current"); expect(tabGroup).not.toHaveTextContent("2/4"); expect(paneList).toHaveClass( "rounded-es-[14px]", "border-s-2", "border-sidebar-foreground/25", ); const collapse = within(tabGroup).getByRole("button", { name: "Collapse panes in Root topic", }); expect(collapse).toHaveAttribute("aria-expanded", "true"); fireEvent.click(collapse); expect(tabGroup).toHaveAttribute("data-pane-group-collapsed", "true"); expect(within(tabGroup).queryByRole("button", { name: "Research pane" })) .not.toBeInTheDocument(); expect(within(tabGroup).getByRole("button", { name: "Expand panes in Root topic", })).toHaveAttribute("aria-expanded", "false"); expect(within(tabGroup).getByRole("button", { name: "Group: Root topic" })) .not.toHaveAttribute("aria-current"); fireEvent.click(within(tabGroup).getByRole("button", { name: "Expand panes in Root topic", })); expect(within(tabGroup).getByRole("button", { name: "Research pane" })) .toBeInTheDocument(); expect(within(tabGroup).getByRole("button", { name: "Root topic" })) .toBeInTheDocument(); }); it("selects a whole tab or individual panes for one bulk delete", async () => { const onRequestDeleteMany = vi.fn(); render( , ); fireEvent.pointerDown(screen.getByRole("button", { name: "Root topic pane actions", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Select" })); expect(screen.getByText("1 selected")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" })); expect(screen.getByRole("button", { name: "Group: Root topic" })) .toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Root topic" })) .toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Research pane" })) .toHaveAttribute("aria-pressed", "true"); expect(screen.getByText("2 selected")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Target tab" })); expect(screen.getByText("3 selected")).toBeInTheDocument(); fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", { name: "Delete", })); expect(onRequestDeleteMany).toHaveBeenCalledWith([ { key: "websocket:root", label: "Root topic" }, { key: "websocket:child", label: "Research pane" }, { key: "websocket:target", label: "Target tab" }, ]); expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument(); }); it("selects a contiguous session range with Shift-click", async () => { render( , ); fireEvent.pointerDown(screen.getByRole("button", { name: "Topic actions for First topic", }), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("menuitem", { name: "Select" })); fireEvent.click(screen.getByRole("button", { name: "Fourth topic" }), { shiftKey: true, }); expect(screen.getByText("4 selected")).toBeInTheDocument(); for (const title of ["First topic", "Second topic", "Third topic", "Fourth topic"]) { expect(screen.getByRole("button", { name: title })).toHaveAttribute( "aria-pressed", "true", ); } }); it("shows temporary chats separately and lets the user reopen or close them", async () => { const temporarySession = session({ key: "temporary:temporary-one", chatId: "temporary-one", preview: "hi", }); const onSelect = vi.fn(); const onClose = vi.fn(); render( , ); const section = screen.getByRole("region", { name: "Temporary chats" }); fireEvent.click(within(section).getByRole("button", { name: "hi" })); expect(onSelect).toHaveBeenCalledWith("temporary:temporary-one"); fireEvent.click(within(section).getByRole("button", { name: "Close temporary chat: hi", })); expect(onClose).toHaveBeenCalledWith("temporary:temporary-one"); }); it("orders chats by latest session activity by default", () => { const sessions = [ session({ chatId: "older", title: "Older chat", preview: "/model fast", updatedAt: "2026-05-21T10:00:00Z", }), session({ chatId: "newest", title: "Newest chat", updatedAt: "2026-05-21T12:00:00Z", }), session({ chatId: "middle", title: "Middle chat", updatedAt: "2026-05-21T11:00:00Z", }), ]; render( , ); const chatsSection = screen.getAllByRole("region")[0]; const text = chatsSection.textContent ?? ""; expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat")); expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat")); expect(screen.queryByText("/model fast")).not.toBeInTheDocument(); }); it("shows a pin indicator for pinned chats", () => { const sessions = [ session({ chatId: "pinned", title: "Pinned chat" }), session({ chatId: "normal", title: "Normal chat" }), ]; render( , ); const pinnedSection = screen.getByRole("region", { name: "Pinned" }); expect( within(pinnedSection) .getByText("Pinned chat") .closest("[data-chat-row]") ?.querySelector("[data-sidebar-pinned-indicator]"), ).toBeInTheDocument(); expect(document.querySelectorAll("[data-sidebar-pinned-indicator]")).toHaveLength(1); }); it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => { const sessions = [ session({ chatId: "zeta", title: "Zeta task", updatedAt: "2026-05-20T12:00:00Z", workspaceScope: { project_path: "/Users/me/nanobot", project_name: "nanobot", access_mode: "restricted", }, }), session({ chatId: "alpha", title: "Alpha task", updatedAt: "2026-05-20T11:00:00Z", workspaceScope: { project_path: "/Users/me/nanobot", project_name: "nanobot", access_mode: "restricted", }, }), session({ chatId: "bench", title: "Bench task", updatedAt: "2026-05-21T09:00:00Z", workspaceScope: { project_path: "/Users/me/nanobot-bench", project_name: "nanobot-bench", access_mode: "full", }, }), ]; render( , ); const nanobotSection = screen.getByRole("region", { name: "nanobot" }); const nanobotText = nanobotSection.textContent ?? ""; const projectSurface = nanobotSection.querySelector( "[data-sidebar-project-surface]", ); expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument(); expect(projectSurface).toHaveClass( "rounded-es-[16px]", "border-s-2", "border-sidebar-foreground/10", ); expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument(); expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument(); expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task")); expect(within(nanobotSection).getByLabelText("Agent running")).toBeInTheDocument(); expect(screen.queryByText("Today")).not.toBeInTheDocument(); }); it("keeps default workspace topics in the Topics section instead of a project folder", () => { const sessions = [ session({ chatId: "default", title: "Default workspace chat", updatedAt: "2026-05-21T10:00:00Z", workspaceScope: { project_path: "/Users/me/.nanobot/workspace", project_name: "workspace", access_mode: "restricted", }, }), session({ chatId: "project", title: "Project chat", updatedAt: "2026-05-21T11:00:00Z", workspaceScope: { project_path: "/Users/me/nanobot", project_name: "nanobot", access_mode: "restricted", }, }), ]; render( , ); expect(screen.getByText("Projects")).toBeInTheDocument(); expect(screen.getByRole("region", { name: "nanobot" })).toBeInTheDocument(); expect(screen.queryByRole("region", { name: "workspace" })).not.toBeInTheDocument(); const chatsSection = screen.getByRole("region", { name: "Topics" }); expect(within(chatsSection).getByText("Default workspace chat")).toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); }); it("grows and retracts the row-owned selection track", () => { const props = { sessions: [ session({ chatId: "active", title: "Active topic" }), session({ chatId: "inactive", title: "Inactive topic" }), ], onSelect: vi.fn(), onRequestDelete: vi.fn(), onTogglePin: vi.fn(), onRequestRename: vi.fn(), onToggleArchive: vi.fn(), }; const { rerender } = render( , ); const activeButton = screen.getByRole("button", { name: "Active topic" }); expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton.querySelector("[data-sidebar-selection-track]")) .toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current"); rerender( , ); expect(screen.getByRole("button", { name: "Active topic" })) .not.toHaveAttribute("aria-current"); expect(screen.getByRole("button", { name: "Inactive topic" })) .toHaveAttribute("aria-current", "page"); expect(screen.getByRole("button", { name: "Active topic" }) .querySelector("[data-sidebar-selection-track]")) .toHaveClass("scale-x-0"); expect(screen.getByRole("button", { name: "Inactive topic" }) .querySelector("[data-sidebar-selection-track]")) .toHaveClass("scale-x-100"); }); it("restores collapsed tabs from the local UI preference", () => { const props = { sessions: [session({ chatId: "root", title: "Root topic" })], activeKey: "websocket:root", paneGroups: { "websocket:root": { tabKey: "websocket:root", title: "Root topic", activePaneKey: "websocket:root", panes: [ { key: "websocket:root", chatId: "root", title: "Root topic" }, { key: "websocket:child", chatId: "child", title: "Research pane" }, ], }, }, onSelect: vi.fn(), onRequestDelete: vi.fn(), onTogglePin: vi.fn(), onRequestRename: vi.fn(), onToggleArchive: vi.fn(), }; const firstRender = render(); fireEvent.click(screen.getByRole("button", { name: "Group: Root topic" })); expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument(); firstRender.unmount(); render(); expect(screen.getByRole("button", { name: "Group: Root topic" })) .toHaveAttribute("aria-expanded", "false"); expect(screen.queryByRole("button", { name: "Research pane" })).not.toBeInTheDocument(); }); it("can collapse a project group and keeps project rename separate from chat titles", async () => { const onToggleGroup = vi.fn(); const onRequestRenameProject = vi.fn(); const onNewChatInProject = vi.fn(); const sessions = [ session({ chatId: "alpha", title: "Alpha task", workspaceScope: { project_path: "/Users/me/nanobot", project_name: "nanobot", access_mode: "restricted", }, }), ]; render( , ); const projectSection = screen.getByRole("region", { name: "Photos" }); fireEvent.click(within(projectSection).getByRole("button", { name: "Photos" })); expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot"); expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument(); const projectButton = within(projectSection).getByRole("button", { name: "Photos" }); fireEvent.contextMenu(projectButton); fireEvent.click(await screen.findByRole("menuitem", { name: "New topic" })); expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); expect(onToggleGroup).toHaveBeenCalledTimes(1); fireEvent.contextMenu(projectButton); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); }); it("animates project disclosure and surrounding layout like tab groups", () => { let collapsed = false; const onToggleGroup = vi.fn(); const animate = vi.fn(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), }) as unknown as Animation); HTMLElement.prototype.animate = animate; HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() { const followsCollapsedProject = this.textContent?.includes("Beta") ?? false; return rect(followsCollapsedProject ? (collapsed ? 64 : 160) : 0); }; const sessions = [ session({ chatId: "alpha", title: "Alpha task", workspaceScope: { project_path: "/Users/me/alpha", project_name: "Alpha project", access_mode: "restricted", }, }), session({ chatId: "beta", title: "Beta task", workspaceScope: { project_path: "/Users/me/beta", project_name: "Beta project", access_mode: "restricted", }, }), ]; const props = { sessions, activeKey: "websocket:alpha", onSelect: vi.fn(), onRequestDelete: vi.fn(), onTogglePin: vi.fn(), onRequestRename: vi.fn(), onRequestRenameProject: vi.fn(), onToggleArchive: vi.fn(), onToggleGroup, }; const { rerender } = render( , ); const projectButton = screen.getByRole("button", { name: "Alpha project" }); const disclosureButton = screen.getByRole("button", { name: "Projects: Alpha project", }); expect(projectButton).toHaveAttribute("aria-expanded", "true"); expect(disclosureButton).toHaveAttribute("aria-expanded", "true"); const expandedIcon = disclosureButton .querySelector("[data-sidebar-project-disclosure-icon]"); expect(expandedIcon).toHaveClass( "transition-transform", "duration-200", "ease-out", "motion-reduce:transition-none", ); expect(expandedIcon).not.toHaveClass("rotate-90"); expect(screen.getByRole("button", { name: "Topic actions for Alpha project" }) .compareDocumentPosition(disclosureButton) & Node.DOCUMENT_POSITION_FOLLOWING) .toBeTruthy(); fireEvent.click(disclosureButton); expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/alpha"); collapsed = true; rerender( , ); expect(screen.getByRole("button", { name: "Projects: Alpha project" }) .querySelector("[data-sidebar-project-disclosure-icon]")) .toHaveClass("rotate-90"); expect(projectButton).toHaveAttribute("aria-expanded", "false"); expect(animate).toHaveBeenCalledWith( [ { transform: "translateY(96px)" }, { transform: "translateY(0)" }, ], { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)", }, ); expect(screen.getByRole("button", { name: "Beta project" }) .closest("[data-sidebar-group-header]")) .toHaveAttribute("data-sidebar-group-header", "project:/Users/me/beta"); }); it("hides the updated dot for the active chat", () => { const sessions = [ session({ chatId: "active", title: "Active task", }), session({ chatId: "done", title: "Done task", }), ]; render( , ); const updated = screen.getAllByLabelText("New activity"); expect(updated).toHaveLength(1); expect(updated[0].firstElementChild).toHaveClass("h-2", "w-2"); }); it("folds long default workspace chats and can show all", () => { const sessions = Array.from({ length: 10 }, (_, index) => session({ chatId: `chat-${index}`, title: `Chat ${index}`, updatedAt: `2026-05-21T10:${String(index).padStart(2, "0")}:00Z`, workspaceScope: { project_path: "/Users/me/.nanobot/workspace", project_name: "workspace", access_mode: "restricted", }, }), ); const onToggleGroup = vi.fn(); const baseProps = { sessions, activeKey: null, onSelect: vi.fn(), onRequestDelete: vi.fn(), onTogglePin: vi.fn(), onRequestRename: vi.fn(), onToggleArchive: vi.fn(), onToggleGroup, defaultWorkspacePath: "/Users/me/.nanobot/workspace", }; const { rerender } = render(); const chatsSection = screen.getByRole("region", { name: "Topics" }); expect(within(chatsSection).getByText("Chat 9")).toBeInTheDocument(); expect(within(chatsSection).getByText("Chat 2")).toBeInTheDocument(); expect(within(chatsSection).queryByText("Chat 1")).not.toBeInTheDocument(); expect(within(chatsSection).queryByRole("button", { name: "Show all" })).not.toBeInTheDocument(); fireEvent.click(within(chatsSection).getByRole("button", { name: "2 hidden topics" })); expect(onToggleGroup).toHaveBeenCalledWith("workspace:chats"); rerender( , ); expect(within(chatsSection).getByText("Chat 0")).toBeInTheDocument(); expect(within(chatsSection).getByRole("button", { name: "Show less" })).toBeInTheDocument(); }); it("sorts Topics section among project groups by recency, not always last", () => { const sessions = [ session({ chatId: "recent-chat", title: "Recent chat", updatedAt: "2026-05-21T12:00:00Z", }), session({ chatId: "project-a", title: "Project A task", updatedAt: "2026-05-21T10:00:00Z", workspaceScope: { project_path: "/Users/me/project-a", project_name: "project-a", access_mode: "restricted", }, }), session({ chatId: "project-b", title: "Project B task", updatedAt: "2026-05-21T11:00:00Z", workspaceScope: { project_path: "/Users/me/project-b", project_name: "project-b", access_mode: "restricted", }, }), ]; render( , ); const allRegions = screen.getAllByRole("region"); const regionNames = allRegions.map((r) => r.getAttribute("aria-label") ?? r.textContent); // The most recently updated conversation ("Recent chat" at 12:00) must be // in the first group — Topics should come before both projects. const chatsIdx = regionNames.findIndex((n) => n?.includes("Topics")); const projAIdx = regionNames.findIndex((n) => n?.includes("project-a")); const projBIdx = regionNames.findIndex((n) => n?.includes("project-b")); expect(chatsIdx).toBeLessThan(projAIdx); expect(chatsIdx).toBeLessThan(projBIdx); expect(within(allRegions[chatsIdx]).getByText("Recent chat")).toBeInTheDocument(); }); it("keeps one Projects heading when Topics sorts between project groups", () => { const sessions = [ session({ chatId: "project-a", title: "Project A task", updatedAt: "2026-05-21T12:00:00Z", workspaceScope: { project_path: "/Users/me/project-a", project_name: "project-a", access_mode: "restricted", }, }), session({ chatId: "middle-chat", title: "Middle chat", updatedAt: "2026-05-21T11:00:00Z", }), session({ chatId: "project-b", title: "Project B task", updatedAt: "2026-05-21T10:00:00Z", workspaceScope: { project_path: "/Users/me/project-b", project_name: "project-b", access_mode: "restricted", }, }), ]; render( , ); const regionNames = screen .getAllByRole("region") .map((r) => r.getAttribute("aria-label") ?? ""); expect(regionNames).toEqual(["project-a", "Topics", "project-b"]); expect(screen.getAllByText("Projects")).toHaveLength(1); }); it("keeps Topics last when its latest conversation is older than all projects", () => { const sessions = [ session({ chatId: "project-a", title: "Project A task", updatedAt: "2026-05-21T12:00:00Z", workspaceScope: { project_path: "/Users/me/project-a", project_name: "project-a", access_mode: "restricted", }, }), session({ chatId: "project-b", title: "Project B task", updatedAt: "2026-05-21T11:00:00Z", workspaceScope: { project_path: "/Users/me/project-b", project_name: "project-b", access_mode: "restricted", }, }), session({ chatId: "old-chat", title: "Old chat", updatedAt: "2026-05-21T10:00:00Z", }), ]; render( , ); const regionNames = screen .getAllByRole("region") .map((r) => r.getAttribute("aria-label") ?? ""); expect(regionNames).toEqual(["project-a", "project-b", "Topics"]); expect(screen.getAllByText("Projects")).toHaveLength(1); }); });