diff --git a/doc/screenshots/garden-mcp-split-stack/apps-list.png b/doc/screenshots/garden-mcp-split-stack/apps-list.png new file mode 100644 index 00000000000..f5b7eed4054 Binary files /dev/null and b/doc/screenshots/garden-mcp-split-stack/apps-list.png differ diff --git a/doc/screenshots/garden-mcp-split-stack/byo-connect.png b/doc/screenshots/garden-mcp-split-stack/byo-connect.png new file mode 100644 index 00000000000..7f82a03ff55 Binary files /dev/null and b/doc/screenshots/garden-mcp-split-stack/byo-connect.png differ diff --git a/doc/screenshots/garden-mcp-split-stack/gateway-detail.png b/doc/screenshots/garden-mcp-split-stack/gateway-detail.png new file mode 100644 index 00000000000..c0c71d91c02 Binary files /dev/null and b/doc/screenshots/garden-mcp-split-stack/gateway-detail.png differ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 83fe93f2882..c6126e6e7b0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -5,6 +5,7 @@ import { Layout } from "./components/Layout"; import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; +import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; import { Cases } from "./pages/Cases"; import { CaseDetail } from "./pages/CaseDetail"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; @@ -47,6 +48,17 @@ import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab"; import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab"; import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; +import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute"; +import { ProfileWizardRoute } from "./pages/tools/profiles/ProfileWizardRoute"; +import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute"; +import { Connections } from "./pages/apps/Connections"; +import { Browse } from "./pages/apps/Browse"; +import { AppsConnect } from "./pages/apps/AppsConnect"; +import { AppsReview } from "./pages/apps/AppsReview"; +import { AppDetail } from "./pages/apps/AppDetail"; +import { AppNotConnected } from "./pages/apps/AppNotConnected"; +import { GatewaysList } from "./pages/apps/gateways/GatewaysList"; +import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { SkillStudio } from "./pages/SkillStudio"; @@ -94,11 +106,36 @@ function boardRoutes() { } /> } /> } /> - } /> } /> } /> } /> } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> @@ -235,6 +272,12 @@ function boardRoutes() { ); } +function AppsConnectEntryRoute() { + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + return searchParams.get("byo") === "1" ? : ; +} + function InboxRootRedirect() { return ; } @@ -307,6 +350,24 @@ function LegacySettingsRedirect() { ); } +function LegacyToolsSettingsRedirect() { + const { tab } = useParams<{ tab?: string }>(); + return ; +} + +// The developer "Tools" surface moved under the Apps "Advanced setup" door +// (PAP-10862). `/tools` and `/tools/:tab` redirect to their new home. +function LegacyToolsRedirect() { + const { tab } = useParams<{ tab?: string }>(); + return ; +} + +function legacyToolsRedirectTarget(tab?: string) { + if (!tab) return "/apps/advanced/profiles"; + if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps"; + return `/apps/advanced/${tab}`; +} + function OnboardingRoutePage() { const { companies } = useCompany(); const { openOnboarding } = useDialogActions(); diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx new file mode 100644 index 00000000000..a3eefa4e2e0 --- /dev/null +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -0,0 +1,231 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppDetailSidebar } from "./AppConnectionSidebar"; + +const sidebarNavItemMock = vi.hoisted(() => vi.fn()); +const currentPath = vi.hoisted(() => ({ value: "/apps/conn-1/permissions" })); +const mockToolsApi = vi.hoisted(() => ({ + getConnection: vi.fn(), + listApplications: vi.fn(), + listConnections: vi.fn(), + listGallery: vi.fn(), + listAppsAttention: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + onClick, + className, + }: { + children: React.ReactNode; + to: string; + onClick?: () => void; + className?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + }), +})); + +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + }), +})); + +vi.mock("@/api/tools", () => ({ + toolsApi: mockToolsApi, +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: (props: { + to: string; + label: string; + end?: boolean; + badge?: number; + badgeTone?: string; + badgeLabel?: string; + }) => { + sidebarNavItemMock(props); + return ( +
+ {props.label} + {props.badge ? ` ${props.badge}` : ""} +
+ ); + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + for (let i = 0; i < 3; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + } +} + +function connection(overrides: Record = {}) { + return { + id: "conn-1", + name: "GitHub", + transport: "remote_http", + status: "active", + healthStatus: "healthy", + enabled: true, + config: {}, + transportConfig: {}, + ...overrides, + }; +} + +describe("AppConnectionSidebar", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + currentPath.value = "/apps/conn-1/permissions"; + mockToolsApi.getConnection.mockResolvedValue(connection()); + mockToolsApi.listApplications.mockResolvedValue({ + applications: [{ + id: "app-1", + applicationKey: "github", + name: "GitHub", + description: "GitHub app", + status: "active", + }], + }); + mockToolsApi.listConnections.mockResolvedValue({ + connections: [connection({ id: "archived-1", applicationId: "app-1", status: "archived" })], + }); + mockToolsApi.listGallery.mockResolvedValue({ + apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }], + }); + mockToolsApi.listAppsAttention.mockResolvedValue({ + apps: [ + { + connection: connection(), + pendingActionRequestCount: 2, + quarantinedCatalogEntryCount: 3, + healthNeedsAttention: false, + reasons: ["pending_action_requests", "quarantined_catalog_entries"], + }, + ], + totals: {}, + }); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function renderSidebar(element = ) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + root = createRoot(container); + await act(async () => { + root.render( + + {element} + , + ); + }); + await flushReact(); + } + + it("renders a back link and the connected app tabs (including Test)", async () => { + await renderSidebar(); + + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.textContent).toContain("GitHub"); + expect(container.querySelectorAll("[data-to]").length).toBe(6); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 5, badgeTone: "danger" })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/test", label: "Test", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/activity", label: "Activity", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/advanced", label: "Advanced", end: true })); + }); + + it("marks the current tab active through the nav item target", async () => { + await renderSidebar(); + + expect(container.querySelector('[data-to="/apps/conn-1/permissions"]')?.getAttribute("data-active")).toBe("true"); + expect(container.querySelector('[data-to="/apps/conn-1/setup"]')?.getAttribute("data-active")).toBe("false"); + }); + + it("renders application-mode tabs under the not-connected app route", async () => { + currentPath.value = "/apps/app/app-1/review"; + + await renderSidebar(); + + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.textContent).toContain("GitHub"); + expect(mockToolsApi.getConnection).not.toHaveBeenCalled(); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/review", label: "Review", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/permissions", label: "Permissions", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/activity", label: "Activity", end: true })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/advanced", label: "Advanced", end: true })); + expect(container.querySelector('[data-to="/apps/app/app-1/review"]')?.getAttribute("data-active")).toBe("true"); + // The Test tab needs a live connection, so it is hidden in application mode. + expect(container.querySelector('[data-to="/apps/app/app-1/test"]')).toBeNull(); + expect(container.querySelectorAll("[data-to]").length).toBe(5); + }); + + it("keeps rendering a connection sidebar when its connection is unavailable", async () => { + mockToolsApi.getConnection.mockResolvedValue(undefined); + mockToolsApi.listGallery.mockResolvedValue({ apps: [] }); + mockToolsApi.listAppsAttention.mockResolvedValue({ apps: [], totals: {} }); + + await renderSidebar(); + + expect(container.textContent).toContain("App"); + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.querySelectorAll("[data-to]").length).toBe(6); + }); + + it("keeps rendering an application sidebar when its application is unavailable", async () => { + mockToolsApi.listApplications.mockResolvedValue({ applications: [] }); + mockToolsApi.listConnections.mockResolvedValue({ connections: [] }); + mockToolsApi.listGallery.mockResolvedValue({ apps: [] }); + mockToolsApi.listAppsAttention.mockResolvedValue({ apps: [], totals: {} }); + + await renderSidebar(); + + expect(container.textContent).toContain("App"); + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps"); + expect(container.querySelectorAll("[data-to]").length).toBe(5); + }); +}); diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx new file mode 100644 index 00000000000..36c58e6edad --- /dev/null +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -0,0 +1,141 @@ +import { ChevronLeft } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { humanizeConnectionDisplayName } from "@paperclipai/shared"; +import type { AppGalleryEntry, ToolApplication, ToolConnection } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { toolsApi } from "@/api/tools"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + APP_TABS, + CONNECTED_ONLY_APP_TABS, + appApplicationTabHref, + appTabHref, + type AppTabKey, +} from "@/pages/apps/app-tabs"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { SidebarNavItem } from "./SidebarNavItem"; + +type AppDetailSidebarProps = + | { kind: "connection"; connectionId: string } + | { kind: "application"; applicationId: string }; + +export function AppDetailSidebar(props: AppDetailSidebarProps) { + const { selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + const connectionQuery = useQuery({ + queryKey: queryKeys.tools.connection(props.kind === "connection" ? props.connectionId : "__none__"), + queryFn: () => toolsApi.getConnection(props.kind === "connection" ? props.connectionId : ""), + enabled: props.kind === "connection" && !!props.connectionId, + }); + const applicationsQuery = useQuery({ + queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listApplications(selectedCompanyId!), + enabled: props.kind === "application" && !!selectedCompanyId, + }); + const connectionsQuery = useQuery({ + queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listConnections(selectedCompanyId!), + enabled: props.kind === "application" && !!selectedCompanyId, + }); + const galleryQuery = useQuery({ + queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listGallery(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const attentionQuery = useQuery({ + queryKey: queryKeys.apps.attention(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listAppsAttention(selectedCompanyId!), + enabled: !!selectedCompanyId, + refetchInterval: 30_000, + }); + + const connection = connectionQuery.data; + const application = props.kind === "application" + ? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId) + : null; + const appConnections = props.kind === "application" + ? (connectionsQuery.data?.connections ?? []).filter((candidate) => candidate.applicationId === props.applicationId) + : []; + const previousConnection = latestArchivedConnection(appConnections); + const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App"; + const logoEntry = galleryEntryFor(galleryQuery.data?.apps ?? [], connection, application ?? undefined); + const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null; + const attentionItem = reviewConnectionId + ? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId) + : null; + const reviewCount = + (attentionItem?.pendingActionRequestCount ?? 0) + (attentionItem?.quarantinedCatalogEntryCount ?? 0); + + return ( + + ); +} + +function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string { + return props.kind === "connection" + ? appTabHref(props.connectionId, tab) + : appApplicationTabHref(props.applicationId, tab); +} + +function galleryEntryFor( + apps: AppGalleryEntry[], + connection: ToolConnection | undefined, + application: ToolApplication | undefined, +): AppGalleryEntry | null { + if (application?.applicationKey) { + const keyed = apps.find((app) => app.key === application.applicationKey); + if (keyed) return keyed; + } + const name = (connection?.name ?? application?.name)?.toLowerCase(); + if (!name) return null; + return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.key === name) ?? null; +} + +function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null { + const archived = connections.filter((connection) => connection.status === "archived"); + if (archived.length === 0) return null; + return archived.reduce((latest, connection) => { + const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime(); + const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime(); + return connectionTime > latestTime ? connection : latest; + }); +} diff --git a/ui/src/components/AppsExperimentalGate.test.tsx b/ui/src/components/AppsExperimentalGate.test.tsx new file mode 100644 index 00000000000..5a386cac89e --- /dev/null +++ b/ui/src/components/AppsExperimentalGate.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppsExperimentalGate } from "./AppsExperimentalGate"; + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( +
+ ), + Outlet: () =>
Apps content
, +})); + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("AppsExperimentalGate", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate() { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("redirects to the dashboard when apps are disabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); + await renderGate(); + + expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe("/dashboard"); + expect(container.querySelector('[data-testid="apps-content"]')).toBeNull(); + }); + + it("renders apps routes when apps are enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); + await renderGate(); + + expect(container.querySelector('[data-testid="apps-content"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + }); + + it("renders nothing while the flag is loading", async () => { + mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); + await renderGate(); + + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + expect(container.querySelector('[data-testid="apps-content"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/AppsExperimentalGate.tsx b/ui/src/components/AppsExperimentalGate.tsx new file mode 100644 index 00000000000..3bbe1eda832 --- /dev/null +++ b/ui/src/components/AppsExperimentalGate.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from "@/lib/router"; +import { useAppsEnabled } from "@/hooks/useAppsEnabled"; + +export function AppsExperimentalGate() { + const { enabled, loaded } = useAppsEnabled(); + + if (!loaded) return null; + if (!enabled) return ; + return ; +} diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx new file mode 100644 index 00000000000..4e15eebf978 --- /dev/null +++ b/ui/src/components/AppsSidebar.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppsSidebar } from "./AppsSidebar"; + +const sidebarNavItemMock = vi.hoisted(() => vi.fn()); +const mockToolsApi = vi.hoisted(() => ({ + listRuntimeSlots: vi.fn(), + listActionRequests: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + onClick, + className, + }: { + children: React.ReactNode; + to: string; + onClick?: () => void; + className?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip" }, + }), +})); + +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + }), +})); + +vi.mock("@/api/tools", () => ({ + toolsApi: mockToolsApi, +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: (props: { + to: string; + label: string; + end?: boolean; + liveCount?: number; + badge?: number; + }) => { + sidebarNavItemMock(props); + return
{props.label}
; + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// React 19 does not export a usable `act` in this vitest/jsdom setup; use a +// flushSync-based helper (PAP-12371 gotcha). +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + } +} + +describe("AppsSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockToolsApi.listRuntimeSlots.mockResolvedValue({ + runtimeSlots: [ + { id: "slot-1", status: "running" }, + { id: "slot-2", status: "stopped" }, + ], + }); + mockToolsApi.listActionRequests.mockResolvedValue({ actionRequests: [] }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders Apps and Developer sections in one sidebar", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Apps"); + expect(container.textContent).toContain("Developer"); + // The Developer boundary caption frames who the door is for (PAP-13241 §5). + expect(container.textContent).toContain("Advanced setup for developers"); + // "Run your own" / "Paste a config" moved to the Connect-an-app page (PAP-10922); + // assert their absence at the item level below. + + // Three peer consumer doors: Browse (store) · Connections · Review (PAP-13254). + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/apps/browse", label: "Browse" }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/apps", label: "Connections", end: true }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/apps/review", label: "Review" }), + ); + // "Needs attention" is no longer a top-level door — it folds into Connections. + expect(sidebarNavItemMock).not.toHaveBeenCalledWith( + expect.objectContaining({ label: "Needs attention" }), + ); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith( + expect.objectContaining({ label: "Run your own" }), + ); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith( + expect.objectContaining({ label: "Paste a config" }), + ); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith( + expect.objectContaining({ label: "Applications" }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles", end: true }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/apps/advanced/runtime", label: "Health", end: true, liveCount: 1 }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "/apps/advanced/audit", label: "Activity", end: true }), + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx new file mode 100644 index 00000000000..09d26c78f56 --- /dev/null +++ b/ui/src/components/AppsSidebar.tsx @@ -0,0 +1,106 @@ +import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { toolsApi } from "@/api/tools"; +import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; +import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; +import { useReviewCount } from "@/pages/apps/useReviewCount"; +import { SidebarNavItem } from "./SidebarNavItem"; + +/** + * Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA + * PAP-13254 / U3). + * + * ← Back · APPS: Browse / Connections / Review (n) + * DEVELOPER: Gateways / Profiles / Rules / Health / Activity + * + * The three consumer doors are peers: "Browse" (the store — discover + add), + * "Connections" (your connected tools + health), and "Review" (PAP-12371, + * Finding B — decisions waiting on your OK, with a live pending count). + * "Needs attention" is no longer a door: health/error triage folds into + * Connections as a status filter + banner, so approvals are never buried + * behind an error label. The Developer section was folded in from the retired + * ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a + * one-line caption frames who it's for (Finding A). "Run your own" and "Paste a + * config" moved out of the sidebar into rows on the Connect-an-app page + * (PAP-10922). + */ +export function AppsSidebar() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + const reviewCount = useReviewCount(); + const { enabled: smokeLabEnabled } = useSmokeLabEnabled(); + const developerTabs = DEVELOPER_TABS.filter( + (tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled, + ); + + const runtimeSlots = useQuery({ + queryKey: queryKeys.tools.runtimeSlots(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listRuntimeSlots(selectedCompanyId!), + enabled: !!selectedCompanyId, + refetchInterval: 15_000, + }); + const runtimeActiveCount = (runtimeSlots.data?.runtimeSlots ?? []) + .filter((slot) => slot.status === "running").length; + + return ( + + ); +} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index d8e318febcc..f4269120c24 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -12,6 +12,7 @@ const mockHealthApi = vi.hoisted(() => ({ const mockInstanceSettingsApi = vi.hoisted(() => ({ getGeneral: vi.fn(), + getExperimental: vi.fn(), })); const mockNavigate = vi.hoisted(() => vi.fn()); @@ -59,6 +60,20 @@ vi.mock("./CompanySettingsSidebar", () => ({ CompanySettingsSidebar: () =>
Company settings sidebar
, })); +vi.mock("./AppsSidebar", () => ({ + AppsSidebar: () =>
Apps sidebar
, +})); + +vi.mock("./AppConnectionSidebar", () => ({ + AppDetailSidebar: (props: { kind: "connection"; connectionId: string } | { kind: "application"; applicationId: string }) => ( +
+ {props.kind === "connection" + ? `App detail sidebar connection ${props.connectionId}` + : `App detail sidebar application ${props.applicationId}`} +
+ ), +})); + vi.mock("./BreadcrumbBar", () => ({ BreadcrumbBar: () =>
Breadcrumbs
, })); @@ -245,6 +260,7 @@ describe("Layout", () => { mockInstanceSettingsApi.getGeneral.mockResolvedValue({ keyboardShortcuts: false, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); mockPluginSlots.slots = []; mockPluginSlotContexts.length = 0; mockSidebarState.sidebarOpen = true; @@ -485,6 +501,218 @@ describe("Layout", () => { }); }); + it("keeps the app sidebar and shows the Apps sidebar in the secondary pane on legacy tools routes", async () => { + currentPathname = "/PAP/tools/runtime"; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Apps sidebar"); + expect(container.textContent).toContain("Main company nav"); + expect(container.textContent).not.toContain("Company settings sidebar"); + expect(mockSetForceCollapsed).toHaveBeenCalledWith(true); + + await act(async () => { + root.unmount(); + }); + }); + + it("does not mount the Apps secondary sidebar while experimental apps are disabled", async () => { + currentPathname = "/PAP/apps/browse"; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).not.toContain("Apps sidebar"); + expect(container.textContent).toContain("Main company nav"); + expect(mockSetForceCollapsed).toHaveBeenCalledWith(false); + + await act(async () => { + root.unmount(); + }); + }); + + it("keeps the Apps sidebar on the M8 advanced-setup tabs", async () => { + currentPathname = "/PAP/apps/advanced/run-your-own"; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Apps sidebar"); + expect(container.textContent).toContain("Main company nav"); + + await act(async () => { + root.unmount(); + }); + }); + + it("keeps the Apps sidebar on legacy developer tabs behind the Advanced door", async () => { + currentPathname = "/PAP/apps/advanced/runtime"; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Apps sidebar"); + expect(container.textContent).toContain("Main company nav"); + + await act(async () => { + root.unmount(); + }); + }); + + // Reserved Apps subroutes are not connection ids. They must keep the + // top-level Apps sidebar, never mount a detail sidebar for a phantom app. + it.each(["browse", "review"])("keeps the Apps sidebar on the %s surface", async (route) => { + currentPathname = `/PAP/apps/${route}`; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Apps sidebar"); + expect(container.textContent).toContain("Main company nav"); + expect(container.textContent).not.toContain("App detail sidebar"); + + await act(async () => { + root.unmount(); + }); + }); + + it("keeps the Apps sidebar on the gateways list and detail routes", async () => { + for (const pathname of ["/PAP/apps/gateways", "/PAP/apps/gateways/gw-1/overview"]) { + currentPathname = pathname; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Apps sidebar"); + expect(container.textContent).toContain("Main company nav"); + expect(container.textContent).not.toContain("App detail sidebar"); + + await act(async () => { + root.unmount(); + }); + } + }); + + it("uses the app connection sidebar on app detail routes", async () => { + currentPathname = "/PAP/apps/conn-1/permissions"; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("App detail sidebar connection conn-1"); + expect(container.textContent).toContain("Main company nav"); + expect(container.textContent).not.toContain("Apps sidebar"); + + await act(async () => { + root.unmount(); + }); + }); + + it("uses the app detail sidebar on not-connected app routes", async () => { + currentPathname = "/PAP/apps/app/app-1/permissions"; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("App detail sidebar application app-1"); + expect(container.textContent).toContain("Main company nav"); + expect(container.textContent).not.toContain("Apps sidebar"); + + await act(async () => { + root.unmount(); + }); + }); + it("forces the app sidebar rail only for the Skills Store route", async () => { async function renderAt(pathname: string) { currentPathname = pathname; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 14b6f221fd2..ccb9fb3f4f9 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -4,6 +4,8 @@ import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from " import { Sidebar } from "./Sidebar"; import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; import { CompanySettingsNav } from "./access/CompanySettingsNav"; +import { AppsSidebar } from "./AppsSidebar"; +import { AppDetailSidebar } from "./AppConnectionSidebar"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; @@ -27,6 +29,7 @@ import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; +import { useAppsEnabled } from "../hooks/useAppsEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -45,13 +48,19 @@ import { NotFoundPage } from "../pages/NotFound"; import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots"; function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null { - if (!companyPrefix) return null; - const segments = pathname.split("/").filter(Boolean); - if (segments.length < 2) return null; - if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return null; - return segments[1]?.toLowerCase() ?? null; + return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null; } +function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] { + if (!companyPrefix) return []; + const segments = pathname.split("/").filter(Boolean); + if (segments.length < 2) return []; + if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return []; + return segments.slice(1); +} + +const RESERVED_APP_SUBPATHS = new Set(["browse", "connect", "review", "attention", "gateways", "advanced", "app"]); + function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) { const segments = pathname.split("/").filter(Boolean); if (segments[0]?.toLowerCase() === "skills") return true; @@ -91,7 +100,19 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); + const { enabled: appsEnabled } = useAppsEnabled(); const isCompanySettingsRoute = location.pathname.includes("/company/settings"); + const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix); + const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools"; + const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps"; + const appDetailConnectionId = + isAppsRoute && companyPathSegments[1] && !RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase()) + ? companyPathSegments[1] + : null; + const appDetailApplicationId = + isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2] + ? companyPathSegments[2] + : null; // The Skills Store renders its own secondary (category) sidebar, so the main // app nav collapses to its rail throughout the Skills Store section (PAP-10879). const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix); @@ -138,6 +159,12 @@ export function Layout() { // both desktop (SecondarySidebar) and mobile (off-canvas drawer). const secondarySidebar = isCompanySettingsRoute ? ( + ) : appsEnabled && appDetailConnectionId ? ( + + ) : appsEnabled && appDetailApplicationId ? ( + + ) : appsEnabled && (isAppsRoute || isToolsRoute) ? ( + ) : routeSidebarSlot ? ( { }); }); + it("hides the Apps nav item unless experimental apps are enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); + const disabledRoot = await renderSidebar(); + + expect([...container.querySelectorAll("a")].some((anchor) => anchor.textContent === "Apps")).toBe(false); + + flushSync(() => { + disabledRoot.unmount(); + }); + + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); + const enabledRoot = await renderSidebar(); + + const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Apps"); + expect(link?.getAttribute("href")).toBe("/apps"); + + flushSync(() => { + enabledRoot.unmount(); + }); + }); + it("shows the Pipelines nav item when pipelines are enabled", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 29326f057c6..613baa62785 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { PanelLeftClose, PanelLeftOpen, Pin, + AppWindow, MessagesSquare, GanttChartSquare, } from "lucide-react"; @@ -79,6 +80,7 @@ export function Sidebar() { usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; + const showApps = experimentalSettings?.enableApps === true; const showPipelines = experimentalSettings?.enablePipelines === true; const goalsLinkPending = experimentalSettings === undefined; const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true; @@ -269,6 +271,7 @@ export function Sidebar() { + {showApps ? : null} diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index a9603af7abf..c7034179c89 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -46,7 +46,7 @@ interface SidebarNavItemProps { className?: string; labelClassName?: string; badge?: number; - badgeTone?: "default" | "danger"; + badgeTone?: "default" | "danger" | "warning"; /** * Accessible noun for the numeric badge when collapsed to the rail, where the * count is rendered as a dot (e.g. `badgeLabel="unread"` → "Inbox, 28 unread"). @@ -152,7 +152,11 @@ export function SidebarNavItem({