diff --git a/packages/views/layout/app-sidebar.test.tsx b/packages/views/layout/app-sidebar.test.tsx index 622a0419d..2d45affdf 100644 --- a/packages/views/layout/app-sidebar.test.tsx +++ b/packages/views/layout/app-sidebar.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@multica/core/api"; import { AppSidebar } from "./app-sidebar"; -const { detail, deletePin, pins } = vi.hoisted(() => ({ +const { detail, deletePin, pins, nav } = vi.hoisted(() => ({ detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } }, deletePin: vi.fn(), pins: { @@ -19,6 +19,7 @@ const { detail, deletePin, pins } = vi.hoisted(() => ({ }, ], }, + nav: { pathname: "/acme/issues" }, })); vi.mock("@dnd-kit/core", () => ({ @@ -43,7 +44,9 @@ vi.mock("@multica/ui/components/ui/sidebar", () => ({ SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => <>{children}, SidebarHeader: ({ children }: { children: React.ReactNode }) => <>{children}, SidebarMenu: ({ children }: { children: React.ReactNode }) => <>{children}, - SidebarMenuButton: ({ children }: { children: React.ReactNode }) => , + SidebarMenuButton: ({ children, isActive }: { children: React.ReactNode; isActive?: boolean }) => ( + + ), SidebarMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}, SidebarRail: () => null, })); @@ -71,7 +74,7 @@ vi.mock("../auth", () => ({ useLogout: () => vi.fn() })); vi.mock("../issues/components/status-icon", () => ({ StatusIcon: () => })); vi.mock("../navigation", () => ({ AppLink: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, - useNavigation: () => ({ pathname: "/acme/issues", push: vi.fn() }), + useNavigation: () => ({ pathname: nav.pathname, push: vi.fn() }), })); vi.mock("../projects/components/project-icon", () => ({ ProjectIcon: () => })); vi.mock("../workspace/workspace-avatar", () => ({ WorkspaceAvatar: () => })); @@ -127,6 +130,7 @@ describe("PinRow", () => { beforeEach(() => { deletePin.mockReset(); detail.current = { isPending: false, isError: false, data: null, error: null }; + nav.pathname = "/acme/issues"; }); it("unpins missing details", async () => { @@ -146,4 +150,24 @@ describe("PinRow", () => { render(); expect(await screen.findByText("MUL-123 Keep this pin")).toBeInTheDocument(); }); + + it("does not activate parent tab when on a pinned item's path", async () => { + detail.current = { isPending: false, isError: false, data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" }, error: null }; + nav.pathname = "/acme/issues/issue-1"; + const { container } = render(); + const issuesTab = container.querySelector(".lucide-list-todo")?.closest("button"); + expect(issuesTab).not.toBeNull(); + expect(issuesTab).not.toHaveAttribute("data-active"); + const pinButton = screen.getByText("MUL-123 Keep this pin").closest("button"); + expect(pinButton).toHaveAttribute("data-active", "true"); + }); + + it("still activates parent tab on a non-pinned item path", async () => { + detail.current = { isPending: false, isError: false, data: { identifier: "MUL-123", title: "Keep this pin", status: "todo" }, error: null }; + nav.pathname = "/acme/issues/issue-999"; + const { container } = render(); + const issuesTab = container.querySelector(".lucide-list-todo")?.closest("button"); + expect(issuesTab).not.toBeNull(); + expect(issuesTab).toHaveAttribute("data-active", "true"); + }); }); diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 1b8ac3a64..d4fafa8bb 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -365,6 +365,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } // write (our own optimistic update, or a WS refetch) cannot reorder the // DOM under dnd-kit while its drop animation is still interpolating. const [localPinned, setLocalPinned] = useState(pinnedItems); + // When the current path matches a pinned item exactly, the pin owns the + // sidebar highlight — suppress the workspace-group tab (Issues / Projects) + // so two rows don't appear active at once. Non-pinned detail pages still + // light up their parent tab via isNavActive's prefix match. + const pathMatchesPin = localPinned.some((pin) => { + const pinHref = pin.item_type === "issue" ? p.issueDetail(pin.item_id) : p.projectDetail(pin.item_id); + return pathname === pinHref; + }); const isDraggingRef = useRef(false); useEffect(() => { if (!isDraggingRef.current) { @@ -668,7 +676,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } {workspaceNav.map((item) => { const href = p[item.key](); - const isActive = isNavActive(pathname, href); + const isActive = !pathMatchesPin && isNavActive(pathname, href); return (