Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
b0908bcb07 fix(sidebar): suppress parent tab highlight on pinned item paths
When the user navigates to a pinned issue or project detail page, both
the pin row and the workspace-group tab (Issues / Projects) light up
because `isNavActive` matches sub-paths via prefix. Detect when the
current path equals a pinned item's href and skip the parent-tab
activation so only the pin appears active.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 14:58:37 +08:00
2 changed files with 36 additions and 4 deletions

View File

@@ -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 }) => <button type="button">{children}</button>,
SidebarMenuButton: ({ children, isActive }: { children: React.ReactNode; isActive?: boolean }) => (
<button type="button" data-active={isActive ? "true" : undefined}>{children}</button>
),
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: () => <span /> }));
vi.mock("../navigation", () => ({
AppLink: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useNavigation: () => ({ pathname: "/acme/issues", push: vi.fn() }),
useNavigation: () => ({ pathname: nav.pathname, push: vi.fn() }),
}));
vi.mock("../projects/components/project-icon", () => ({ ProjectIcon: () => <span /> }));
vi.mock("../workspace/workspace-avatar", () => ({ WorkspaceAvatar: () => <span /> }));
@@ -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(<AppSidebar />);
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(<AppSidebar />);
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(<AppSidebar />);
const issuesTab = container.querySelector(".lucide-list-todo")?.closest("button");
expect(issuesTab).not.toBeNull();
expect(issuesTab).toHaveAttribute("data-active", "true");
});
});

View File

@@ -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<PinnedItem[]>(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 }
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
const href = p[item.key]();
const isActive = isNavActive(pathname, href);
const isActive = !pathMatchesPin && isNavActive(pathname, href);
return (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton