Files
nanobot/webui/src/tests/chat-list.test.tsx
T
chengyongruandXubin Ren a70871679c fix(webui): sort Chats group among projects by recency
In project-based sidebar grouping, the "Chats" section (non-project
conversations) was always appended at the end regardless of its most
recent updated_at. This meant the newest conversation could appear
below older project groups.

Move Chats group insertion before the global sort, compute its
updatedAt from its most recently updated session, and sort all groups
together by updatedAt descending.
2026-06-02 14:08:45 +08:00

316 lines
10 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();
});
});