From 8ee7bb8e72eed1ab9eed28d379a8edff8231261e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 9 May 2026 14:34:29 -0500 Subject: [PATCH] Fix sidebar section header controls Co-Authored-By: Paperclip --- ui/src/components/SidebarAgents.test.tsx | 21 ++- ui/src/components/SidebarAgents.tsx | 6 +- ui/src/components/SidebarProjects.test.tsx | 21 ++- ui/src/components/SidebarProjects.tsx | 6 +- ui/src/components/SidebarSection.test.tsx | 56 +++++- ui/src/components/SidebarSection.tsx | 196 ++++++++++++--------- 6 files changed, 202 insertions(+), 104 deletions(-) diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 0e68b05166e..b96daff1198 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -237,23 +237,28 @@ describe("SidebarAgents", () => { expect(agentLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]); }); - it("moves agent creation into the section menu and links browse agents", async () => { + it("uses the heading for section menu and the plus button for agent creation", async () => { await renderSidebarAgents(); - expect(container.querySelector('button[aria-label="New agent"]')).toBeNull(); + const sectionMenuTrigger = container.querySelector('button[aria-label="Agents section actions"]'); + expect(sectionMenuTrigger?.textContent).toContain("Agents"); + expect(sectionMenuTrigger?.querySelector("svg")).toBeNull(); + + const newAgentButton = container.querySelector('button[aria-label="New agent"]'); + expect(newAgentButton).toBeTruthy(); + await act(async () => { + newAgentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockOpenNewAgent).toHaveBeenCalledTimes(1); + await openAgentsSectionMenu(); const newAgentItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) .find((element) => element.textContent?.includes("New agent")); - expect(newAgentItem).toBeTruthy(); + expect(newAgentItem).toBeFalsy(); const browseLink = Array.from(document.body.querySelectorAll("a")) .find((element) => element.textContent?.includes("Browse agents")); expect(browseLink?.getAttribute("href")).toBe("/agents/all"); - - await act(async () => { - newAgentItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(mockOpenNewAgent).toHaveBeenCalledTimes(1); }); it("sorts alphabetically and persists the selected mode per company and user", async () => { diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index b3eefcb27b3..a54e8213277 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -351,10 +351,14 @@ export function SidebarAgents() { { expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy(); }); - it("moves project creation into the section menu and links browse projects", async () => { + it("uses the heading for section menu and the plus button for project creation", async () => { await renderSidebarProjects(); - expect(container.querySelector('button[aria-label="New project"]')).toBeNull(); + const sectionMenuTrigger = container.querySelector('button[aria-label="Projects section actions"]'); + expect(sectionMenuTrigger?.textContent).toContain("Projects"); + expect(sectionMenuTrigger?.querySelector("svg")).toBeNull(); + + const newProjectButton = container.querySelector('button[aria-label="New project"]'); + expect(newProjectButton).toBeTruthy(); + await act(async () => { + newProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockOpenNewProject).toHaveBeenCalledTimes(1); + await openProjectsMenu(container); const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) .find((element) => element.textContent?.includes("New project")); - expect(newProjectItem).toBeTruthy(); + expect(newProjectItem).toBeFalsy(); const browseLink = Array.from(document.body.querySelectorAll("a")) .find((element) => element.textContent?.includes("Browse projects")); expect(browseLink?.getAttribute("href")).toBe("/projects"); - - await act(async () => { - newProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(mockOpenNewProject).toHaveBeenCalledTimes(1); }); it("sorts alphabetically and persists the selected mode per company and user", async () => { diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index e568f78af83..97900b4880d 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -297,10 +297,14 @@ export function SidebarProjects() { ({ isMobile: false, @@ -92,9 +93,44 @@ describe("SidebarSection", () => { const projectsLabel = Array.from(container.querySelectorAll("span")) .find((element) => element.textContent === "Projects"); - expect(workLabel?.previousElementSibling?.className).toContain("w-3"); - expect(projectsLabel?.previousElementSibling?.tagName.toLowerCase()).toBe("svg"); - expect(projectsLabel?.previousElementSibling?.getAttribute("class")).toContain("w-3"); + expect(workLabel?.parentElement?.textContent).toBe("Work"); + expect(projectsLabel?.parentElement?.textContent).toBe("Projects"); + expect(projectsLabel?.parentElement?.querySelector("svg")).toBeNull(); + expect(container.querySelector('button[aria-label="Collapse Projects"] svg')).toBeTruthy(); + }); + + it("keeps collapse on the caret and opens the menu from the heading", async () => { + const onOpenChange = vi.fn(); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + Projects + , + ); + }); + await flushReact(); + + await openSectionMenu(container); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Browse projects"); + + const caret = container.querySelector('button[aria-label="Collapse Projects"]'); + await act(async () => { + caret?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onOpenChange).toHaveBeenCalledWith(false); }); it("renders configured menu actions and radio choices", async () => { @@ -170,6 +206,11 @@ describe("SidebarSection", () => { ariaLabel: "Projects section actions", actions: [{ type: "item", label: "New project" }], }} + headerAction={{ + ariaLabel: "New project", + icon: Plus, + onClick: vi.fn(), + }} > Projects , @@ -179,12 +220,13 @@ describe("SidebarSection", () => { const projectsLabel = Array.from(container.querySelectorAll("span")) .find((element) => element.textContent === "Projects"); - const caret = projectsLabel?.previousElementSibling; - const trigger = container.querySelector('button[aria-label="Projects section actions"]'); + const caret = container.querySelector('button[aria-label="Expand Projects"] svg'); + const action = container.querySelector('button[aria-label="New project"]'); expect(caret?.getAttribute("class")).toContain("opacity-100"); expect(caret?.getAttribute("class")).not.toContain("opacity-0"); - expect(trigger?.getAttribute("class")).toContain("opacity-100"); - expect(trigger?.getAttribute("class")).not.toContain("opacity-0"); + expect(projectsLabel?.parentElement?.textContent).toBe("Projects"); + expect(action?.getAttribute("class")).toContain("opacity-100"); + expect(action?.getAttribute("class")).not.toContain("opacity-0"); }); }); diff --git a/ui/src/components/SidebarSection.tsx b/ui/src/components/SidebarSection.tsx index e81a59bc808..86b68b4722c 100644 --- a/ui/src/components/SidebarSection.tsx +++ b/ui/src/components/SidebarSection.tsx @@ -1,6 +1,6 @@ import { useState, type ComponentType, type ReactNode } from "react"; import { Link } from "@/lib/router"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSidebar } from "../context/SidebarContext"; import { @@ -45,6 +45,12 @@ type SidebarSectionMenu = { onRadioValueChange?: (value: string) => void; }; +type SidebarSectionHeaderAction = { + ariaLabel: string; + icon: SidebarSectionIcon; + onClick: () => void; +}; + interface SidebarSectionProps { label: string; children: ReactNode; @@ -53,16 +59,20 @@ interface SidebarSectionProps { onOpenChange: (open: boolean) => void; }; menu?: SidebarSectionMenu; + headerAction?: SidebarSectionHeaderAction; } function SidebarSectionHeader({ collapsible, + headerAction, label, menu, -}: Pick) { +}: Pick) { const { isMobile } = useSidebar(); const [menuOpen, setMenuOpen] = useState(false); - const hasMenu = Boolean(menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0)); + const hasMenu = Boolean( + menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0), + ); const labelClassName = "text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60"; const headerControlVisibilityClassName = isMobile ? "opacity-100" @@ -73,95 +83,123 @@ function SidebarSectionHeader({ collapsible?.open && "rotate-90", menuOpen && "opacity-100", ); + const actionClassName = cn( + "h-5 w-5 shrink-0 text-muted-foreground/60 transition-opacity hover:text-foreground data-[state=open]:opacity-100", + headerControlVisibilityClassName, + ); + const headerContent = {label}; + const HeaderActionIcon = headerAction?.icon; - const labelContent = ( - <> - {collapsible ? ( -