From 69a97eabe849860d95b731f8636d012bc6f3517f Mon Sep 17 00:00:00 2001 From: scotttong Date: Fri, 5 Jun 2026 17:46:11 -0700 Subject: [PATCH] PAP-89: gate sidebar nav presentation on enableStreamlinedLeftNavigation Branch the left-nav presentation on the experimental flag (default OFF = classic), navigation-only. All routes stay registered in both modes; destination pages are untouched. - Sidebar.tsx: read the flag once and thread a `streamlined` boolean. OFF (classic): "New Issue" button, "Issues" label, no top-level Projects link, restore collapsible below Work. ON (streamlined, current): "New Task", "Tasks", Projects nav item -> /projects. - SidebarAgents.tsx: accept `streamlined` prop; OFF restores show-all (no active/recent-5 filter, no "See all agents" link). - MobileBottomNav.tsx: mirror the Tasks/Issues label set on the flag. - D5 (option A): classic does NOT add a top-level /projects link; /projects stays registered and reachable by URL. - Tests: Sidebar.test + SidebarAgents.test cover BOTH flag states. Verified live toggle at /instance/settings/experimental switches classic <-> streamlined in light + dark (Playwright). ui typecheck clean; 29 sidebar tests pass. Co-Authored-By: Claude Opus 4.8 --- ui/src/components/MobileBottomNav.tsx | 13 ++++- ui/src/components/Sidebar.test.tsx | 67 +++++++++++++++++++++++- ui/src/components/Sidebar.tsx | 21 ++++++-- ui/src/components/SidebarAgents.test.tsx | 45 +++++++++++++++- ui/src/components/SidebarAgents.tsx | 24 +++++---- 5 files changed, 149 insertions(+), 21 deletions(-) diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index e32a68b93c4..4a347007cfe 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { NavLink, useLocation } from "@/lib/router"; import { House, @@ -9,6 +10,8 @@ import { } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; +import { instanceSettingsApi } from "../api/instanceSettings"; +import { queryKeys } from "../lib/queryKeys"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { cn } from "../lib/utils"; import { useInboxBadge } from "../hooks/useInboxBadge"; @@ -39,11 +42,17 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialogActions(); const inboxBadge = useInboxBadge(selectedCompanyId); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + // IA flag (PAP-89): mirror the sidebar's classic/streamlined label set. + const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true; const items = useMemo( () => [ { type: "link", to: "/dashboard", label: "Home", icon: House }, - { type: "link", to: "/issues", label: "Tasks", icon: CircleDot }, + { type: "link", to: "/issues", label: streamlined ? "Tasks" : "Issues", icon: CircleDot }, { type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() }, { type: "link", to: "/agents/all", label: "Agents", icon: Users }, { @@ -54,7 +63,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { badge: inboxBadge.inbox, }, ], - [openNewIssue, inboxBadge.inbox], + [openNewIssue, inboxBadge.inbox, streamlined], ); return ( diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 323c31d6e1a..9bffd1fe26c 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -83,7 +83,13 @@ vi.mock("./SidebarCompanyMenu", () => ({ })); vi.mock("./SidebarAgents", () => ({ - SidebarAgents: () => null, + SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => ( +
+ ), +})); + +vi.mock("./SidebarProjects", () => ({ + SidebarProjects: () =>
Projects collapsible
, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -144,7 +150,10 @@ describe("Sidebar", () => { }); it("renders plugin sidebar launchers inside the Work section", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableStreamlinedLeftNavigation: true, + }); const root = await renderSidebar(); const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")] @@ -160,6 +169,60 @@ describe("Sidebar", () => { }); }); + it("streamlined (flag ON): New Task button, Tasks label, top-level Projects link, no per-project collapsible", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableStreamlinedLeftNavigation: true, + }); + const root = await renderSidebar(); + + expect(container.textContent).toContain("New Task"); + expect(container.textContent).not.toContain("New Issue"); + + const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim()); + expect(navLabels).toContain("Tasks"); + expect(navLabels).not.toContain("Issues"); + + const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects"); + expect(projectsLink?.getAttribute("href")).toBe("/projects"); + + expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull(); + expect( + container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"), + ).toBe("true"); + + await act(async () => { + root.unmount(); + }); + }); + + it("classic (flag OFF): New Issue button, Issues label, per-project collapsible, no top-level Projects link", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableStreamlinedLeftNavigation: false, + }); + const root = await renderSidebar(); + + expect(container.textContent).toContain("New Issue"); + expect(container.textContent).not.toContain("New Task"); + + const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim()); + expect(navLabels).toContain("Issues"); + expect(navLabels).not.toContain("Tasks"); + // No top-level Projects nav link in classic mode (D5 option A). + expect(navLabels).not.toContain("Projects"); + + // Per-project collapsible restored below Work. + expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"), + ).toBe("false"); + + await act(async () => { + root.unmount(); + }); + }); + it("renders plugin sidebar slots in Work below Workspaces", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); const root = await renderSidebar(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 72e9f5c6cf3..9776e5ba042 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { NavLink } from "@/lib/router"; import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarAgents } from "./SidebarAgents"; +import { SidebarProjects } from "./SidebarProjects"; import { useDialogActions } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { heartbeatsApi } from "../api/heartbeats"; @@ -46,6 +47,11 @@ export function Sidebar() { }); const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; + // IA flag (PAP-89): branch the sidebar nav presentation. Default OFF = classic + // ("New Issue", "Issues", per-project collapsible, no Projects nav link). + // ON = streamlined (current: "New Task", "Tasks", top-level Projects link). + // Gating is navigation-only; all routes stay registered in both modes. + const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true; const pluginContext = { companyId: selectedCompanyId, @@ -73,14 +79,14 @@ export function Sidebar() {