mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
[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:
parent
685ee84e4a
commit
76f09c8eb6
6 changed files with 180 additions and 27 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue