feat(webui): polish sidebar and session transitions (#5393)

This commit is contained in:
chengyongru
2026-08-14 17:03:11 +08:00
committed by GitHub
parent 057c5e849b
commit 221e8a4e4a
17 changed files with 1152 additions and 560 deletions
+215 -27
View File
@@ -18,13 +18,54 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
};
}
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(
<ChatList
sessions={[session({ chatId: "review", title: "Review the patch" })]}
activeKey="websocket:review"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={onTogglePin}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
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(
<ChatList
@@ -71,6 +112,46 @@ describe("ChatList", () => {
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(
<ChatList
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={onRequestRename}
onToggleArchive={vi.fn()}
onDissolveTab={onDissolveTab}
/>,
);
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: "Tab: 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();
@@ -324,21 +405,25 @@ describe("ChatList", () => {
expect(tabHeader).not.toHaveAttribute("data-chat-row");
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
expect(tabButton).not.toHaveAttribute("aria-current");
expect(tabButton.querySelector("svg")).not.toBeInTheDocument();
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(
"bg-sidebar-selected",
"rounded-[0.65rem]",
);
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass("rounded-[0.65rem]");
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",
@@ -354,7 +439,6 @@ describe("ChatList", () => {
})).toHaveAttribute("aria-expanded", "false");
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }))
.not.toHaveAttribute("aria-current");
expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
fireEvent.click(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic",
@@ -517,10 +601,13 @@ describe("ChatList", () => {
);
const pinnedSection = screen.getByRole("region", { name: "Pinned" });
expect(within(pinnedSection).getByTitle("Pinned")).toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Earlier" })).queryByTitle("Pinned"),
).not.toBeInTheDocument();
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", () => {
@@ -574,8 +661,16 @@ describe("ChatList", () => {
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"));
@@ -630,7 +725,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("switches row-owned tab highlights without a moving selection surface", () => {
it("grows and retracts the row-owned selection track", () => {
const props = {
sessions: [
session({ chatId: "active", title: "Active topic" }),
@@ -650,12 +745,10 @@ describe("ChatList", () => {
/>,
);
const activeButton = screen.getByTitle("Active topic");
const activeButton = screen.getByRole("button", { name: "Active topic" });
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected",
);
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
expect(activeButton.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
rerender(
<ChatList
@@ -664,11 +757,16 @@ describe("ChatList", () => {
/>,
);
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page");
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass(
"bg-sidebar-selected",
);
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", () => {
@@ -743,21 +841,111 @@ describe("ChatList", () => {
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" }),
);
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.pointerDown(
within(projectSection).getByLabelText("Topic actions for Photos"),
{ button: 0 },
);
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(
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": false }} />,
);
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(
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": true }} />,
);
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({