diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d7e9889f..2c873329 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -978,6 +978,7 @@ export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [ "members", "invites", "secrets", + "instance", ] as const; export type PluginReservedCompanySettingsRouteSegment = (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 210c8845..dc06d4e9 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -175,10 +175,10 @@ describe("plugin UI slot validators", () => { it("prevents company settings page slots from shadowing core settings routes", () => { const parsed = pluginUiSlotDeclarationSchema.safeParse({ type: "companySettingsPage", - id: "access-settings", - displayName: "Access", - exportName: "AccessSettingsPage", - routePath: "access", + id: "instance-settings", + displayName: "Instance", + exportName: "InstanceSettingsPage", + routePath: "instance", }); expect(parsed.success).toBe(false); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index ed7ac016..b8d5a33b 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -979,7 +979,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(runs).toHaveLength(2); const failedRun = runs.find((row) => row.id === runId); - const retryRun = runs.find((row) => row.id !== runId); + const retryRuns = runs.filter((row) => row.retryOfRunId === runId); + expect(retryRuns).toHaveLength(1); + const retryRun = retryRuns[0]; expect(failedRun?.status).toBe("failed"); expect(failedRun?.errorCode).toBe("process_lost"); expect(failedRun?.livenessState).toBe("failed"); @@ -994,11 +996,16 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(retryRun?.processLossRetryCount).toBe(1); expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await waitForValue(async () => + db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => { + const row = rows[0] ?? null; + return row?.executionRunId === retryRun?.id ? row : null; + }) + ); expect(issue?.executionRunId).toBe(retryRun?.id ?? null); expect(issue?.checkoutRunId).toBe(runId); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6e51901c..12f3e152 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -63,6 +63,7 @@ import { useCompany } from "./context/CompanyContext"; import { useDialogActions } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; +import { normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings"; function boardRoutes() { return ( @@ -82,6 +83,15 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -158,7 +168,43 @@ function InboxRootRedirect() { function LegacySettingsRedirect() { const location = useLocation(); - return ; + const { companies, selectedCompany, loading } = useCompany(); + const { companyPrefix } = useParams<{ companyPrefix?: string }>(); + + if (loading) { + return
Loading...
; + } + + const targetCompany = + (companyPrefix + ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) + : null) ?? + selectedCompany ?? + companies[0] ?? + null; + + if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } + return ; + } + + const normalizedPath = normalizeRememberedInstanceSettingsPath( + `${location.pathname}${location.search}${location.hash}`, + ); + + return ( + + ); } function OnboardingRoutePage() { @@ -292,18 +338,9 @@ export function App() { }> } /> } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 124725bb..d69dd9ae 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -12,6 +12,9 @@ const mockSidebarBadgesApi = vi.hoisted(() => ({ const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockPluginsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ @@ -28,6 +31,13 @@ vi.mock("@/lib/router", () => ({ {children} ), + NavLink: ({ + children, + to, + }: { + children: React.ReactNode; + to: string; + }) => {children}, })); vi.mock("@/context/CompanyContext", () => ({ @@ -68,6 +78,10 @@ vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); +vi.mock("@/api/plugins", () => ({ + pluginsApi: mockPluginsApi, +})); + vi.mock("@/plugins/slots", () => ({ usePluginSlots: mockUsePluginSlots, })); @@ -103,6 +117,7 @@ describe("CompanySettingsSidebar", () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: false, }); + mockPluginsApi.list.mockResolvedValue([]); mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false, @@ -136,6 +151,8 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Paperclip"); expect(container.textContent).toContain("Company Settings"); + expect(container.textContent).toContain("Company settings"); + expect(container.textContent).toContain("Instance settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); expect(container.textContent).not.toContain("Cloud upstream"); @@ -179,6 +196,32 @@ describe("CompanySettingsSidebar", () => { end: true, }), ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/instance/profile", + label: "Profile", + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/instance/general", + label: "General", + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/instance/plugins", + label: "Plugins", + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/instance/adapters", + label: "Adapters", + }), + ); await act(async () => { root.unmount(); @@ -294,4 +337,64 @@ describe("CompanySettingsSidebar", () => { root.unmount(); }); }); + + it("renders instance plugin links while filtering sandbox-provider-only plugins", async () => { + mockPluginsApi.list.mockResolvedValue([ + { + id: "linear", + packageName: "@example/linear", + manifestJson: { + displayName: "Linear", + environmentDrivers: [], + }, + }, + { + id: "sandbox-only", + packageName: "@example/sandbox", + manifestJson: { + displayName: "Sandbox only", + environmentDrivers: [{ kind: "sandbox_provider", driverKey: "e2b" }], + }, + }, + { + id: "hybrid", + packageName: "@example/hybrid", + manifestJson: { + displayName: "Hybrid", + environmentDrivers: [ + { kind: "sandbox_provider", driverKey: "e2b" }, + { kind: "environment_driver", driverKey: "ssh" }, + ], + }, + }, + ]); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const pluginLinks = Array.from( + container.querySelectorAll('a[href^="/company/settings/instance/plugins/"]'), + ); + expect(pluginLinks.map((link) => link.getAttribute("href"))).toEqual([ + "/company/settings/instance/plugins/linear", + "/company/settings/instance/plugins/hybrid", + ]); + expect(container.textContent).toContain("Linear"); + expect(container.textContent).toContain("Hybrid"); + expect(container.textContent).not.toContain("Sandbox only"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index ad05f4b1..aab43ec8 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,15 +1,46 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react"; +import { + ChevronLeft, + Clock3, + CloudUpload, + Cpu, + FlaskConical, + KeyRound, + MailPlus, + MonitorCog, + Puzzle, + Settings, + Shield, + SlidersHorizontal, + UserRoundPen, + Users, +} from "lucide-react"; +import type { PluginRecord } from "@paperclipai/shared"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { instanceSettingsApi } from "@/api/instanceSettings"; +import { pluginsApi } from "@/api/plugins"; import { ApiError } from "@/api/client"; -import { Link } from "@/lib/router"; +import { Link, NavLink } from "@/lib/router"; +import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; +import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; +/** + * Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin + * settings page, so a sidebar entry would lead nowhere useful. Filter them out + * here. Plugins that mix a sandbox provider with other contributions still + * appear. + */ +function isSandboxProviderOnly(plugin: PluginRecord): boolean { + const drivers = plugin.manifestJson.environmentDrivers ?? []; + if (drivers.length === 0) return false; + return drivers.every((d) => d.kind === "sandbox_provider"); +} + export function CompanySettingsSidebar() { const { selectedCompany, selectedCompanyId } = useCompany(); const { isMobile, setSidebarOpen } = useSidebar(); @@ -40,7 +71,12 @@ export function CompanySettingsSidebar() { queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); + const { data: plugins } = useQuery({ + queryKey: queryKeys.plugins.all, + queryFn: () => pluginsApi.list(), + }); const showCloudUpstream = experimentalSettings?.enableCloudSync === true; + const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin)); return ( ); diff --git a/ui/src/components/InstanceSidebar.test.tsx b/ui/src/components/InstanceSidebar.test.tsx index e49d940b..a556e712 100644 --- a/ui/src/components/InstanceSidebar.test.tsx +++ b/ui/src/components/InstanceSidebar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -47,6 +47,12 @@ vi.mock("../context/SidebarContext", () => ({ import { InstanceSidebar } from "./InstanceSidebar"; +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + function makePlugin(overrides: Partial & { manifestJson: PluginRecord["manifestJson"] }): PluginRecord { return { id: overrides.id ?? "plugin-id", @@ -75,10 +81,10 @@ async function flushReact() { async function findPluginLinks(container: HTMLElement, expectedCount: number) { await act(async () => { await vi.waitFor(() => { - expect(container.querySelectorAll('a[href^="/instance/settings/plugins/"]')).toHaveLength(expectedCount); + expect(container.querySelectorAll('a[href^="/company/settings/instance/plugins/"]')).toHaveLength(expectedCount); }); }); - return Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]')); + return Array.from(container.querySelectorAll('a[href^="/company/settings/instance/plugins/"]')); } function renderSidebar(container: HTMLElement) { @@ -161,7 +167,7 @@ describe("InstanceSidebar", () => { await flushReact(); const pluginLinks = await findPluginLinks(container, 1); - expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/linear"); + expect(pluginLinks[0]?.getAttribute("href")).toBe("/company/settings/instance/plugins/linear"); expect(pluginLinks[0]?.textContent).toBe("Linear"); }); @@ -199,7 +205,7 @@ describe("InstanceSidebar", () => { await flushReact(); const pluginLinks = await findPluginLinks(container, 1); - expect(pluginLinks[0]?.getAttribute("href")).toBe("/instance/settings/plugins/hybrid"); + expect(pluginLinks[0]?.getAttribute("href")).toBe("/company/settings/instance/plugins/hybrid"); }); it("renders the indented plugin list between the Plugins and Adapters rows", async () => { @@ -225,17 +231,17 @@ describe("InstanceSidebar", () => { await vi.waitFor(() => { const links = Array.from( - container.querySelectorAll('a[href^="/instance/settings/"]'), + container.querySelectorAll('a[href^="/company/settings/instance/"]'), ); - expect(links.some((a) => a.getAttribute("href") === "/instance/settings/plugins/linear")).toBe(true); + expect(links.some((a) => a.getAttribute("href") === "/company/settings/instance/plugins/linear")).toBe(true); }); - const topLevelLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/"]')); + const topLevelLinks = Array.from(container.querySelectorAll('a[href^="/company/settings/instance/"]')); const hrefs = topLevelLinks.map((a) => a.getAttribute("href")); - const pluginsIndex = hrefs.indexOf("/instance/settings/plugins"); - const adaptersIndex = hrefs.indexOf("/instance/settings/adapters"); - const linearIndex = hrefs.indexOf("/instance/settings/plugins/linear"); + const pluginsIndex = hrefs.indexOf("/company/settings/instance/plugins"); + const adaptersIndex = hrefs.indexOf("/company/settings/instance/adapters"); + const linearIndex = hrefs.indexOf("/company/settings/instance/plugins/linear"); expect(pluginsIndex).toBeGreaterThanOrEqual(0); expect(adaptersIndex).toBeGreaterThan(pluginsIndex); @@ -274,7 +280,7 @@ describe("InstanceSidebar", () => { await vi.waitFor(() => { expect(mockPluginsApi.list).toHaveBeenCalled(); }); - const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]')); + const pluginLinks = Array.from(container.querySelectorAll('a[href^="/company/settings/instance/plugins/"]')); expect(pluginLinks).toHaveLength(0); }); }); diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index 5235affd..285fdcb5 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, import type { PluginRecord } from "@paperclipai/shared"; import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; +import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; import { queryKeys } from "@/lib/queryKeys"; import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { SidebarNavItem } from "./SidebarNavItem"; @@ -38,18 +39,18 @@ export function InstanceSidebar() { diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 85a44ad6..873ceba1 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -41,8 +41,8 @@ vi.mock("@/lib/router", () => ({ useParams: () => { const [firstSegment, secondSegment] = currentPathname.split("/").filter(Boolean); return { - companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP", - pluginRoutePath: firstSegment === "instance" ? undefined : secondSegment, + companyPrefix: firstSegment ?? "PAP", + pluginRoutePath: secondSegment, }; }, })); @@ -51,10 +51,6 @@ vi.mock("./Sidebar", () => ({ Sidebar: () =>
Main company nav
, })); -vi.mock("./InstanceSidebar", () => ({ - InstanceSidebar: () =>
Instance sidebar
, -})); - vi.mock("./CompanySettingsSidebar", () => ({ CompanySettingsSidebar: () =>
Company settings sidebar
, })); @@ -195,12 +191,6 @@ vi.mock("../lib/company-selection", () => ({ shouldSyncCompanySelectionFromRoute: () => false, })); -vi.mock("../lib/instance-settings", () => ({ - DEFAULT_INSTANCE_SETTINGS_PATH: "/instance/settings/general", - normalizeRememberedInstanceSettingsPath: (value: string | null | undefined) => - value ?? "/instance/settings/general", -})); - vi.mock("../lib/main-content-focus", () => ({ scheduleMainContentFocus: () => () => undefined, })); @@ -364,14 +354,16 @@ describe("Layout", () => { expect(selector?.textContent).toContain("Members"); expect(selector?.textContent).toContain("Invites"); expect(selector?.textContent).toContain("Secrets"); + expect(selector?.textContent).toContain("Instance general"); + expect(selector?.textContent).toContain("Instance plugins"); await act(async () => { root.unmount(); }); }); - it("renders the instance settings sidebar on instance settings routes", async () => { - currentPathname = "/instance/settings/general"; + it("renders the company settings sidebar on instance settings routes", async () => { + currentPathname = "/PAP/company/settings/instance/general"; const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -387,9 +379,8 @@ describe("Layout", () => { await flushReact(); await flushReact(); - expect(container.textContent).toContain("Instance sidebar"); + expect(container.textContent).toContain("Company settings sidebar"); expect(container.textContent).not.toContain("Company rail"); - expect(container.textContent).not.toContain("Company settings sidebar"); expect(container.textContent).not.toContain("Main company nav"); expect(container.textContent).not.toContain("Plugin route sidebar"); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 02bec3de..a93a6869 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -2,7 +2,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 { Sidebar } from "./Sidebar"; -import { InstanceSidebar } from "./InstanceSidebar"; import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; import { CompanySettingsNav } from "./access/CompanySettingsNav"; import { BreadcrumbBar } from "./BreadcrumbBar"; @@ -30,10 +29,6 @@ import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; -import { - DEFAULT_INSTANCE_SETTINGS_PATH, - normalizeRememberedInstanceSettingsPath, -} from "../lib/instance-settings"; import { resetNavigationScroll, shouldResetScrollOnNavigation, @@ -44,8 +39,6 @@ import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots"; -const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath"; - function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null { if (!companyPrefix) return null; const segments = pathname.split("/").filter(Boolean); @@ -54,15 +47,6 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi return segments[1]?.toLowerCase() ?? null; } -function readRememberedInstanceSettingsPath(): string { - if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH; - try { - return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY)); - } catch { - return DEFAULT_INSTANCE_SETTINGS_PATH; - } -} - export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialogActions(); @@ -82,14 +66,12 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); - const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const isCompanySettingsRoute = location.pathname.includes("/company/settings"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const previousPathname = useRef(null); const mainContentRef = useRef(null); const [mobileNavVisible, setMobileNavVisible] = useState(true); - const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const [shortcutsOpen, setShortcutsOpen] = useState(false); const matchedCompany = useMemo(() => { if (!companyPrefix) return null; @@ -313,21 +295,6 @@ export function Layout() { }; }, [isMobile]); - useEffect(() => { - if (!location.pathname.startsWith("/instance/settings/")) return; - - const nextPath = normalizeRememberedInstanceSettingsPath( - `${location.pathname}${location.search}${location.hash}`, - ); - setInstanceSettingsTarget(nextPath); - - try { - window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath); - } catch { - // Ignore storage failures in restricted environments. - } - }, [location.hash, location.pathname, location.search]); - useEffect(() => { if (typeof document === "undefined") return; const mainContent = mainContentRef.current; @@ -383,9 +350,7 @@ export function Layout() { >
- {isInstanceSettingsRoute ? ( - - ) : isCompanySettingsRoute ? ( + {isCompanySettingsRoute ? ( ) : ( companySidebar @@ -394,7 +359,6 @@ export function Layout() {
@@ -402,9 +366,7 @@ export function Layout() {
- {isInstanceSettingsRoute ? ( - - ) : isCompanySettingsRoute ? ( + {isCompanySettingsRoute ? ( ) : ( companySidebar @@ -413,7 +375,6 @@ export function Layout() {
diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index 395b32db..be119d5b 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -44,6 +43,12 @@ vi.mock("../context/ThemeContext", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + async function flushReact() { await act(async () => { await Promise.resolve(); @@ -85,7 +90,6 @@ describe("SidebarAccountMenu", () => { , @@ -106,11 +110,13 @@ describe("SidebarAccountMenu", () => { await flushReact(); expect(document.body.textContent).toContain("Edit profile"); + expect(document.body.textContent).not.toContain("Instance settings"); expect(document.body.textContent).toContain("Documentation"); expect(document.body.textContent).toContain("Paperclip v1.2.3"); expect(document.body.textContent).toContain("jane@example.com"); expect(document.body.querySelector('[data-slot="popover-content"]')?.className) .toContain("w-[277px]"); + expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull(); await act(async () => { root.unmount(); diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index bf8a20ae..f6f3703d 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -5,7 +5,6 @@ import { LogOut, type LucideIcon, Moon, - Settings, UserRound, Sun, UserRoundPen, @@ -20,12 +19,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn } from "../lib/utils"; -const PROFILE_SETTINGS_PATH = "/instance/settings/profile"; +const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; const DOCS_URL = "https://docs.paperclip.ing/"; interface SidebarAccountMenuProps { deploymentMode?: DeploymentMode; - instanceSettingsTarget: string; open?: boolean; onOpenChange?: (open: boolean) => void; version?: string | null; @@ -103,7 +101,6 @@ function MenuAction({ label, description, icon: Icon, onClick, href, external = export function SidebarAccountMenu({ deploymentMode, - instanceSettingsTarget, open: controlledOpen, onOpenChange, version, @@ -200,13 +197,6 @@ export function SidebarAccountMenu({ href={PROFILE_SETTINGS_PATH} onClick={closeNavigationChrome} /> - { expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members"); expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites"); expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets"); + expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile"); + expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("instance-general"); + expect(getCompanySettingsTab("/company/settings/instance/access")).toBe("instance-access"); + expect(getCompanySettingsTab("/company/settings/instance/heartbeats")).toBe("instance-heartbeats"); + expect(getCompanySettingsTab("/company/settings/instance/experimental")).toBe("instance-experimental"); + expect(getCompanySettingsTab("/PAP/company/settings/instance/plugins/example")).toBe("instance-plugins"); + expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters"); }); it("renders the active tab and navigates when a different tab is selected", async () => { @@ -96,6 +103,13 @@ describe("CompanySettingsNav", () => { { value: "members", label: "Members" }, { value: "invites", label: "Invites" }, { value: "secrets", label: "Secrets" }, + { value: "instance-profile", label: "Instance profile" }, + { value: "instance-general", label: "Instance general" }, + { value: "instance-access", label: "Instance access" }, + { value: "instance-heartbeats", label: "Instance heartbeats" }, + { value: "instance-experimental", label: "Instance experimental" }, + { value: "instance-plugins", label: "Instance plugins" }, + { value: "instance-adapters", label: "Instance adapters" }, ], }), ); diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index f97e2559..118c0aa0 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -1,5 +1,6 @@ import { PageTabBar } from "@/components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; +import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; import { useLocation, useNavigate } from "@/lib/router"; const items = [ @@ -9,11 +10,46 @@ const items = [ { value: "members", label: "Members", href: "/company/settings/members" }, { value: "invites", label: "Invites", href: "/company/settings/invites" }, { value: "secrets", label: "Secrets", href: "/company/settings/secrets" }, + { value: "instance-profile", label: "Instance profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` }, + { value: "instance-general", label: "Instance general", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/general` }, + { value: "instance-access", label: "Instance access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` }, + { value: "instance-heartbeats", label: "Instance heartbeats", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats` }, + { value: "instance-experimental", label: "Instance experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` }, + { value: "instance-plugins", label: "Instance plugins", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/plugins` }, + { value: "instance-adapters", label: "Instance adapters", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/adapters` }, ] as const; type CompanySettingsTab = (typeof items)[number]["value"]; export function getCompanySettingsTab(pathname: string): CompanySettingsTab { + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`)) { + return "instance-profile"; + } + + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/access`)) { + return "instance-access"; + } + + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`)) { + return "instance-heartbeats"; + } + + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`)) { + return "instance-experimental"; + } + + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/plugins`)) { + return "instance-plugins"; + } + + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/adapters`)) { + return "instance-adapters"; + } + + if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/general`)) { + return "instance-general"; + } + if (pathname.includes("/company/settings/environments")) { return "environments"; } diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 27119d6a..bc6d28b4 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -21,6 +21,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "u", "design-guide", "search", + "settings", ]); const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]); diff --git a/ui/src/lib/instance-settings.test.ts b/ui/src/lib/instance-settings.test.ts index f81e4a1e..76ee66c0 100644 --- a/ui/src/lib/instance-settings.test.ts +++ b/ui/src/lib/instance-settings.test.ts @@ -5,15 +5,30 @@ import { } from "./instance-settings"; describe("normalizeRememberedInstanceSettingsPath", () => { - it("keeps known instance settings pages", () => { + it("canonicalizes known instance settings pages under company settings", () => { expect(normalizeRememberedInstanceSettingsPath("/instance/settings/general")).toBe( - "/instance/settings/general", + "/company/settings/instance/general", ); expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe( - "/instance/settings/experimental", + "/company/settings/instance/experimental", ); expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe( - "/instance/settings/plugins/example?tab=config#logs", + "/company/settings/instance/plugins/example?tab=config#logs", + ); + expect(normalizeRememberedInstanceSettingsPath("/PAP/company/settings/instance/adapters")).toBe( + "/company/settings/instance/adapters", + ); + expect(normalizeRememberedInstanceSettingsPath("/company/settings/instance/general")).toBe( + "/company/settings/instance/general", + ); + expect(normalizeRememberedInstanceSettingsPath("/company/settings/instance/plugins/example?tab=config#logs")).toBe( + "/company/settings/instance/plugins/example?tab=config#logs", + ); + expect(normalizeRememberedInstanceSettingsPath("/settings/access?tab=users#admins")).toBe( + "/company/settings/instance/access?tab=users#admins", + ); + expect(normalizeRememberedInstanceSettingsPath("/PAP/settings/plugins/example")).toBe( + "/company/settings/instance/plugins/example", ); }); diff --git a/ui/src/lib/instance-settings.ts b/ui/src/lib/instance-settings.ts index c231791c..ba2ccc10 100644 --- a/ui/src/lib/instance-settings.ts +++ b/ui/src/lib/instance-settings.ts @@ -1,24 +1,77 @@ -export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general"; +export const INSTANCE_SETTINGS_PATH_PREFIX = "/company/settings/instance"; +export const DEFAULT_INSTANCE_SETTINGS_PATH = `${INSTANCE_SETTINGS_PATH_PREFIX}/general`; + +const LEGACY_INSTANCE_SETTINGS_PATH_PREFIX = "/instance/settings"; +const LEGACY_SETTINGS_PATH_PREFIX = "/settings"; + +function splitPath(rawPath: string): { pathname: string; search: string; hash: string } { + const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); + return { + pathname: match?.[1] ?? rawPath, + search: match?.[2] ?? "", + hash: match?.[3] ?? "", + }; +} + +function normalizePathForMatching(rawPath: string): { pathname: string; search: string; hash: string } { + const { pathname, search, hash } = splitPath(rawPath); + const segments = pathname.split("/").filter(Boolean); + const first = segments[0]?.toLowerCase(); + const second = segments[1]?.toLowerCase(); + + if (first === "company") { + return { pathname: `/${segments.join("/")}`, search, hash }; + } + + if (second === "company" || second === "settings" || second === "instance") { + return { pathname: `/${segments.slice(1).join("/")}`, search, hash }; + } + + return { pathname, search, hash }; +} + +function instanceSettingsSuffix(pathname: string): string | null { + if (pathname === INSTANCE_SETTINGS_PATH_PREFIX) return "/general"; + if (pathname.startsWith(`${INSTANCE_SETTINGS_PATH_PREFIX}/`)) { + return pathname.slice(INSTANCE_SETTINGS_PATH_PREFIX.length); + } + + if (pathname === LEGACY_INSTANCE_SETTINGS_PATH_PREFIX || pathname === "/instance") { + return "/general"; + } + if (pathname.startsWith(`${LEGACY_INSTANCE_SETTINGS_PATH_PREFIX}/`)) { + return pathname.slice(LEGACY_INSTANCE_SETTINGS_PATH_PREFIX.length); + } + + if (pathname === LEGACY_SETTINGS_PATH_PREFIX) return "/general"; + if (pathname.startsWith(`${LEGACY_SETTINGS_PATH_PREFIX}/`)) { + return pathname.slice(LEGACY_SETTINGS_PATH_PREFIX.length); + } + + return null; +} export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string { if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH; - const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/); - const pathname = match?.[1] ?? rawPath; - const search = match?.[2] ?? ""; - const hash = match?.[3] ?? ""; + const { pathname, search, hash } = normalizePathForMatching(rawPath); + const suffix = instanceSettingsSuffix(pathname); + if (!suffix) return DEFAULT_INSTANCE_SETTINGS_PATH; if ( - pathname === "/instance/settings/general" || - pathname === "/instance/settings/heartbeats" || - pathname === "/instance/settings/plugins" || - pathname === "/instance/settings/experimental" + suffix === "/profile" || + suffix === "/general" || + suffix === "/access" || + suffix === "/heartbeats" || + suffix === "/plugins" || + suffix === "/experimental" || + suffix === "/adapters" ) { - return `${pathname}${search}${hash}`; + return `${INSTANCE_SETTINGS_PATH_PREFIX}${suffix}${search}${hash}`; } - if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) { - return `${pathname}${search}${hash}`; + if (/^\/plugins\/[^/?#]+$/.test(suffix)) { + return `${INSTANCE_SETTINGS_PATH_PREFIX}${suffix}${search}${hash}`; } return DEFAULT_INSTANCE_SETTINGS_PATH; diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 94532a19..c55b66f1 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -267,7 +267,8 @@ export function AdapterManager() { useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, - { label: "Settings", href: "/instance/settings/general" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings", href: "/company/settings/instance/general" }, { label: "Adapters" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); diff --git a/ui/src/pages/CloudUpstream.tsx b/ui/src/pages/CloudUpstream.tsx index 1b23a0e5..0b23d354 100644 --- a/ui/src/pages/CloudUpstream.tsx +++ b/ui/src/pages/CloudUpstream.tsx @@ -220,7 +220,10 @@ export function CloudUpstream() {
Cloud sync is disabled. Enable it in{" "} - + Instance Settings {" "} to show upstream connection and push tools. diff --git a/ui/src/pages/InstanceAccess.tsx b/ui/src/pages/InstanceAccess.tsx index 7ca2f673..a568c366 100644 --- a/ui/src/pages/InstanceAccess.tsx +++ b/ui/src/pages/InstanceAccess.tsx @@ -21,7 +21,8 @@ export function InstanceAccess() { useEffect(() => { setBreadcrumbs([ - { label: "Instance Settings", href: "/instance/settings/general" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings", href: "/company/settings/instance/general" }, { label: "Access" }, ]); }, [setBreadcrumbs]); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 7c196ab8..bd888cab 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -128,7 +128,8 @@ export function InstanceExperimentalSettings() { useEffect(() => { setBreadcrumbs([ - { label: "Instance Settings" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings", href: "/company/settings/instance/general" }, { label: "Experimental" }, ]); }, [setBreadcrumbs]); diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 69d61b50..0bcb2c93 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -37,7 +37,8 @@ export function InstanceGeneralSettings() { useEffect(() => { setBreadcrumbs([ - { label: "Instance Settings" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings" }, { label: "General" }, ]); }, [setBreadcrumbs]); diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx index 18faff5e..5ca4e0ba 100644 --- a/ui/src/pages/InstanceSettings.tsx +++ b/ui/src/pages/InstanceSettings.tsx @@ -33,7 +33,8 @@ export function InstanceSettings() { useEffect(() => { setBreadcrumbs([ - { label: "Instance Settings" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings", href: "/company/settings/instance/general" }, { label: "Heartbeats" }, ]); }, [setBreadcrumbs]); diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index 4293d767..8f919e14 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -100,7 +100,8 @@ export function PluginManager() { useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, - { label: "Settings", href: "/instance/settings/heartbeats" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings", href: "/company/settings/instance/general" }, { label: "Plugins" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); @@ -307,7 +308,7 @@ export function PluginManager() { )} @@ -359,7 +360,7 @@ export function PluginManager() {
@@ -463,7 +464,7 @@ export function PluginManager() {
diff --git a/ui/src/pages/ProfileSettings.tsx b/ui/src/pages/ProfileSettings.tsx index a60c0b7a..674ff780 100644 --- a/ui/src/pages/ProfileSettings.tsx +++ b/ui/src/pages/ProfileSettings.tsx @@ -35,7 +35,8 @@ export function ProfileSettings() { useEffect(() => { setBreadcrumbs([ - { label: "Instance Settings" }, + { label: "Settings", href: "/company/settings" }, + { label: "Instance settings", href: "/company/settings/instance/general" }, { label: "Profile" }, ]); }, [setBreadcrumbs]); diff --git a/ui/src/plugins/bridge.test.ts b/ui/src/plugins/bridge.test.ts index adc48fb5..368a283e 100644 --- a/ui/src/plugins/bridge.test.ts +++ b/ui/src/plugins/bridge.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import * as React from "react"; import * as ReactDOM from "react-dom"; -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type { MouseEvent as ReactMouseEvent } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -46,6 +46,10 @@ afterEach(() => { delete globalThis.__paperclipPluginBridge__; }); +function act(callback: () => void) { + flushSync(callback); +} + describe("plugin host navigation", () => { it("resolves plugin page routes into the active company prefix", () => { expect(resolveHostNavigationHref("/wiki", "PAP")).toBe("/PAP/wiki"); @@ -57,8 +61,14 @@ describe("plugin host navigation", () => { it("does not double-prefix active company paths or global host paths", () => { expect(resolveHostNavigationHref("/PAP/wiki", "PAP")).toBe("/PAP/wiki"); expect(resolveHostNavigationHref("/pap/wiki", "PAP")).toBe("/pap/wiki"); + }); + + it("rewrites legacy instance settings paths into the active company settings scope", () => { expect(resolveHostNavigationHref("/instance/settings/plugins", "PAP")).toBe( - "/instance/settings/plugins", + "/PAP/company/settings/instance/plugins", + ); + expect(resolveHostNavigationHref("/settings/experimental?x=1#auto", "pap")).toBe( + "/PAP/company/settings/instance/experimental?x=1#auto", ); }); diff --git a/ui/src/plugins/bridge.ts b/ui/src/plugins/bridge.ts index e8a9d13a..fcdf35af 100644 --- a/ui/src/plugins/bridge.ts +++ b/ui/src/plugins/bridge.ts @@ -38,6 +38,7 @@ import { ApiError } from "@/api/client"; import { useToastActions, type ToastInput } from "@/context/ToastContext"; import { useSidebar } from "@/context/SidebarContext"; import { isGlobalPath, normalizeCompanyPrefix } from "@/lib/company-routes"; +import { normalizeRememberedInstanceSettingsPath } from "@/lib/instance-settings"; // --------------------------------------------------------------------------- // Bridge error type (mirrors the SDK's PluginBridgeError) @@ -281,6 +282,16 @@ function hasCompanyPrefix(pathname: string, companyPrefix: string): boolean { return firstSegment?.toUpperCase() === normalizeCompanyPrefix(companyPrefix); } +function isLegacyInstanceSettingsPath(pathname: string): boolean { + return ( + pathname === "/instance" || + pathname === "/instance/settings" || + pathname.startsWith("/instance/settings/") || + pathname === "/settings" || + pathname.startsWith("/settings/") + ); +} + /** * Resolve a plugin-provided Paperclip path to the active company scope. * @@ -295,6 +306,12 @@ export function resolveHostNavigationHref( if (sameOriginPath === null) return to; const { pathname, search, hash } = splitPath(sameOriginPath); + if (isLegacyInstanceSettingsPath(pathname)) { + const canonicalPath = normalizeRememberedInstanceSettingsPath(`${pathname}${search}${hash}`); + if (!companyPrefix) return canonicalPath; + return `/${normalizeCompanyPrefix(companyPrefix)}${canonicalPath}`; + } + if (!pathname.startsWith("/") || isGlobalPath(pathname) || !companyPrefix) { return sameOriginPath; } diff --git a/ui/storybook/stories/navigation-layout.stories.tsx b/ui/storybook/stories/navigation-layout.stories.tsx index 2e87c862..71989c0b 100644 --- a/ui/storybook/stories/navigation-layout.stories.tsx +++ b/ui/storybook/stories/navigation-layout.stories.tsx @@ -253,7 +253,6 @@ function NavigationLayoutStories() {
undefined} version="0.3.1"