mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
fix(frontend): keep project thread kebab inside the sidebar (#5682)
In grouped mode the sidebar indents a project's thread rows with a nested SidebarMenu carrying ml-4, but SidebarMenu defaults to w-full. Width 100% plus a 16px left margin overflows the sidebar by 16px, so the absolutely positioned row kebab (right-1) lands past the visible edge and gets clipped. The Archived group nests one level deeper and overflows 32px, hiding its kebabs entirely. Swap w-full for w-auto on both indented menus so the block-level flex container fills the remaining width minus its margin. A dom test renders ProjectsSection in grouped mode from seeded query caches and pins that every indented menu drops w-full while the root menu keeps it. Fixes #5681
This commit is contained in:
parent
3de3fd5166
commit
ded0f0b696
@ -91,7 +91,7 @@ function ProjectThreadGroup({
|
||||
</CollapsibleTrigger>
|
||||
</SidebarMenuItem>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenu className="border-sidebar-border ml-4 border-l pl-2">
|
||||
<SidebarMenu className="border-sidebar-border ml-4 w-auto border-l pl-2">
|
||||
{branchEntries.map((entry) => (
|
||||
<ThreadSidebarItem
|
||||
key={entry.thread.thread_id}
|
||||
@ -130,7 +130,7 @@ function ArchivedProjectsGroup({
|
||||
</CollapsibleTrigger>
|
||||
</SidebarMenuItem>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenu className="border-sidebar-border ml-4 border-l pl-2">
|
||||
<SidebarMenu className="border-sidebar-border ml-4 w-auto border-l pl-2">
|
||||
{projects.map((project) => (
|
||||
<ProjectThreadGroup
|
||||
key={project.id}
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ProjectsSection } from "@/components/workspace/projects-section";
|
||||
import { ThreadDeleteDialogProvider } from "@/components/workspace/thread-delete-dialog";
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
import { DEFAULT_LOCALE } from "@/core/i18n";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { PROJECTS_QUERY_KEY, type Project } from "@/core/projects";
|
||||
import { updateLocalSettings } from "@/core/settings/store";
|
||||
import { INFINITE_THREADS_QUERY_KEY_PREFIX } from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
import { THREAD_PROJECT_METADATA_KEY } from "@/core/threads/utils";
|
||||
|
||||
rs.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
|
||||
usePathname: () => "/workspace",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
|
||||
const ACTIVE_PROJECT_ID = "project-active";
|
||||
const ARCHIVED_PROJECT_ID = "project-archived";
|
||||
|
||||
function makeProject(
|
||||
id: string,
|
||||
name: string,
|
||||
status: Project["status"],
|
||||
): Project {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
instructions: "",
|
||||
presentation: {},
|
||||
status,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeThread(id: string, projectId: string): AgentThread {
|
||||
return {
|
||||
thread_id: id,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
status: "idle",
|
||||
metadata: { [THREAD_PROJECT_METADATA_KEY]: projectId },
|
||||
values: { title: `Chat in ${projectId}` },
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: "user-1",
|
||||
email: "user@example.test",
|
||||
system_role: "user",
|
||||
} as User;
|
||||
|
||||
/**
|
||||
* Seed every query `ProjectsSection` reads in grouped mode so the section
|
||||
* renders from cache without touching the network: both project lists and
|
||||
* the non-archived infinite thread search that the groups partition.
|
||||
*/
|
||||
function renderGroupedSection(): ReturnType<typeof render> {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
[...PROJECTS_QUERY_KEY, { status: "active" }],
|
||||
[makeProject(ACTIVE_PROJECT_ID, "Active project", "active")],
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
[...PROJECTS_QUERY_KEY, { status: "archived" }],
|
||||
[makeProject(ARCHIVED_PROJECT_ID, "Archived project", "archived")],
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
[...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: false }],
|
||||
{
|
||||
pages: [
|
||||
[
|
||||
makeThread("thread-active", ACTIVE_PROJECT_ID),
|
||||
makeThread("thread-archived", ARCHIVED_PROJECT_ID),
|
||||
],
|
||||
],
|
||||
pageParams: [0],
|
||||
},
|
||||
);
|
||||
const tree: ReactNode = (
|
||||
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider initialUser={user}>
|
||||
<SidebarProvider>
|
||||
<ThreadDeleteDialogProvider>
|
||||
<ProjectsSection />
|
||||
</ThreadDeleteDialogProvider>
|
||||
</SidebarProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
return render(tree);
|
||||
}
|
||||
|
||||
function menuOf(element: Element): HTMLElement | null {
|
||||
return element.closest<HTMLElement>('[data-sidebar="menu"]');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateLocalSettings("projectsDisplayMode", "grouped");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
updateLocalSettings("projectsDisplayMode", "flat");
|
||||
rs.restoreAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ProjectsSection grouped-mode nested menus", () => {
|
||||
// happy-dom has no layout engine, so the regression is pinned at the class
|
||||
// level: `SidebarMenu` defaults to `w-full`, and an indented (`ml-4`) menu
|
||||
// that keeps it is 16px wider than its container — the absolutely
|
||||
// positioned row kebab (`right-1`) then lands outside the sidebar and gets
|
||||
// clipped. Every indented menu must swap `w-full` for `w-auto`.
|
||||
it("indents thread rows without overflowing the sidebar width", async () => {
|
||||
const { container } = renderGroupedSection();
|
||||
|
||||
// The Archived group starts collapsed; expand it so its doubly nested
|
||||
// menus (group → project → rows) mount and get checked too.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
const kebabs = await screen.findAllByRole("button", { name: "More" });
|
||||
expect(kebabs).toHaveLength(2);
|
||||
|
||||
for (const kebab of kebabs) {
|
||||
const menu = menuOf(kebab);
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu?.classList.contains("ml-4")).toBe(true);
|
||||
expect(menu?.classList.contains("w-auto")).toBe(true);
|
||||
expect(menu?.classList.contains("w-full")).toBe(false);
|
||||
}
|
||||
|
||||
const menus = [
|
||||
...container.querySelectorAll<HTMLElement>('[data-sidebar="menu"]'),
|
||||
];
|
||||
const nestedMenus = menus.filter((menu) =>
|
||||
Boolean(menu.parentElement && menuOf(menu.parentElement)),
|
||||
);
|
||||
// Active project rows, the Archived group, and the archived project rows.
|
||||
expect(nestedMenus).toHaveLength(3);
|
||||
for (const menu of nestedMenus) {
|
||||
expect(menu.classList.contains("w-auto")).toBe(true);
|
||||
expect(menu.classList.contains("w-full")).toBe(false);
|
||||
}
|
||||
|
||||
// The outermost menu is not indented and keeps the primitive's full width.
|
||||
const rootMenus = menus.filter((menu) => !nestedMenus.includes(menu));
|
||||
expect(rootMenus).toHaveLength(1);
|
||||
expect(rootMenus[0]?.classList.contains("w-full")).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user