[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 180 additions and 27 deletions

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