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 <SidebarProjects /> 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 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-06-05 17:46:11 -07:00
1 parent c007fc19f0
commit 69a97eabe8
5 files changed
+149 -21

No files matched your search

+11 -2
View File
@@ -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<MobileNavItem[]>(
() => [
{ 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 (
+65 -2
View File
@@ -83,7 +83,13 @@ vi.mock("./SidebarCompanyMenu", () => ({
}));
vi.mock("./SidebarAgents", () => ({
SidebarAgents: () => null,
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
),
}));
vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
}));
// 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();
+16 -5
View File
@@ -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() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
{/* New Task button aligned with nav items */}
{/* New Task / New Issue button aligned with nav items */}
<button
onClick={() => openNewIssue()}
data-slot="icon-button"
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
>
<SquarePen className="h-4 w-4 shrink-0" />
<span className="truncate">New Task</span>
<span className="truncate">{streamlined ? "New Task" : "New Issue"}</span>
</button>
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem
@@ -94,13 +100,15 @@ export function Sidebar() {
</div>
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
<SidebarNavItem to="/issues" label={streamlined ? "Tasks" : "Issues"} icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
{streamlined ? (
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
) : null}
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
@@ -116,7 +124,10 @@ export function Sidebar() {
/>
</SidebarSection>
<SidebarAgents />
{/* Classic mode restores the per-project collapsible below Work. */}
{streamlined ? null : <SidebarProjects />}
<SidebarAgents streamlined={streamlined} />
<SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} />
+43 -2
View File
@@ -258,14 +258,14 @@ describe("SidebarAgents", () => {
vi.clearAllMocks();
});
async function renderSidebarAgents() {
async function renderSidebarAgents(streamlined = true) {
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<SidebarAgents />
<SidebarAgents streamlined={streamlined} />
</QueryClientProvider>,
);
});
@@ -514,6 +514,47 @@ describe("SidebarAgents", () => {
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});
it("classic mode (flag OFF) shows all agents and no See all link even when one is running", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
{ id: "run-1", agentId: "agent-b", status: "running" },
]);
await renderSidebarAgents(false);
// Show-all: every agent is listed regardless of live-run state. (Bravo's
// label includes its live-run badge text, so match by prefix.)
const labels = agentLinkLabels(container);
expect(labels).toHaveLength(3);
expect(labels[0]).toBe("Alpha");
expect(labels[1]).toContain("Bravo");
expect(labels[2]).toBe("Charlie");
// No recent-5 truncation, so no "See all agents" link in classic mode.
expect(seeAllAgentsLink(container)).toBeNull();
});
it("classic mode (flag OFF) shows more than 5 agents without truncation", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
makeAgent({
id: `agent-${index}`,
name: `Agent ${index}`,
urlKey: `agent-${index}`,
}),
),
);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
await renderSidebarAgents(false);
expect(agentLinkLabels(container)).toHaveLength(7);
expect(seeAllAgentsLink(container)).toBeNull();
});
it("does not offer sidebar resume for budget-paused agents", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
+14 -10
View File
@@ -216,7 +216,7 @@ function SidebarAgentItem({
);
}
export function SidebarAgents() {
export function SidebarAgents({ streamlined = true }: { streamlined?: boolean } = {}) {
const [open, setOpen] = useState(true);
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
const queryClient = useQueryClient();
@@ -283,20 +283,24 @@ export function SidebarAgents() {
[orderedAgents, sortMode],
);
// IA Phase 5: if any agent has a live run, show only those active agents.
// Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either way a
// "See all agents" link is shown so the full list is always reachable.
// IA Phase 5 (streamlined): if any agent has a live run, show only those
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
// way a "See all agents" link is shown so the full list is always reachable.
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
const runningAgents = useMemo(
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
[sortedAgents, liveCountByAgent],
);
const hasActiveAgents = runningAgents.length > 0;
const displayedAgents = hasActiveAgents
? runningAgents
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
// Always expose "See all agents" whenever there are agents — even in the
// active-only state — so users never lose the entry point to the full list.
const showSeeAllLink = sortedAgents.length > 0;
const displayedAgents = !streamlined
? sortedAgents
: hasActiveAgents
? runningAgents
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
// Always expose "See all agents" whenever the displayed list is a subset of all
// agents, so users never lose the entry point to the full list. In classic mode
// every agent is already shown, so the link is unnecessary.
const showSeeAllLink = streamlined && sortedAgents.length > 0;
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;