forked from farhoodlabs/paperclip
[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:
@@ -53,6 +53,10 @@ vi.mock("./SidebarNavItem", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Workspace switcher</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user