From ded0f0b6969220701f539b135ad379d912ad43a9 Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Tue, 22 Sep 2026 22:09:09 +0800 Subject: [PATCH] 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 --- .../components/workspace/projects-section.tsx | 4 +- .../projects-section-nested-menu.dom.test.tsx | 165 ++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/projects-section-nested-menu.dom.test.tsx diff --git a/frontend/src/components/workspace/projects-section.tsx b/frontend/src/components/workspace/projects-section.tsx index 2c50c0640..35a77e6ed 100644 --- a/frontend/src/components/workspace/projects-section.tsx +++ b/frontend/src/components/workspace/projects-section.tsx @@ -91,7 +91,7 @@ function ProjectThreadGroup({ - + {branchEntries.map((entry) => ( - + {projects.map((project) => ( ({ + 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 { + 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 = ( + + + + + + + + + + + + ); + return render(tree); +} + +function menuOf(element: Element): HTMLElement | null { + return element.closest('[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('[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); + }); +});