[PAP-3180] Move workspace switcher into sidebar (#4981)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - The board UI needs a clear persistent way to move between company
workspaces.
> - The previous layout kept company switching in a separate left rail,
which made the sidebar feel split between workspace selection and
navigation.
> - The workspace switcher belongs in the sidebar header so navigation
and workspace context stay together.
> - This pull request removes the separate company rail from the layout
and turns the sidebar company menu into the primary workspace switcher.
> - The benefit is a cleaner sidebar structure that keeps workspace
identity, switching, company actions, and navigation in one place.

## What Changed

- Removed the standalone `CompanyRail` from the main layout.
- Added the company/workspace switcher to the default, company settings,
and instance settings sidebars.
- Expanded `SidebarCompanyMenu` to list active workspaces, indicate the
current workspace, navigate out of instance settings when switching, and
expose add-company onboarding.
- Updated focused component tests for the new workspace-switcher
behavior.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/SidebarCompanyMenu.test.tsx
src/components/CompanySettingsSidebar.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `git diff --check`
- Visual smoke attempted against the managed dev server at
`http://127.0.0.1:57385`; a fresh browser context reached the
authenticated sign-in screen, so I could not capture an authenticated
sidebar screenshot from this heartbeat.

## Risks

- Low-to-medium UI risk: this changes the primary sidebar structure and
workspace-switching entry point.
- The instance-settings switch behavior now routes back to the selected
company dashboard when a workspace is selected.
- No migrations, API contracts, or lockfile changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled, medium reasoning mode.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-02 08:13:53 -05:00
committed by GitHub
parent 685ee84e4a
commit 76f09c8eb6
6 changed files with 180 additions and 27 deletions
@@ -53,6 +53,10 @@ vi.mock("./SidebarNavItem", () => ({
},
}));
vi.mock("./SidebarCompanyMenu", () => ({
SidebarCompanyMenu: () => <div>Workspace switcher</div>,
}));
vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
+5 -1
View File
@@ -7,6 +7,7 @@ import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
@@ -32,7 +33,10 @@ export function CompanySettingsSidebar() {
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
<SidebarCompanyMenu />
</div>
<div className="flex flex-col gap-1 px-3 pb-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
+7 -5
View File
@@ -5,6 +5,7 @@ import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function InstanceSidebar() {
const { data: plugins } = useQuery({
@@ -14,11 +15,12 @@ export function InstanceSidebar() {
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
<span className="flex-1 text-sm font-bold text-foreground truncate">
Instance Settings
</span>
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
<SidebarCompanyMenu />
</div>
<div className="flex items-center gap-2 px-5 pb-3 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">Instance Settings</span>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
-3
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
@@ -336,7 +335,6 @@ export function Layout() {
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
{isInstanceSettingsRoute ? (
<InstanceSidebar />
) : isCompanySettingsRoute ? (
@@ -354,7 +352,6 @@ export function Layout() {
) : (
<div className="flex h-full flex-col shrink-0">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",
+85 -1
View File
@@ -14,7 +14,11 @@ const mockAuthApi = vi.hoisted(() => ({
updateProfile: vi.fn(),
signOut: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockOpenOnboarding = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/dashboard" }));
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
@@ -24,18 +28,51 @@ vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => mockLocation,
useNavigate: () => mockNavigate,
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companies: [
{
id: "company-1",
issuePrefix: "PAP",
name: "Acme Labs",
brandColor: "#3366ff",
status: "active",
},
{
id: "company-2",
issuePrefix: "STR",
name: "Strata",
brandColor: "#36a269",
status: "active",
},
],
selectedCompany: {
id: "company-1",
issuePrefix: "PAP",
name: "Acme Labs",
brandColor: "#3366ff",
status: "active",
},
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("@/context/DialogContext", () => ({
useDialogActions: () => ({
openOnboarding: mockOpenOnboarding,
}),
}));
vi.mock("./CompanyPatternIcon", () => ({
CompanyPatternIcon: ({ companyName }: { companyName: string }) => (
<span aria-hidden="true">{companyName.slice(0, 1)}</span>
),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
@@ -68,6 +105,7 @@ describe("SidebarCompanyMenu", () => {
},
});
mockAuthApi.signOut.mockResolvedValue(undefined);
mockLocation.pathname = "/PAP/dashboard";
});
afterEach(() => {
@@ -94,7 +132,7 @@ describe("SidebarCompanyMenu", () => {
expect(container.textContent).toContain("Acme Labs");
const trigger = container.querySelector('button[aria-label="Open Acme Labs menu"]');
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
expect(trigger).not.toBeNull();
await act(async () => {
@@ -103,6 +141,9 @@ describe("SidebarCompanyMenu", () => {
});
await flushReact();
expect(document.body.textContent).toContain("Switch workspace");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("Add company...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
@@ -122,4 +163,47 @@ describe("SidebarCompanyMenu", () => {
root.unmount();
});
});
it("navigates to the selected workspace dashboard from company-prefixed routes", async () => {
mockLocation.pathname = "/PAP/issues";
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
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();
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Strata"));
expect(strataItem).toBeTruthy();
await act(async () => {
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
expect(mockNavigate).toHaveBeenCalledWith("/STR/dashboard");
await act(async () => {
root.unmount();
});
});
});
+79 -17
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, LogOut, Settings, UserPlus } from "lucide-react";
import { Link } from "@/lib/router";
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
import type { Company } from "@paperclipai/shared";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { authApi } from "@/api/auth";
import { Button } from "@/components/ui/button";
import {
@@ -13,21 +14,39 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
interface SidebarCompanyMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function WorkspaceIcon({ company }: { company: Company }) {
return (
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
brandColor={company.brandColor}
className="size-5 shrink-0 rounded-md text-[11px]"
/>
);
}
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
const [internalOpen, setInternalOpen] = useState(false);
const queryClient = useQueryClient();
const { selectedCompany } = useCompany();
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
const navigate = useNavigate();
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const sidebarCompanies = companies.filter((company) => company.status !== "archived");
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -48,33 +67,76 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
if (isMobile) setSidebarOpen(false);
}
function selectCompany(company: Company) {
const pathPrefix = location.pathname.split("/")[1]?.toUpperCase();
const isCompanyRoute = sidebarCompanies.some((sidebarCompany) => (
sidebarCompany.issuePrefix.toUpperCase() === pathPrefix
));
const shouldLeaveCurrentRoute = company.id !== selectedCompany?.id
&& (location.pathname.startsWith("/instance/") || isCompanyRoute);
setSelectedCompanyId(company.id);
setOpen(false);
if (isMobile) setSidebarOpen(false);
if (shouldLeaveCurrentRoute) {
navigate(`/${company.issuePrefix}/dashboard`);
}
}
function addCompany() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
openOnboarding();
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-auto flex-1 justify-start gap-1 px-2 py-1.5 text-left"
aria-label={selectedCompany ? `Open ${selectedCompany.name} menu` : "Open company menu"}
disabled={!selectedCompany}
className="h-9 flex-1 justify-start gap-2 px-2 text-left"
aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{selectedCompany?.brandColor ? (
<span
className="size-4 shrink-0 rounded-sm"
style={{ backgroundColor: selectedCompany.brandColor }}
/>
) : null}
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
<span className="truncate text-sm font-bold text-foreground">
{selectedCompany?.name ?? "Select company"}
{selectedCompany?.name ?? "Select workspace"}
</span>
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="truncate">
{selectedCompany?.name ?? "Company"}
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
<DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-semibold uppercase text-muted-foreground">
Switch workspace
</DropdownMenuLabel>
<div className="max-h-72 overflow-y-auto">
{sidebarCompanies.map((company) => {
const isSelected = company.id === selectedCompany?.id;
return (
<DropdownMenuItem
key={company.id}
onClick={() => selectCompany(company)}
className={cn(
"min-w-0 gap-2 py-2",
isSelected && "bg-accent text-accent-foreground",
)}
>
<WorkspaceIcon company={company} />
<span className="min-w-0 flex-1 truncate">{company.name}</span>
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
</DropdownMenuItem>
);
})}
{sidebarCompanies.length === 0 ? (
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
) : null}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={addCompany} className="gap-2 py-2 text-muted-foreground">
<Plus className="size-4" />
<span>Add company...</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/company/settings/invites" onClick={closeNavigationChrome}>