diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts
index e4a2db9172c..a1f2e209156 100644
--- a/packages/shared/src/types/instance.ts
+++ b/packages/shared/src/types/instance.ts
@@ -47,7 +47,6 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
- enableStreamlinedLeftNavigation: boolean;
enableConferenceRoomChat: boolean;
enableTaskWatchdogs: boolean;
enableIssuePlanDecompositions: boolean;
diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts
index 9015b094fb7..c519b45b32b 100644
--- a/packages/shared/src/validators/instance.ts
+++ b/packages/shared/src/validators/instance.ts
@@ -41,7 +41,6 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false),
- enableStreamlinedLeftNavigation: z.boolean().default(false),
enableConferenceRoomChat: z.boolean().default(false),
enableTaskWatchdogs: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts
index dd83b47b379..fd6a82d6aa4 100644
--- a/server/src/__tests__/instance-settings-service.test.ts
+++ b/server/src/__tests__/instance-settings-service.test.ts
@@ -14,12 +14,11 @@ describe("instance settings service", () => {
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
enableNewestFirstIssueThread: true,
+ enableStreamlinedLeftNavigation: true,
})).toEqual({
enableEnvironments: true,
enableIsolatedWorkspaces: true,
- enableStreamlinedLeftNavigation: false,
enableConferenceRoomChat: false,
- enableTaskWatchdogs: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
@@ -35,7 +34,7 @@ describe("instance settings service", () => {
expect(normalizeExperimentalSettings({}).enableConferenceRoomChat).toBe(false);
// Rows persisted before the flag existed (PAP-137) must normalize to off.
expect(
- normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: true }).enableConferenceRoomChat,
+ normalizeExperimentalSettings({ enableEnvironments: true }).enableConferenceRoomChat,
).toBe(false);
});
@@ -54,7 +53,6 @@ describe("instance settings service", () => {
const current = normalizeExperimentalSettings({});
const enabled = normalizeExperimentalSettings({ ...current, enableConferenceRoomChat: true });
expect(enabled.enableConferenceRoomChat).toBe(true);
- expect(enabled.enableStreamlinedLeftNavigation).toBe(false);
const disabled = normalizeExperimentalSettings({ ...enabled, enableConferenceRoomChat: false });
expect(disabled).toEqual(current);
diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts
index d18a5bbdd5f..51d881adefd 100644
--- a/server/src/services/instance-settings.ts
+++ b/server/src/services/instance-settings.ts
@@ -46,7 +46,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
return {
enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
- enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
@@ -62,7 +61,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
return {
enableEnvironments: false,
enableIsolatedWorkspaces: false,
- enableStreamlinedLeftNavigation: false,
enableConferenceRoomChat: false,
enableTaskWatchdogs: false,
enableIssuePlanDecompositions: false,
diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx
index df06f891378..dce289d8649 100644
--- a/ui/src/components/Sidebar.test.tsx
+++ b/ui/src/components/Sidebar.test.tsx
@@ -91,13 +91,7 @@ vi.mock("./SidebarCompanyMenu", () => ({
}));
vi.mock("./SidebarAgents", () => ({
- SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
-
- ),
-}));
-
-vi.mock("./SidebarProjects", () => ({
- SidebarProjects: () => Projects collapsible
,
+ SidebarAgents: () => ,
}));
async function flushReact() {
@@ -163,7 +157,6 @@ describe("Sidebar", () => {
it("renders plugin sidebar launchers inside the Work section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
- enableStreamlinedLeftNavigation: true,
});
const root = await renderSidebar();
@@ -180,11 +173,8 @@ describe("Sidebar", () => {
});
});
- it("streamlined (flag ON): keeps Task wording, top-level Projects link, no per-project collapsible", async () => {
- mockInstanceSettingsApi.getExperimental.mockResolvedValue({
- enableIsolatedWorkspaces: false,
- enableStreamlinedLeftNavigation: true,
- });
+ it("uses the streamlined navigation by default", async () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
expect(container.textContent).toContain("New Task");
@@ -197,37 +187,7 @@ describe("Sidebar", () => {
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 Task button, Tasks 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 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");
- // 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");
+ expect(container.querySelector('[data-testid="sidebar-agents"]')).not.toBeNull();
await act(async () => {
root.unmount();
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx
index 61d3f2b63fc..cb3a8e69d52 100644
--- a/ui/src/components/Sidebar.tsx
+++ b/ui/src/components/Sidebar.tsx
@@ -24,7 +24,6 @@ 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 { useSidebar } from "../context/SidebarContext";
@@ -57,11 +56,6 @@ 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
- // (per-project collapsible, no Projects nav link). ON = streamlined
- // (top-level Projects link). Issue/Task wording is split to PR #7651.
- // Gating is navigation-only; all routes stay registered in both modes.
- const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
// pattern as showWorkspacesLink above).
@@ -179,9 +173,7 @@ export function Sidebar() {
{showWorkspacesLink ? (
) : null}
- {streamlined ? (
-
- ) : null}
+
- {/* Classic mode restores the per-project collapsible below Work. */}
- {streamlined ? null : }
-
-
+
diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx
index d2162e67c9b..255fde6702b 100644
--- a/ui/src/components/SidebarAgents.test.tsx
+++ b/ui/src/components/SidebarAgents.test.tsx
@@ -264,21 +264,7 @@ describe("SidebarAgents", () => {
vi.clearAllMocks();
});
- async function renderSidebarAgents(streamlined = true) {
- const currentRoot = createRoot(container);
- root = currentRoot;
-
- await act(async () => {
- currentRoot.render(
-
-
- ,
- );
- });
- await flushReact();
- }
-
- async function renderSidebarAgentsWithDefaultProps() {
+ async function renderSidebarAgents() {
const currentRoot = createRoot(container);
root = currentRoot;
@@ -301,7 +287,7 @@ describe("SidebarAgents", () => {
currentRoot.render(
-
+
,
);
@@ -571,30 +557,7 @@ 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 () => {
+ it("uses the streamlined agent limit by default", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
makeAgent({
@@ -606,28 +569,10 @@ describe("SidebarAgents", () => {
);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
- await renderSidebarAgents(false);
+ await renderSidebarAgents();
- expect(agentLinkLabels(container)).toHaveLength(7);
- expect(seeAllAgentsLink(container)).toBeNull();
- });
-
- it("defaults to classic mode when rendered outside the Sidebar flag path", async () => {
- mockAgentsApi.list.mockResolvedValue(
- Array.from({ length: 7 }, (_, index) =>
- makeAgent({
- id: `agent-${index}`,
- name: `Agent ${index}`,
- urlKey: `agent-${index}`,
- }),
- ),
- );
- mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
-
- await renderSidebarAgentsWithDefaultProps();
-
- expect(agentLinkLabels(container)).toHaveLength(7);
- expect(seeAllAgentsLink(container)).toBeNull();
+ expect(agentLinkLabels(container)).toHaveLength(5);
+ expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});
it("does not offer sidebar resume for budget-paused agents", async () => {
diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx
index fc28a7c33e9..1e384d9be43 100644
--- a/ui/src/components/SidebarAgents.tsx
+++ b/ui/src/components/SidebarAgents.tsx
@@ -246,7 +246,7 @@ function SidebarAgentItem({
);
}
-export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
+export function SidebarAgents() {
const [open, setOpen] = useState(true);
const [pendingAgentIds, setPendingAgentIds] = useState>(() => new Set());
const queryClient = useQueryClient();
@@ -314,24 +314,18 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
[orderedAgents, sortMode],
);
- // 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.
+ // 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
+ // keeps the full list reachable.
const runningAgents = useMemo(
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
[sortedAgents, liveCountByAgent],
);
const hasActiveAgents = runningAgents.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 displayedAgents = hasActiveAgents
+ ? runningAgents
+ : sortedAgents.slice(0, RECENT_AGENT_LIMIT);
+ const showSeeAllLink = sortedAgents.length > 0;
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;
diff --git a/ui/src/components/SidebarProjects.test.tsx b/ui/src/components/SidebarProjects.test.tsx
deleted file mode 100644
index 6d8759348ee..00000000000
--- a/ui/src/components/SidebarProjects.test.tsx
+++ /dev/null
@@ -1,476 +0,0 @@
-// @vitest-environment jsdom
-
-import type { ReactNode } from "react";
-import { flushSync } from "react-dom";
-import { createRoot } from "react-dom/client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { Project, ResourceMemberships } from "@paperclipai/shared";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { SidebarProjects } from "./SidebarProjects";
-import { TooltipProvider } from "@/components/ui/tooltip";
-
-const mockProjectsApi = vi.hoisted(() => ({
- list: vi.fn(),
-}));
-
-const mockAuthApi = vi.hoisted(() => ({
- getSession: vi.fn(),
-}));
-
-const mockResourceMembershipsApi = vi.hoisted(() => ({
- listMine: vi.fn(),
- updateProject: vi.fn(),
-}));
-
-const mockOpenNewProject = vi.hoisted(() => vi.fn());
-const mockPushToast = vi.hoisted(() => vi.fn());
-const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
-const mockPersistOrder = vi.hoisted(() => vi.fn());
-const mockSidebarState = vi.hoisted(() => ({ isMobile: false, collapsed: false, peeking: false }));
-const mockPointerState = vi.hoisted(() => ({ fine: true }));
-
-vi.mock("@/lib/router", () => ({
- Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
- {children}
- ),
- NavLink: ({
- children,
- className,
- to,
- ...props
- }: {
- children: ReactNode;
- className?: string | ((state: { isActive: boolean }) => string);
- to: string;
- }) => (
-
- {children}
-
- ),
- useLocation: () => ({ pathname: "/PAP/projects/bravo/issues", search: "", hash: "", state: null }),
-}));
-
-vi.mock("../context/CompanyContext", () => ({
- useCompany: () => ({
- selectedCompanyId: "company-1",
- selectedCompany: { id: "company-1", issuePrefix: "PAP" },
- }),
-}));
-
-vi.mock("../context/DialogContext", () => ({
- useDialog: () => ({
- openNewProject: mockOpenNewProject,
- }),
- useDialogActions: () => ({
- openNewProject: mockOpenNewProject,
- }),
-}));
-
-vi.mock("../context/SidebarContext", () => ({
- useSidebar: () => ({
- isMobile: mockSidebarState.isMobile,
- setSidebarOpen: mockSetSidebarOpen,
- collapsed: mockSidebarState.collapsed,
- peeking: mockSidebarState.peeking,
- }),
-}));
-
-vi.mock("../context/ToastContext", () => ({
- useToastActions: () => ({
- pushToast: mockPushToast,
- }),
-}));
-
-vi.mock("../api/projects", () => ({
- projectsApi: mockProjectsApi,
-}));
-
-vi.mock("../api/auth", () => ({
- authApi: mockAuthApi,
-}));
-
-vi.mock("../api/resourceMemberships", () => ({
- resourceMembershipsApi: mockResourceMembershipsApi,
-}));
-
-vi.mock("../hooks/useProjectOrder", () => ({
- useProjectOrder: ({ projects }: { projects: Project[] }) => {
- const curatedOrder = ["project-b", "project-a", "project-c"];
- return {
- orderedProjects: [...projects].sort(
- (left, right) => curatedOrder.indexOf(left.id) - curatedOrder.indexOf(right.id),
- ),
- persistOrder: mockPersistOrder,
- };
- },
-}));
-
-vi.mock("@/plugins/slots", () => ({
- usePluginSlots: () => ({
- slots: [{ id: "slot-1", pluginKey: "plugin-1" }],
- }),
- PluginSlotMount: ({ context }: { context: { projectId: string } }) => (
- Plugin slot
- ),
-}));
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
-
-if (!globalThis.PointerEvent) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (globalThis as any).PointerEvent = MouseEvent;
-}
-
-async function act(callback: () => void | Promise) {
- let result: void | Promise = undefined;
- flushSync(() => {
- result = callback();
- });
- await result;
-}
-
-function makeProject(overrides: Partial): Project {
- return {
- id: "project-a",
- companyId: "company-1",
- urlKey: "alpha",
- goalId: null,
- goalIds: [],
- goals: [],
- name: "Alpha",
- description: null,
- status: "in_progress",
- leadAgentId: null,
- targetDate: null,
- color: "#ef4444",
- icon: null,
- env: null,
- pauseReason: null,
- pausedAt: null,
- executionWorkspacePolicy: null,
- codebase: {
- workspaceId: null,
- repoUrl: null,
- repoRef: null,
- defaultRef: null,
- repoName: null,
- localFolder: null,
- managedFolder: "/tmp/project-a",
- effectiveLocalFolder: "/tmp/project-a",
- origin: "local_folder",
- },
- workspaces: [],
- primaryWorkspace: null,
- managedByPlugin: null,
- archivedAt: null,
- createdAt: new Date("2026-01-01T00:00:00Z"),
- updatedAt: new Date("2026-01-01T00:00:00Z"),
- ...overrides,
- };
-}
-
-async function flushReact() {
- await act(async () => {
- await Promise.resolve();
- await new Promise((resolve) => window.setTimeout(resolve, 0));
- });
-}
-
-function projectLinkLabels(container: HTMLElement) {
- return Array.from(container.querySelectorAll('a[href$="/issues"]'))
- .map((anchor) => anchor.textContent?.replace("Plugin slot", "").trim())
- .filter(Boolean);
-}
-
-async function openProjectsMenu(container: HTMLElement) {
- const trigger = container.querySelector('button[aria-label="Projects section actions"]');
- expect(trigger).not.toBeNull();
-
- await act(async () => {
- trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
- trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-}
-
-async function openProjectMenu(label = "Open actions for Alpha") {
- const trigger = document.body.querySelector(`button[aria-label="${label}"]`);
- expect(trigger).not.toBeNull();
-
- await act(async () => {
- trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
- trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-}
-
-async function chooseSortMode(label: string) {
- const item = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
- .find((element) => element.textContent?.includes(label));
- expect(item).toBeTruthy();
-
- await act(async () => {
- item?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-}
-
-describe("SidebarProjects", () => {
- let container: HTMLDivElement;
- let root: ReturnType | null;
- let queryClient: QueryClient;
- let memberships: ResourceMemberships;
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- root = null;
- queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
- });
- localStorage.clear();
- mockSidebarState.isMobile = false;
- mockSidebarState.collapsed = false;
- mockSidebarState.peeking = false;
- mockPointerState.fine = true;
- Object.defineProperty(window, "matchMedia", {
- writable: true,
- value: vi.fn().mockImplementation((query: string) => ({
- matches: query.includes("(hover: hover)") && query.includes("(pointer: fine)")
- ? mockPointerState.fine
- : false,
- media: query,
- onchange: null,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- addListener: vi.fn(),
- removeListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- });
- mockProjectsApi.list.mockResolvedValue([
- makeProject({
- id: "project-a",
- urlKey: "alpha",
- name: "Alpha",
- createdAt: new Date("2026-01-01T00:00:00Z"),
- updatedAt: new Date("2026-01-05T00:00:00Z"),
- }),
- makeProject({
- id: "project-b",
- urlKey: "bravo",
- name: "Bravo",
- createdAt: new Date("2026-01-02T00:00:00Z"),
- updatedAt: new Date("2026-01-10T00:00:00Z"),
- }),
- makeProject({
- id: "project-c",
- urlKey: "charlie",
- name: "Charlie",
- createdAt: new Date("2026-01-03T00:00:00Z"),
- updatedAt: new Date("2026-01-12T00:00:00Z"),
- }),
- ]);
- mockAuthApi.getSession.mockResolvedValue({
- session: { id: "session-1", userId: "user-1" },
- user: { id: "user-1" },
- });
- memberships = {
- projectMemberships: {},
- agentMemberships: {},
- updatedAt: null,
- };
- mockResourceMembershipsApi.listMine.mockImplementation(() => Promise.resolve(memberships));
- mockResourceMembershipsApi.updateProject.mockImplementation((_companyId, projectId, data) => {
- memberships = {
- ...memberships,
- projectMemberships: {
- ...memberships.projectMemberships,
- [projectId]: data.state,
- },
- updatedAt: new Date(),
- };
- return Promise.resolve({
- resourceType: "project",
- resourceId: projectId,
- state: data.state,
- });
- });
- });
-
- afterEach(async () => {
- const currentRoot = root;
- if (currentRoot) {
- await act(async () => {
- currentRoot.unmount();
- });
- }
- queryClient.clear();
- container.remove();
- document.body.innerHTML = "";
- localStorage.clear();
- vi.clearAllMocks();
- });
-
- async function renderSidebarProjects() {
- const currentRoot = createRoot(container);
- root = currentRoot;
-
- await act(async () => {
- currentRoot.render(
-
-
- ,
- );
- });
- await flushReact();
- }
-
- async function renderRailSidebarProjects() {
- mockSidebarState.collapsed = true;
- const currentRoot = createRoot(container);
- root = currentRoot;
-
- await act(async () => {
- currentRoot.render(
-
-
-
-
- ,
- );
- });
- await flushReact();
- }
-
- it("renders icon-only project rows with tooltips and no row actions in the rail", async () => {
- await renderRailSidebarProjects();
-
- // Project names stay in the a11y tree but kept in flow (zero-width, clipped)
- // so rows stay 1:1 tall with the expanded state (PAP-10676); rows become
- // tooltip triggers and the per-row actions dropdown is dropped.
- const nameSpan = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Bravo");
- expect(nameSpan?.className).not.toContain("sr-only");
- expect(nameSpan?.className).toContain("w-0");
- expect(nameSpan?.className).toContain("overflow-hidden");
- const projectLink = container.querySelector('a[href^="/projects/"]');
- expect(projectLink?.parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
- expect(container.querySelector('button[aria-label="Open actions for Bravo"]')).toBeNull();
- // The section header collapses to a divider (no section menu trigger).
- expect(container.querySelector('button[aria-label="Projects section actions"]')).toBeNull();
- });
-
- it("keeps top mode in curated order and renders plugin project slots", async () => {
- await renderSidebarProjects();
-
- expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
- expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
- expect(container.querySelector('[aria-roledescription="sortable"]')).toBeTruthy();
- });
-
- it("uses plain project rows for top mode on mobile", async () => {
- mockSidebarState.isMobile = true;
-
- await renderSidebarProjects();
-
- expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
- expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
- });
-
- it("uses plain project rows for top mode on coarse pointer screens", async () => {
- mockPointerState.fine = false;
-
- await renderSidebarProjects();
-
- expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
- expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
- });
-
- it("uses the heading for section menu and the plus button for project creation", async () => {
- await renderSidebarProjects();
-
- 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).toBeFalsy();
- const browseLink = Array.from(document.body.querySelectorAll("a"))
- .find((element) => element.textContent?.includes("Browse projects"));
- expect(browseLink?.getAttribute("href")).toBe("/projects");
- });
-
- it("sorts alphabetically and persists the selected mode per company and user", async () => {
- await renderSidebarProjects();
- await openProjectsMenu(container);
- await chooseSortMode("Alphabetical");
-
- expect(projectLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
- expect(localStorage.getItem("paperclip.projectSortMode:company-1:user-1")).toBe("alphabetical");
- });
-
- it("sorts recent projects by updated time descending", async () => {
- await renderSidebarProjects();
- await openProjectsMenu(container);
- await chooseSortMode("Recent");
-
- expect(projectLinkLabels(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
- });
-
- it("filters left projects only after membership state loads", async () => {
- let resolveMemberships!: (value: unknown) => void;
- mockResourceMembershipsApi.listMine.mockReturnValue(new Promise((resolve) => {
- resolveMemberships = resolve;
- }));
-
- await renderSidebarProjects();
- expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
-
- await act(async () => {
- resolveMemberships({
- projectMemberships: { "project-a": "left" },
- agentMemberships: {},
- updatedAt: null,
- });
- });
- await flushReact();
-
- expect(projectLinkLabels(container)).toEqual(["Bravo", "Charlie"]);
- });
-
- it("offers leave project from each sidebar project menu", async () => {
- await renderSidebarProjects();
- await openProjectMenu();
-
- const leaveItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Leave project"));
- expect(leaveItem).toBeTruthy();
-
- await act(async () => {
- leaveItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-
- expect(mockResourceMembershipsApi.updateProject).toHaveBeenCalledWith(
- "company-1",
- "project-a",
- { state: "left" },
- );
- expect(projectLinkLabels(container)).toEqual(["Bravo", "Charlie"]);
- });
-});
diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx
deleted file mode 100644
index 968e05d8d89..00000000000
--- a/ui/src/components/SidebarProjects.tsx
+++ /dev/null
@@ -1,464 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { NavLink, useLocation } from "@/lib/router";
-import { useQuery } from "@tanstack/react-query";
-import { FolderOpen, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react";
-import {
- DndContext,
- MouseSensor,
- closestCenter,
- type DragEndEvent,
- useSensor,
- useSensors,
-} from "@dnd-kit/core";
-import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { useCompany } from "../context/CompanyContext";
-import { useDialogActions } from "../context/DialogContext";
-import { useSidebar } from "../context/SidebarContext";
-import { authApi } from "../api/auth";
-import { projectsApi } from "../api/projects";
-import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
-import { queryKeys } from "../lib/queryKeys";
-import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
-import { useProjectOrder } from "../hooks/useProjectOrder";
-import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
-import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
-import { ProjectTile } from "./ProjectTile";
-import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
-import {
- getProjectSortModeStorageKey,
- PROJECT_SORT_MODE_UPDATED_EVENT,
- readProjectSortMode,
- type ProjectSortModeUpdatedDetail,
- type ProjectSidebarSortMode,
- writeProjectSortMode,
-} from "../lib/project-order";
-import type { Project } from "@paperclipai/shared";
-
-type ProjectSidebarSlot = ReturnType["slots"][number];
-
-const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
- { value: "top", label: "Top" },
- { value: "alphabetical", label: "Alphabetical" },
- { value: "recent", label: "Recent" },
-];
-const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)";
-
-type ProjectItemProps = {
- activeProjectRef: string | null;
- companyId: string | null;
- companyPrefix: string | null;
- isMobile: boolean;
- project: Project;
- projectSidebarSlots: ProjectSidebarSlot[];
- rail: boolean;
- setSidebarOpen: (open: boolean) => void;
- onLeaveProject: (project: Project) => void;
- leaving?: boolean;
- isDragging?: boolean;
-};
-
-function projectTimestamp(project: Project): number {
- const updated = new Date(project.updatedAt).getTime();
- if (Number.isFinite(updated)) return updated;
- const created = new Date(project.createdAt).getTime();
- return Number.isFinite(created) ? created : 0;
-}
-
-function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Project[] {
- if (sortMode === "top") return projects;
- const sorted = [...projects];
- if (sortMode === "alphabetical") {
- sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
- return sorted;
- }
- sorted.sort((left, right) => {
- const timeDiff = projectTimestamp(right) - projectTimestamp(left);
- return timeDiff !== 0 ? timeDiff : left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
- });
- return sorted;
-}
-
-function hasFineReorderPointer() {
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
- return window.matchMedia(REORDER_POINTER_MEDIA).matches;
-}
-
-function useFineReorderPointer() {
- const [matches, setMatches] = useState(hasFineReorderPointer);
-
- useEffect(() => {
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
- const query = window.matchMedia(REORDER_POINTER_MEDIA);
- const onChange = (event: MediaQueryListEvent) => setMatches(event.matches);
- setMatches(query.matches);
- query.addEventListener("change", onChange);
- return () => query.removeEventListener("change", onChange);
- }, []);
-
- return matches;
-}
-
-function ProjectItem({
- activeProjectRef,
- companyId,
- companyPrefix,
- isMobile,
- project,
- projectSidebarSlots,
- rail,
- setSidebarOpen,
- onLeaveProject,
- leaving = false,
- isDragging = false,
-}: ProjectItemProps) {
- const routeRef = projectRouteRef(project);
-
- const link = (
- {
- if (isDragging) {
- e.preventDefault();
- return;
- }
- if (isMobile) setSidebarOpen(false);
- }}
- className={cn(
- "flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
- activeProjectRef === routeRef || activeProjectRef === project.id
- ? "bg-accent text-foreground"
- : "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
- )}
- >
-
- {project.name}
- {!rail && project.pauseReason === "budget" ? : null}
-
- );
-
- return (
-
-
- {!rail && projectSidebarSlots.length > 0 && (
-
- {projectSidebarSlots.map((slot) => (
-
- ))}
-
- )}
-
- );
-}
-
-function SortableProjectItem(props: ProjectItemProps) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: props.project.id });
-
- return (
-
- );
-}
-
-export function SidebarProjects() {
- const [open, setOpen] = useState(true);
- const { selectedCompany, selectedCompanyId } = useCompany();
- const { openNewProject } = useDialogActions();
- const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
- const rail = collapsed && !peeking;
- const fineReorderPointer = useFineReorderPointer();
- const location = useLocation();
-
- const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
- enabled: !!selectedCompanyId,
- });
- const membershipsQuery = useResourceMemberships(selectedCompanyId);
- const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
- const { data: session } = useQuery({
- queryKey: queryKeys.auth.session,
- queryFn: () => authApi.getSession(),
- });
- const { slots: projectSidebarSlots } = usePluginSlots({
- slotTypes: ["projectSidebarItem"],
- entityType: "project",
- companyId: selectedCompanyId,
- enabled: !!selectedCompanyId,
- });
-
- const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
- const sortModeStorageKey = useMemo(() => {
- if (!selectedCompanyId) return null;
- return getProjectSortModeStorageKey(selectedCompanyId, currentUserId);
- }, [currentUserId, selectedCompanyId]);
- const [sortMode, setSortMode] = useState(() => {
- if (!sortModeStorageKey) return "top";
- return readProjectSortMode(sortModeStorageKey);
- });
-
- const visibleProjects = useMemo(
- () => (projects ?? []).filter((project: Project) => {
- if (project.archivedAt) return false;
- if (!membershipsQuery.isSuccess) return true;
- return resourceMembershipState(membershipsQuery.data, "project", project.id) !== "left";
- }),
- [membershipsQuery.data, membershipsQuery.isSuccess, projects],
- );
- const { orderedProjects, persistOrder } = useProjectOrder({
- projects: visibleProjects,
- companyId: selectedCompanyId,
- userId: currentUserId,
- });
- const sortedProjects = useMemo(
- () => sortProjects(orderedProjects, sortMode),
- [orderedProjects, sortMode],
- );
- const isTopMode = sortMode === "top";
- const canReorderProjects = isTopMode && !isMobile && fineReorderPointer;
-
- const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
- const activeProjectRef = projectMatch?.[1] ?? null;
- const sensors = useSensors(
- // Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
- useSensor(MouseSensor, {
- activationConstraint: { distance: 8 },
- }),
- );
-
- useEffect(() => {
- if (!sortModeStorageKey) {
- setSortMode("top");
- return;
- }
- setSortMode(readProjectSortMode(sortModeStorageKey));
- }, [sortModeStorageKey]);
-
- useEffect(() => {
- if (!sortModeStorageKey) return;
-
- const onStorage = (event: StorageEvent) => {
- if (event.key !== sortModeStorageKey) return;
- setSortMode(readProjectSortMode(sortModeStorageKey));
- };
- const onCustomEvent = (event: Event) => {
- const detail = (event as CustomEvent).detail;
- if (!detail || detail.storageKey !== sortModeStorageKey) return;
- setSortMode(detail.sortMode);
- };
-
- window.addEventListener("storage", onStorage);
- window.addEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
- return () => {
- window.removeEventListener("storage", onStorage);
- window.removeEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
- };
- }, [sortModeStorageKey]);
-
- const persistSortMode = useCallback(
- (value: string) => {
- const nextSortMode: ProjectSidebarSortMode =
- value === "alphabetical" || value === "recent" ? value : "top";
- setSortMode(nextSortMode);
- if (sortModeStorageKey) {
- writeProjectSortMode(sortModeStorageKey, nextSortMode);
- }
- },
- [sortModeStorageKey],
- );
-
- const handleDragEnd = useCallback(
- (event: DragEndEvent) => {
- if (!isTopMode) return;
- const { active, over } = event;
- if (!over || active.id === over.id) return;
-
- const ids = orderedProjects.map((project) => project.id);
- const oldIndex = ids.indexOf(active.id as string);
- const newIndex = ids.indexOf(over.id as string);
- if (oldIndex === -1 || newIndex === -1) return;
-
- persistOrder(arrayMove(ids, oldIndex, newIndex));
- },
- [isTopMode, orderedProjects, persistOrder],
- );
-
- const leaveProject = useCallback(
- (project: Project) => membershipMutation.mutate({
- resourceType: "project",
- resourceId: project.id,
- resourceName: project.name,
- state: "left",
- }),
- [membershipMutation],
- );
- const projectLeaving = useCallback(
- (project: Project) =>
- membershipMutation.isPending &&
- membershipMutation.variables?.resourceType === "project" &&
- membershipMutation.variables.resourceId === project.id,
- [membershipMutation.isPending, membershipMutation.variables],
- );
-
- const renderProject = (project: Project) => (
-
- );
-
- return (
-
- {canReorderProjects ? (
-
- project.id)}
- strategy={verticalListSortingStrategy}
- >
-
- {orderedProjects.map((project: Project) => (
-
- ))}
-
-
-
- ) : (
-
- {sortedProjects.map((project: Project) => renderProject(project))}
-
- )}
-
- );
-}
diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx
index 6e6247e7749..05a143e7d47 100644
--- a/ui/src/pages/InstanceExperimentalSettings.test.tsx
+++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx
@@ -47,7 +47,6 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
return {
enableEnvironments: false,
enableIsolatedWorkspaces: false,
- enableStreamlinedLeftNavigation: false,
enableConferenceRoomChat: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
@@ -115,11 +114,20 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await renderPage();
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
- expect(headings).toContain("Streamlined Left Navigation Bar");
expect(headings).not.toContain("Conference Room Chat");
expect(container.querySelector(CONFERENCE_TOGGLE_SELECTOR)).toBeNull();
});
+ it("does not render the retired streamlined left navigation setting", async () => {
+ await renderPage();
+
+ const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
+ expect(headings).not.toContain("Streamlined Left Navigation Bar");
+ expect(
+ container.querySelector('button[aria-label="Toggle streamlined left navigation experimental setting"]'),
+ ).toBeNull();
+ });
+
it("does not render the toggle even when the stored flag is currently enabled", async () => {
currentExperimentalSettings = {
...currentExperimentalSettings,
diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx
index 0e39845adf9..eb3e5d12196 100644
--- a/ui/src/pages/InstanceExperimentalSettings.tsx
+++ b/ui/src/pages/InstanceExperimentalSettings.tsx
@@ -232,8 +232,6 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
- const enableStreamlinedLeftNavigation =
- experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
@@ -371,28 +369,6 @@ export function InstanceExperimentalSettings() {
-
-
-
-
Streamlined Left Navigation Bar
-
- Reduces the maximum number of items in the left navigation bar — nests Projects under Work with a
- dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
-
-
-
- toggleMutation.mutate({
- enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
- })
- }
- disabled={toggleMutation.isPending}
- aria-label="Toggle streamlined left navigation experimental setting"
- />
-
-
-
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (