import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ChatList } from "@/components/ChatList"; 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, }; } describe("ChatList", () => { 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 ?? ""; expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument(); 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 chats in the Chats 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: "Chats" }); expect(within(chatsSection).getByText("Default workspace chat")).toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).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(); fireEvent.click( within(projectSection).getByRole("button", { name: "Start a new chat in Photos" }), ); expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); expect(onToggleGroup).toHaveBeenCalledTimes(1); fireEvent.pointerDown( within(projectSection).getByLabelText("Chat actions for Photos"), { button: 0 }, ); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); }); it("hides the completed dot for the active chat", () => { const sessions = [ session({ chatId: "active", title: "Active task", }), session({ chatId: "done", title: "Done task", }), ]; render( , ); const finished = screen.getAllByLabelText("Agent finished"); expect(finished).toHaveLength(1); expect(finished[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: "Chats" }); 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 chats" })); expect(onToggleGroup).toHaveBeenCalledWith("workspace:chats"); rerender( , ); expect(within(chatsSection).getByText("Chat 0")).toBeInTheDocument(); expect(within(chatsSection).getByRole("button", { name: "Show less" })).toBeInTheDocument(); }); it("sorts Chats 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 — Chats should come before both projects. const chatsIdx = regionNames.findIndex((n) => n?.includes("Chats")); 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 Chats 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", "Chats", "project-b"]); expect(screen.getAllByText("Projects")).toHaveLength(1); }); it("keeps Chats 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", "Chats"]); expect(screen.getAllByText("Projects")).toHaveLength(1); }); });