nanobot/webui/src/tests/chat-list.test.tsx

622 lines
19 KiB
TypeScript

import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, 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,
};
}
function rect({
left,
top,
width,
height,
}: {
left: number;
top: number;
width: number;
height: number;
}): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
} as DOMRect;
}
describe("ChatList", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
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(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
showPreviews
/>,
);
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(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
pinnedKeys={["websocket:pinned"]}
/>,
);
const pinnedSection = screen.getByRole("region", { name: "Pinned" });
expect(within(pinnedSection).getByTitle("Pinned")).toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Earlier" })).queryByTitle("Pinned"),
).not.toBeInTheDocument();
});
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 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(
<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: "Topics" });
expect(within(chatsSection).getByText("Default workspace chat")).toBeInTheDocument();
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("positions one background highlight and resets it across hidden targets", () => {
let revealFrame: FrameRequestCallback | null = null;
let resizeObserverCallback: ResizeObserverCallback | null = null;
let activeTargetVisible = true;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
return 1;
});
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.hasAttribute("data-chat-list-content")) {
return rect({ left: 0, top: 0, width: 300, height: 200 });
}
if (this.getAttribute("data-chat-row") === "websocket:active") {
return activeTargetVisible
? rect({ left: 8, top: 12, width: 284, height: 32 })
: rect({ left: 0, top: 0, width: 0, height: 0 });
}
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
return rect({ left: 8, top: 48, width: 284, height: 40 });
}
return rect({ left: 0, top: 0, width: 0, height: 0 });
},
);
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(
<ChatList
{...props}
activeKey="websocket:active"
/>,
);
const highlight = screen.getByTestId("sessions-selection-highlight");
expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
expect(resizeObserverCallback).not.toBeNull();
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
);
expect(highlight).toHaveClass(
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
activeTargetVisible = false;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle("opacity: 0");
activeTargetVisible = true;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
revealFrame?.(0);
rerender(
<ChatList
{...props}
activeKey="websocket:inactive"
/>,
);
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
expect(highlight).toHaveStyle(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
);
rerender(<ChatList {...props} activeKey={null} />);
expect(highlight).toHaveStyle("opacity: 0");
});
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 topic in Photos" }),
);
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
expect(onToggleGroup).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(
within(projectSection).getByLabelText("Topic actions for Photos"),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
});
it("hides the updated 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()}
updatedChatIds={["active", "done"]}
/>,
);
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(<ChatList {...baseProps} />);
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(
<ChatList
{...baseProps}
collapsedGroups={{ "workspace:chats": false }}
/>,
);
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(
<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 — 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(
<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", "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(
<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", "Topics"]);
expect(screen.getAllByText("Projects")).toHaveLength(1);
});
});