fix(webui): keep project heading singular

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.
This commit is contained in:
chengyongru 2026-06-02 13:41:14 +08:00 committed by Xubin Ren
parent a70871679c
commit b2cabb2bd8
2 changed files with 106 additions and 6 deletions

View File

@ -175,6 +175,7 @@ export const ChatList = memo(function ChatList({
const running = new Set(runningChatIds);
const completed = new Set(completedChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
@ -192,12 +193,11 @@ export const ChatList = memo(function ChatList({
return (
<section key={group.id} aria-label={group.label}>
{group.kind === "project"
&& limitedGroups[index - 1]?.kind !== "project" ? (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{labels.projects}
</div>
) : null}
{index === firstProjectGroupIndex ? (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{labels.projects}
</div>
) : null}
{group.kind === "project" ? (
<ProjectGroupHeader
label={group.label}

View File

@ -312,4 +312,104 @@ describe("ChatList", () => {
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);
});
});