maintainer edit: render the Projects divider only before the first project group so Chats can sort between projects without duplicating the heading. Add middle and last ordering regression coverage.
416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
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>): 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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:alpha"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
sort="title_asc"
|
|
showTimestamps
|
|
runningChatIds={["zeta"]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:default"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
defaultWorkspacePath="/Users/me/.nanobot/workspace"
|
|
showTimestamps
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:alpha"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
onToggleGroup={onToggleGroup}
|
|
onRequestRenameProject={onRequestRenameProject}
|
|
onNewChatInProject={onNewChatInProject}
|
|
projectNameOverrides={{ "/Users/me/nanobot": "Photos" }}
|
|
collapsedGroups={{ "project:/Users/me/nanobot": true }}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:active"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
completedChatIds={["active", "done"]}
|
|
/>,
|
|
);
|
|
|
|
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(<ChatList {...baseProps} />);
|
|
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(
|
|
<ChatList
|
|
{...baseProps}
|
|
collapsedGroups={{ "workspace:chats": false }}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:recent-chat"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
showTimestamps
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:middle-chat"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
showTimestamps
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<ChatList
|
|
sessions={sessions}
|
|
activeKey="websocket:old-chat"
|
|
onSelect={vi.fn()}
|
|
onRequestDelete={vi.fn()}
|
|
onTogglePin={vi.fn()}
|
|
onRequestRename={vi.fn()}
|
|
onToggleArchive={vi.fn()}
|
|
showTimestamps
|
|
/>,
|
|
);
|
|
|
|
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);
|
|
});
|
|
});
|