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 ( - - ); -} - -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 ? (