feat(mcp) [split 7/8]: activate Apps and gateway UI (#9562)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 7/8 and focuses on Apps/Gateways UI and
feature-flagged activation
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: The standalone UI foundation needs feature-flagged routes,
navigation, app detail flows, gateways, Storybook scenarios, and QA
configuration.
- Proposed solution: Adds Apps/Gateways pages and components,
navigation/route activation, remaining page integrations, Storybook
stories, and QA Vite configuration.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is
`pap10341-split/06-ui-tools-foundation`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: UXDesigner sanity pass on Apps/Gateways flows and
flag-off behavior; Greptile on every PR.

## What Changed

- Adds Apps/Gateways pages and components, navigation/route activation,
remaining page integrations, Storybook stories, and QA Vite
configuration.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck`
- `pnpm check:token-gates` — all gates clean
- Focused UI Vitest run with `NODE_ENV=test` — 17 files, 147 tests
passed

## Risks

- Navigation or flag regressions could expose incomplete experiences;
activation remains controlled by existing experimental settings.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> 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, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools enabled.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.


## UI Evidence

QA captured these from the live Garden MCP split stack at 1440px and
verified clean rendering:

![Apps
connections](https://raw.githubusercontent.com/paperclipai/paperclip/42512d030c96f678c379fbd97d8b7c1737d654fc/doc/screenshots/garden-mcp-split-stack/apps-list.png)

![Gateway
detail](https://raw.githubusercontent.com/paperclipai/paperclip/42512d030c96f678c379fbd97d8b7c1737d654fc/doc/screenshots/garden-mcp-split-stack/gateway-detail.png)

![BYO connect
wizard](https://raw.githubusercontent.com/paperclipai/paperclip/42512d030c96f678c379fbd97d8b7c1737d654fc/doc/screenshots/garden-mcp-split-stack/byo-connect.png)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-07-14 15:40:08 -05:00
1 parent a116713b9a
commit f712cedbc2
79 files changed
+17681 -146

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+62 -1
View File
@@ -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() {
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="company/settings/secrets" element={<Secrets />} />
<Route path="company/settings/tools" element={<LegacyToolsSettingsRedirect />} />
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
<Route path="tools" element={<LegacyToolsRedirect />} />
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
<Route element={<AppsExperimentalGate />}>
<Route path="apps" element={<Connections />} />
<Route path="apps/browse" element={<Browse />} />
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
<Route path="apps/connect/:appKey" element={<Navigate to="/apps/browse" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps/browse" replace />} />
<Route path="apps/review" element={<AppsReview />} />
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
</Route>
<Route path="company/settings/instance" element={<Navigate to="general" replace />} />
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
@@ -235,6 +272,12 @@ function boardRoutes() {
);
}
function AppsConnectEntryRoute() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
return searchParams.get("byo") === "1" ? <AppsConnect /> : <Navigate to="/apps/browse" replace />;
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
@@ -307,6 +350,24 @@ function LegacySettingsRedirect() {
);
}
function LegacyToolsSettingsRedirect() {
const { tab } = useParams<{ tab?: string }>();
return <Navigate to={legacyToolsRedirectTarget(tab)} replace />;
}
// 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 <Navigate to={legacyToolsRedirectTarget(tab)} replace />;
}
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();
@@ -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;
}) => (
<a href={to} onClick={onClick} className={className}>
{children}
</a>
),
}));
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 (
<div data-to={props.to} data-active={props.to === currentPath.value ? "true" : "false"}>
{props.label}
{props.badge ? ` ${props.badge}` : ""}
</div>
);
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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<string, unknown> = {}) {
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<typeof createRoot>;
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 = <AppDetailSidebar kind="connection" connectionId="conn-1" />) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
{element}
</QueryClientProvider>,
);
});
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(<AppDetailSidebar kind="application" applicationId="app-1" />);
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(<AppDetailSidebar kind="application" applicationId="missing-app" />);
expect(container.textContent).toContain("App");
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
expect(container.querySelectorAll("[data-to]").length).toBe(5);
});
});
+141
View File
@@ -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 (
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
<div className="flex shrink-0 flex-col gap-3 px-3 py-3">
<Link
to="/apps"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">All apps</span>
</Link>
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={28} />
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
</div>
</div>
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter(
(tab) => props.kind === "connection" || !CONNECTED_ONLY_APP_TABS.has(tab.key),
).map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
label={tab.label}
icon={tab.icon}
end
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
badgeTone="danger"
badgeLabel="needing review"
/>
))}
</div>
</nav>
</aside>
);
}
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;
});
}
@@ -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 }) => (
<div data-testid="navigate" data-to={to} data-replace={String(replace)} />
),
Outlet: () => <div data-testid="apps-content">Apps content</div>,
}));
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(
<QueryClientProvider client={queryClient}>
<AppsExperimentalGate />
</QueryClientProvider>,
);
});
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();
});
});
@@ -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 <Navigate to="/dashboard" replace />;
return <Outlet />;
}
+166
View File
@@ -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;
}) => (
<a href={to} onClick={onClick} className={className}>
{children}
</a>
),
}));
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 <div data-to={props.to}>{props.label}</div>;
},
}));
// 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<void>) {
let result: void | Promise<void> = 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(
<QueryClientProvider client={queryClient}>
<AppsSidebar />
</QueryClientProvider>,
);
});
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();
});
});
});
+106
View File
@@ -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 (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
</Link>
<div className="flex items-center gap-2 px-2 py-1">
<AppWindow className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">Apps</span>
</div>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Apps
</div>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/apps/browse" label="Browse" icon={Store} />
<SidebarNavItem to="/apps" label="Connections" icon={AppWindow} end />
<SidebarNavItem
to="/apps/review"
label="Review"
icon={ShieldQuestion}
badge={reviewCount > 0 ? reviewCount : undefined}
badgeTone="warning"
badgeLabel="waiting for your OK"
/>
</div>
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Developer
</div>
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
Advanced setup for developers. Most teams never open this.
</p>
<div className="flex flex-col gap-0.5">
{developerTabs.map((tab) => (
<SidebarNavItem
key={tab.key}
to={advancedTabHref(tab.key)}
label={tab.label}
icon={tab.icon}
end
liveCount={tab.key === "runtime" && runtimeActiveCount > 0 ? runtimeActiveCount : undefined}
/>
))}
</div>
</nav>
</aside>
);
}
+228
View File
@@ -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: () => <div>Company settings sidebar</div>,
}));
vi.mock("./AppsSidebar", () => ({
AppsSidebar: () => <div>Apps sidebar</div>,
}));
vi.mock("./AppConnectionSidebar", () => ({
AppDetailSidebar: (props: { kind: "connection"; connectionId: string } | { kind: "application"; applicationId: string }) => (
<div>
{props.kind === "connection"
? `App detail sidebar connection ${props.connectionId}`
: `App detail sidebar application ${props.applicationId}`}
</div>
),
}));
vi.mock("./BreadcrumbBar", () => ({
BreadcrumbBar: () => <div>Breadcrumbs</div>,
}));
@@ -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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
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;
+32 -5
View File
@@ -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 ? (
<CompanySettingsSidebar />
) : appsEnabled && appDetailConnectionId ? (
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
) : appsEnabled && appDetailApplicationId ? (
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
) : appsEnabled && (isAppsRoute || isToolsRoute) ? (
<AppsSidebar />
) : routeSidebarSlot ? (
<PluginSlotMount
slot={routeSidebarSlot}
+21
View File
@@ -452,6 +452,27 @@ describe("Sidebar", () => {
});
});
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,
+3
View File
@@ -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() {
<SidebarSection label="Company" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
+9 -3
View File
@@ -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({
<span
className={cn(
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-(--shadow-extract-12)",
badgeTone === "danger" ? "bg-red-600" : "bg-primary",
badgeTone === "danger"
? "bg-red-600"
: badgeTone === "warning"
? "bg-amber-500"
: "bg-primary",
)}
aria-hidden="true"
/>
@@ -192,7 +196,9 @@ export function SidebarNavItem({
"ml-auto px-1.5 leading-none",
badgeTone === "danger"
? "bg-red-600/90 text-red-50"
: "bg-primary text-primary-foreground",
: badgeTone === "warning"
? "bg-amber-500/90 text-amber-50"
: "bg-primary text-primary-foreground",
)}
>
{badge}
+23
View File
@@ -1477,6 +1477,14 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--project-none: #64748b; /* Semantic rename of --hex-64748b (DECISION-SHEET.md A3, value unchanged). 'No project assigned' muted-slate fallback (TOKEN-AUDIT.md 1.3) across Routines/MarkdownEditor/RoutineRunVariablesDialog/RoutineList/IssueColumns/editable-sections; also ActivityCharts.tsx status 'backlog'. */
--project-seed: #6366f1; /* Semantic rename of --hex-6366f1 (DECISION-SHEET.md A3, value unchanged). Project-color-fallback indigo seed default (ProjectDetail/PipelineSettings/IssueProperties/NewIssueDialog) — new-project-color-picker-seed family per TOKEN-AUDIT.md 1.3. */
--liveness-blue: #2563eb; /* DECISION-SHEET.md A6: IssueChatThread.tsx human-message 'liveness blue' bubble (PAP-95 rev 5). Same value as --status-task-in_progress by coincidence, decoupled on purpose so a future status-hue change doesn't drag the chat bubble along. */
--app-logo-tile-1: #ff4f00;
--app-logo-tile-2: #24292f;
--app-logo-tile-3: #4a154b;
--app-logo-tile-4: #0f172a;
--app-logo-tile-5: #5e6ad2;
--app-logo-tile-6: #1a73e8;
--app-logo-tile-7: #0f9d58;
--app-logo-tile-8: #ea4335;
--gradient-extract-1: linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02)); /* Dashboard.tsx budget-alert card gradient. */
--gradient-extract-2: linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02)); /* Costs.tsx subtle white card gradient. */
--gradient-extract-3: radial-gradient(circle at top left,rgba(244,114,182,0.08),transparent 35%),radial-gradient(circle at bottom right,rgba(56,189,248,0.1),transparent 32%); /* AccountingModelCard.tsx decorative overlay. */
@@ -1548,6 +1556,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
*/
:root {
--sz-320px: 320px; /* Extracted from ui/src/components/ActiveAgentsPanel.tsx (h-[320px]). */
--sz-390px: 390px;
--sz-64px: 64px;
--sz-3px: 3px; /* Extracted from ui/src/components/ActivityCharts.tsx (gap-[3px]). */
--sz-calc-1: calc(100% - 1.5rem); /* Extracted from ui/src/components/ActivityFeed.tsx (w-[calc(100%-1.5rem)]). */
--sz-calc-2: calc(100% - 0.75rem); /* Extracted from ui/src/components/ActivityFeed.tsx (w-[calc(100%-0.75rem)]). */
@@ -1577,6 +1587,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--sz-94vw: 94vw; /* Extracted from ui/src/components/FileViewerSheet.tsx (w-[94vw]). */
--sz-1280px: 1280px; /* Extracted from ui/src/components/FileViewerSheet.tsx (max-w-[1280px]). */
--sz-60vh: 60vh; /* Extracted from ui/src/components/FrontDoor.tsx (min-h-[60vh]). */
--sz-52vh: 52vh;
--sz-40rem: 40rem;
--sz-44rem: 44rem;
--sz-30pct: 30%;
--sz-24pct: 24%;
--sz-18pct: 18%;
--sz-12pct: 12%;
--sz-calc-6: min(20rem,calc(100vw - 2rem)); /* Extracted from ui/src/components/InlineEntitySelector.tsx (w-[min(20rem,calc(100vw-2rem))]). */
--sz-18rem: 18rem; /* Extracted from ui/src/components/IssueBlockedNotice.tsx (max-w-[18rem]). */
--sz-70vh: 70vh; /* Extracted from ui/src/components/IssueChatThread.test.tsx (max-h-[70vh]). */
@@ -1801,6 +1818,12 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--gtc-56: minmax(0,1fr) minmax(14rem,18rem); /* Extracted from ui/src/pages/TeamCatalog.tsx (grid-cols-[minmax(0,1fr)_minmax(14rem,18rem)]). */
--gtc-57: repeat(14,minmax(0,1fr)); /* Extracted from ui/src/pages/UserProfile.tsx (grid-cols-[repeat(14,minmax(0,1fr))]). */
--gtc-58: auto 1fr auto; /* Extracted from ui/src/pages/UserProfile.tsx (grid-cols-[auto_1fr_auto]). */
--gtc-59: 8rem 1fr;
--gtc-60: 1fr 1fr;
--gtc-61: 1fr 1fr 1fr;
--gtc-62: 1fr minmax(0,18rem);
--gtc-63: 280px 1fr;
--gtc-64: minmax(0,20rem) 1fr;
--gtr-1: auto auto; /* Extracted from ui/src/components/ui/card.tsx (grid-rows-[auto_auto]). */
--gtr-2: 1fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[1fr]). */
--gtr-3: 0fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[0fr]). */
+25 -5
View File
@@ -33,6 +33,7 @@ import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { toolsApi } from "../api/tools";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
import { StatusBadge } from "../components/StatusBadge";
import { MarkdownBody } from "../components/MarkdownBody";
@@ -87,6 +88,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { AgentToolsTab } from "./AgentToolsTab";
import {
appendCapped,
LIVE_TRANSCRIPT_RENDER_LIMIT,
@@ -270,12 +272,13 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget";
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "budget";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "instructions" || value === "prompts") return "instructions";
if (value === "configure" || value === "configuration") return "configuration";
if (value === "skills") return "skills";
if (value === "tools") return "tools";
if (value === "budget") return "budget";
if (value === "runs") return value;
return "dashboard";
@@ -892,10 +895,12 @@ export function AgentDetail() {
? "configuration"
: activeView === "skills"
? "skills"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: activeView === "tools"
? "tools"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
@@ -999,6 +1004,8 @@ export function AgentDetail() {
crumbs.push({ label: "Configuration" });
// } else if (activeView === "skills") { // TODO: bring back later
// crumbs.push({ label: "Skills" });
} else if (activeView === "tools") {
crumbs.push({ label: "Tools" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else if (activeView === "budget") {
@@ -1243,6 +1250,7 @@ export function AgentDetail() {
{ value: "instructions", label: "Instructions" },
{ value: "skills", label: "Skills" },
{ value: "configuration", label: "Configuration" },
{ value: "tools", label: "Tools" },
{ value: "runs", label: "Runs" },
{ value: "budget", label: "Budget" },
]}
@@ -1360,6 +1368,10 @@ export function AgentDetail() {
/>
)}
{activeView === "tools" && resolvedCompanyId && (
<AgentToolsTab agent={agent} companyId={resolvedCompanyId} />
)}
{activeView === "runs" && (
<RunsTab
runs={heartbeats ?? []}
@@ -3850,6 +3862,13 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
() => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines, parserTick],
);
const toolDecisionLookup = useQuery({
queryKey: queryKeys.tools.runDecisions(run.companyId, run.id),
queryFn: () => toolsApi.getRunDecisionLookup(run.companyId, run.id),
enabled: Boolean(run.companyId && run.id),
refetchInterval: isLive ? 3000 : false,
staleTime: isLive ? 1000 : 30_000,
});
useEffect(() => {
setTranscriptMode("nice");
@@ -3936,6 +3955,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="max-h-(--sz-38rem) overflow-y-auto rounded-2xl border border-border/70 bg-background/40 p-3 sm:p-4">
<RunTranscriptView
entries={transcript}
toolDecisions={toolDecisionLookup.data?.decisions ?? []}
mode={transcriptMode}
streaming={isLive}
limit={isLive ? LIVE_TRANSCRIPT_RENDER_LIMIT : undefined}
+309
View File
@@ -0,0 +1,309 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ToolCatalogEntry,
ToolPolicy,
ToolProfileEffectiveSummary,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
const mockToolsApi = vi.hoisted(() => ({
getEffectiveProfilesForAgent: vi.fn(),
listConnections: vi.fn(),
listPolicies: vi.fn(),
listCatalog: vi.fn(),
listAudit: vi.fn(),
putConnectionInstalls: vi.fn(),
}));
vi.mock("../api/tools", () => ({ toolsApi: mockToolsApi }));
// Render the company-aware Link as a plain anchor so we don't need a Router.
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...rest }: { to: string; children: unknown }) =>
createElement("a", { href: to, ...rest }, children as never),
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact(cycles = 4) {
// Multiple cycles let dependent query waves settle (connections → per-connection catalog).
for (let i = 0; i < cycles; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
function makeCatalogEntry(overrides: Partial<ToolCatalogEntry>): ToolCatalogEntry {
return {
id: "cat-1",
companyId: "company-1",
applicationId: "app-1",
connectionId: "conn-1",
entryKind: "tool",
toolName: "github.read_repo",
title: "Read repo",
description: null,
inputSchema: null,
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
...overrides,
};
}
function makePolicy(overrides: Partial<ToolPolicy>): ToolPolicy {
return {
id: "pol-1",
companyId: "company-1",
name: "Require approval for writes",
description: "All write tools need board approval",
policyType: "require_approval",
priority: 100,
enabled: true,
selectors: {},
conditions: null,
config: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
...overrides,
};
}
describe("AgentToolsTab", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockToolsApi.getEffectiveProfilesForAgent.mockReset();
mockToolsApi.listConnections.mockReset();
mockToolsApi.listPolicies.mockReset();
mockToolsApi.listCatalog.mockReset();
mockToolsApi.putConnectionInstalls.mockReset();
mockToolsApi.putConnectionInstalls.mockResolvedValue({ connectionId: "conn-1", installs: [] });
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
container.remove();
vi.clearAllMocks();
});
async function renderTab() {
const { AgentToolsTab } = await import("./AgentToolsTab");
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const agent = { id: "agent-1", name: "Coder" } as never;
await act(async () => {
root = createRoot(container);
root.render(
createElement(
QueryClientProvider,
{ client },
createElement(AgentToolsTab, { agent, companyId: "company-1" }),
),
);
});
await flushReact();
}
it("preserves checkbox changes made after the last saved install state", async () => {
const { mergeInstallDraft } = await import("./AgentToolsTab");
expect(
mergeInstallDraft(
{ "conn-1": true, "conn-2": false },
{ "conn-1": true, "conn-2": true },
{ "conn-1": false, "conn-2": false },
),
).toEqual({
draft: { "conn-1": true, "conn-2": true },
hasPendingChanges: true,
});
});
it("renders effective access, access profiles, governing policy, and unavailable tools", async () => {
const allowed = makeCatalogEntry({ id: "cat-allow", toolName: "github.read_repo" });
const denied = makeCatalogEntry({
id: "cat-deny",
toolName: "github.delete_repo",
riskLevel: "critical",
isReadOnly: false,
isWrite: true,
isDestructive: true,
});
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [
{
id: "prof-1",
companyId: "company-1",
profileKey: "github-safe",
name: "GitHub safe",
description: null,
status: "active",
defaultAction: "deny",
newToolsReviewedAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
entries: [],
bindings: [],
summary: {
accessMode: "selected",
allowedToolCount: 1,
allowedApplicationCount: 1,
excludedToolCount: 0,
totalToolCount: 1,
assignmentCount: 1,
appliesToAgentCount: 1,
isCompanyDefault: true,
},
},
],
entries: [],
bindings: [
{
id: "bind-1",
companyId: "company-1",
profileId: "prof-1",
targetType: "agent",
targetId: "agent-1",
priority: 100,
metadata: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
allowedTools: [allowed],
allowedToolNames: ["github.read_repo"],
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
mockToolsApi.listConnections.mockResolvedValue({
connections: [{ id: "conn-1", name: "Production GitHub" }],
});
mockToolsApi.listPolicies.mockResolvedValue({
policies: [
makePolicy({ id: "pol-1", name: "Require approval for writes" }),
makePolicy({
id: "pol-2",
name: "Block other agent",
enabled: true,
selectors: { agentId: "someone-else" },
}),
],
});
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [allowed, denied] });
await renderTab();
const text = container.textContent ?? "";
expect(text).toContain("Effective access");
expect(text).toContain("github.read_repo");
expect(text).toContain("Production GitHub");
expect(text).toContain("GitHub safe");
expect(text).toContain("Access profiles");
expect(text).toContain("Company default");
expect(container.querySelector('a[href="/apps/advanced/profiles/prof-1"]')?.textContent).toBe("GitHub safe");
expect(container.querySelector('a[href="/apps/advanced/profiles?check=1"]')?.textContent).toBe("Check access");
// Governing policy #1 is the company-wide require_approval rule.
expect(text).toContain("#1 Require approval for writes");
// The policy that targets a different agent must NOT appear.
expect(text).not.toContain("Block other agent");
// Unavailable tool surfaced from the full tool list minus allowed.
expect(text).toContain("github.delete_repo");
});
it("shows the empty allow-list message when no profile applies", async () => {
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [],
bindings: [],
allowedTools: [],
allowedToolNames: [],
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
mockToolsApi.listConnections.mockResolvedValue({ connections: [] });
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
await renderTab();
const text = container.textContent ?? "";
expect(text).toContain("No tools are allowed for this agent");
expect(text).toContain("No active profile applies");
});
it("autosaves installed apps for the current agent", async () => {
const allowed = makeCatalogEntry({ id: "cat-allow", toolName: "github.read_repo" });
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [],
bindings: [],
allowedTools: [allowed],
allowedToolNames: ["github.read_repo"],
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
mockToolsApi.listConnections.mockResolvedValue({
connections: [{ id: "conn-1", companyId: "company-1", name: "Production GitHub", installs: [] }],
});
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [allowed] });
await renderTab();
expect(container.textContent).toContain("Installed apps");
expect(container.textContent).toContain("Permitted only");
expect(container.textContent).toContain("Permitted but not installed — tools will not appear in runs.");
expect(container.querySelector('a[href="/apps/conn-1/permissions"]')?.textContent).toBe("Open permissions");
const installCheckbox = container.querySelector<HTMLElement>('[aria-label="Install Production GitHub on Coder"]');
expect(installCheckbox).toBeTruthy();
await act(async () => {
installCheckbox!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => window.setTimeout(resolve, 300));
});
await flushReact();
expect(mockToolsApi.putConnectionInstalls).toHaveBeenCalledWith("conn-1", [
{ targetType: "agent", targetId: "agent-1" },
]);
});
});
+638
View File
@@ -0,0 +1,638 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { HelpCircle, PackageCheck } from "lucide-react";
import type {
AgentDetail as AgentDetailRecord,
ToolCatalogEntry,
ToolConnection,
ToolPolicy,
} from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { queryKeys } from "../lib/queryKeys";
import { toolsApi } from "../api/tools";
import { Checkbox } from "@/components/ui/checkbox";
import { InlineBanner } from "@/components/InlineBanner";
import { EnforcementBanner } from "../components/EnforcementBanner";
import {
RiskBadge,
CapabilityBadges,
LoadingState as ToolsLoadingState,
ErrorState as ToolsErrorState,
} from "./tools/shared";
import { cn } from "../lib/utils";
import { brandChipBadge } from "../lib/status-colors";
import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs";
/** Normalize a selector value (string or string[]) into a flat string list. */
function selectorStringList(value: unknown): string[] {
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
if (Array.isArray(value)) {
return value.filter((v): v is string => typeof v === "string" && v.trim().length > 0);
}
return [];
}
/**
* A policy governs this agent's allow list when it either explicitly names the
* agent, or carries no agent/actor restriction that would exclude agents. This
* mirrors how the tool gateway evaluates selectors server-side.
*/
export function policyGovernsAgent(policy: ToolPolicy, agentId: string): boolean {
const selectors = (policy.selectors ?? {}) as Record<string, unknown>;
const agentIds = [
...selectorStringList(selectors.agentId),
...selectorStringList(selectors.agentIds),
];
if (agentIds.length > 0) return agentIds.includes(agentId);
const actorTypes = [
...selectorStringList(selectors.actorType),
...selectorStringList(selectors.actorTypes),
];
if (actorTypes.length > 0 && !actorTypes.includes("agent")) return false;
return true;
}
export function mergeInstallDraft(
serverDraft: Record<string, boolean>,
currentDraft: Record<string, boolean>,
lastSavedDraft: Record<string, boolean>,
): { draft: Record<string, boolean>; hasPendingChanges: boolean } {
const draft = { ...serverDraft };
let hasPendingChanges = false;
for (const [connectionId, installed] of Object.entries(currentDraft)) {
if (installed === (lastSavedDraft[connectionId] ?? false)) continue;
draft[connectionId] = installed;
hasPendingChanges = true;
}
return { draft, hasPendingChanges };
}
function InstalledAppsSection({
agentId,
agentName,
connections,
draft,
permittedConnectionIds,
pendingConnectionId,
saving,
unsaved,
error,
onChange,
}: {
agentId: string;
agentName: string;
connections: ToolConnection[];
draft: Record<string, boolean>;
permittedConnectionIds: Set<string>;
pendingConnectionId: string | null;
saving: boolean;
unsaved: boolean;
error: boolean;
onChange: (connectionId: string, installed: boolean) => void;
}) {
return (
<section className="rounded-lg border border-border bg-card">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-3 py-2.5">
<div>
<h3 className="text-sm font-semibold text-foreground">Installed apps</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
Installed apps load tools into {agentName}'s context on every run. Permitted-only apps do not add context cost.
</p>
</div>
<InstallSaveStatusChip pending={saving} unsaved={unsaved} error={error} />
</div>
<div className="space-y-3 p-3">
<InlineBanner tone="info" compact>
Has access means the app is permitted. Installed means its tools are added to this agent's runtime context.
</InlineBanner>
{connections.length === 0 ? (
<p className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
No permitted apps yet. Bind an access profile to make apps available here.
</p>
) : (
<div className="divide-y divide-border rounded-md border border-border">
{connections.map((connection) => {
const installState = installStateFrom(connection.installs);
const installedForAll = installState.onAll;
const checked = installedForAll || (draft[connection.id] ?? installState.agentIds.has(agentId));
const permitted = permittedConnectionIds.has(connection.id);
const rowPending = pendingConnectionId === connection.id;
return (
<div key={connection.id} className="space-y-2 px-3 py-3">
<label className="flex items-start gap-3">
<Checkbox
checked={checked}
disabled={installedForAll || rowPending}
aria-label={`Install ${connection.name} on ${agentName}`}
onCheckedChange={(next) => onChange(connection.id, Boolean(next))}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{connection.name}</span>
<InstallBadge installed={checked} installedForAll={installedForAll} permitted={permitted} />
{rowPending ? <span className="text-xs text-muted-foreground">Saving...</span> : null}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{installedForAll
? "Installed from the app page for every agent. Remove the all-agents install there."
: checked
? "Loaded into this agent's runtime context."
: INSTALLED_HINT}
</span>
</span>
</label>
{permitted && !checked ? (
<InlineBanner
tone="warning"
compact
className="ml-7"
actions={(
<Link
to={`/apps/${connection.id}/permissions`}
className="text-xs font-medium text-primary hover:underline"
>
Open permissions
</Link>
)}
>
Permitted but not installed — tools will not appear in runs.
</InlineBanner>
) : null}
</div>
);
})}
</div>
)}
</div>
</section>
);
}
function InstallBadge({
installed,
installedForAll,
permitted,
}: {
installed: boolean;
installedForAll: boolean;
permitted: boolean;
}) {
const label = installed ? (installedForAll ? "Installed for all" : "Installed") : permitted ? "Permitted only" : "Not permitted";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
installed ? brandChipBadge.green : brandChipBadge.gray,
)}
>
{installed ? <PackageCheck className="h-3 w-3" /> : null}
{label}
</span>
);
}
function InstallSaveStatusChip({
pending,
unsaved,
error,
}: {
pending: boolean;
unsaved: boolean;
error: boolean;
}) {
if (pending) return <span className="text-xs text-muted-foreground">Saving...</span>;
if (error) return <span className="text-xs text-destructive">Could not save</span>;
if (unsaved) return <span className="text-xs text-muted-foreground">Unsaved changes</span>;
return <span className="text-xs text-muted-foreground">Saved</span>;
}
const POLICY_EFFECT_LABEL: Record<string, string> = {
allow: "allow",
block: "block",
deny: "deny",
require_approval: "require approval",
redact: "redact",
rate_limit: "rate limit",
};
const DENIED_TOOLS_DISPLAY_LIMIT = 30;
/**
* Agent detail · Tools tab (PAP-10788, surface 09 of the PAP-10771 v2 spec).
*
* Communicates the agent's resolved tool access: the banner makes clear the
* prompt can narrow the list but never expand it, and the side panel explains
* which access profiles and rules shape the final list.
*/
export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; companyId: string }) {
const queryClient = useQueryClient();
const [installDraft, setInstallDraft] = useState<Record<string, boolean>>({});
const lastSavedInstallRef = useRef<Record<string, boolean>>({});
const skipNextInstallAutosaveRef = useRef(true);
const failedInstallDraftRef = useRef<{ connectionId: string; installed: boolean } | null>(null);
const effective = useQuery({
queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id),
queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agent.id),
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(companyId),
queryFn: () => toolsApi.listConnections(companyId),
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(companyId),
queryFn: () => toolsApi.listPolicies(companyId),
});
const connectionList = connectionsQuery.data?.connections ?? [];
const connectionInstallSignature = useMemo(
() =>
connectionList
.map((connection) => {
const installKey = (connection.installs ?? [])
.map((install) => `${install.targetType}:${install.targetId}`)
.sort()
.join(",");
return `${connection.id}=${installKey}`;
})
.join("|"),
[connectionList],
);
const syncInstall = useMutation({
mutationFn: ({ connection, installed }: { connection: ToolConnection; installed: boolean }) => {
const nextState = installStateFrom(connection.installs);
if (!nextState.onAll) {
if (installed) nextState.agentIds.add(agent.id);
else nextState.agentIds.delete(agent.id);
}
return toolsApi.putConnectionInstalls(
connection.id,
installPayload(connection.companyId ?? companyId, nextState),
);
},
onSuccess: async (_snapshot, variables) => {
failedInstallDraftRef.current = null;
lastSavedInstallRef.current = {
...lastSavedInstallRef.current,
[variables.connection.id]: variables.installed,
};
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }),
]);
},
onError: (_error, variables) => {
failedInstallDraftRef.current = {
connectionId: variables.connection.id,
installed: variables.installed,
};
},
});
useEffect(() => {
setInstallDraft({});
lastSavedInstallRef.current = {};
skipNextInstallAutosaveRef.current = true;
failedInstallDraftRef.current = null;
}, [agent.id, companyId]);
useEffect(() => {
const next = Object.fromEntries(
connectionList.map((connection) => [
connection.id,
isAgentInstalled(installStateFrom(connection.installs), agent.id),
]),
);
failedInstallDraftRef.current = null;
setInstallDraft((current) => {
const merged = mergeInstallDraft(next, current, lastSavedInstallRef.current);
lastSavedInstallRef.current = next;
skipNextInstallAutosaveRef.current = !merged.hasPendingChanges;
return merged.draft;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent.id, connectionInstallSignature]);
useEffect(() => {
if (skipNextInstallAutosaveRef.current) {
skipNextInstallAutosaveRef.current = false;
return;
}
if (syncInstall.isPending) return;
const changedConnection = connectionList.find((connection) => {
const saved = lastSavedInstallRef.current[connection.id] ?? false;
const draft = installDraft[connection.id] ?? false;
const failed = failedInstallDraftRef.current;
return saved !== draft && !(failed?.connectionId === connection.id && failed.installed === draft);
});
if (!changedConnection) return;
const timeout = window.setTimeout(() => {
const installed = installDraft[changedConnection.id] ?? false;
const saved = lastSavedInstallRef.current[changedConnection.id] ?? false;
const failed = failedInstallDraftRef.current;
if (saved !== installed && !(failed?.connectionId === changedConnection.id && failed.installed === installed)) {
syncInstall.mutate({ connection: changedConnection, installed });
}
}, 250);
return () => window.clearTimeout(timeout);
}, [connectionList, installDraft, syncInstall.isPending, syncInstall.mutate]);
// The effective endpoint returns the *allowed* slice of the catalog. To show
// "Denied tools (suppressed)" we need the full company catalog, which is only
// exposed per-connection — same aggregation the Applications tab uses.
const catalogQueries = useQueries({
queries: connectionList.map((connection) => ({
queryKey: queryKeys.tools.catalog(connection.id),
queryFn: () => toolsApi.listCatalog(connection.id),
staleTime: 60_000,
})),
});
const connectionNameById = useMemo(() => {
const map = new Map<string, string>();
for (const connection of connectionList) map.set(connection.id, connection.name);
return map;
}, [connectionList]);
const allowedTools = useMemo(
() =>
[...(effective.data?.allowedTools ?? [])].sort((a, b) =>
a.toolName.localeCompare(b.toolName),
),
[effective.data?.allowedTools],
);
const permittedConnectionIds = useMemo(
() => new Set([
...(effective.data?.entries ?? [])
.filter((entry) => entry.effect === "include" && entry.connectionId)
.map((entry) => entry.connectionId!),
...allowedTools.map((tool) => tool.connectionId),
]),
[allowedTools, effective.data?.entries],
);
const installedAppConnections = useMemo(
() =>
connectionList
.filter((connection) => permittedConnectionIds.has(connection.id) || (installDraft[connection.id] ?? false))
.sort((a, b) => a.name.localeCompare(b.name)),
[connectionList, installDraft, permittedConnectionIds],
);
const hasInstallUnsavedChanges = installedAppConnections.some(
(connection) => (installDraft[connection.id] ?? false) !== (lastSavedInstallRef.current[connection.id] ?? false),
);
const catalogStamp = catalogQueries.map((q) => q.dataUpdatedAt).join(",");
const deniedTools = useMemo(() => {
const allowedIds = new Set((effective.data?.allowedTools ?? []).map((tool) => tool.id));
const seen = new Set<string>();
const denied: ToolCatalogEntry[] = [];
for (const result of catalogQueries) {
for (const entry of result.data?.catalog ?? []) {
if (entry.status !== "active") continue;
if (allowedIds.has(entry.id) || seen.has(entry.id)) continue;
seen.add(entry.id);
denied.push(entry);
}
}
return denied.sort((a, b) => a.toolName.localeCompare(b.toolName));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effective.data?.allowedTools, catalogStamp]);
const governingPolicies = useMemo(() => {
const policies = policiesQuery.data?.policies ?? [];
return policies
.map((policy, index) => ({ policy, order: index + 1 }))
.filter(({ policy }) => policy.enabled && policyGovernsAgent(policy, agent.id));
}, [policiesQuery.data?.policies, agent.id]);
const profiles = effective.data?.profiles ?? [];
const catalogLoading = catalogQueries.some((q) => q.isLoading);
if (effective.isLoading) return <ToolsLoadingState label="Resolving effective access…" />;
if (effective.error) {
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
}
const policiesHref = "/apps/advanced/policies";
const profilesHref = "/apps/advanced/profiles";
return (
<div className="space-y-4">
<EnforcementBanner
tone="info"
title="Effective access"
body={
<>
This is exactly the tool set Paperclip will accept for{" "}
<span className="font-medium">{agent.name}</span>. Profile and policy edits are
reflected within ~5 seconds. The agent's prompt can narrow this list but{" "}
<span className="font-medium">cannot expand it</span> — everything else is blocked by
default.
</>
}
/>
<InstalledAppsSection
agentId={agent.id}
agentName={agent.name}
connections={installedAppConnections}
draft={installDraft}
permittedConnectionIds={permittedConnectionIds}
pendingConnectionId={syncInstall.isPending ? syncInstall.variables?.connection.id ?? null : null}
saving={syncInstall.isPending}
unsaved={hasInstallUnsavedChanges}
error={syncInstall.isError && hasInstallUnsavedChanges}
onChange={(connectionId, installed) => {
failedInstallDraftRef.current = null;
setInstallDraft((current) => ({ ...current, [connectionId]: installed }));
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Allowed tools table */}
<div className="lg:col-span-2">
<div className="rounded-lg border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Allowed tools</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{allowedTools.length} {allowedTools.length === 1 ? "tool" : "tools"}
</span>
</div>
{allowedTools.length === 0 ? (
<p className="px-3 py-6 text-sm text-muted-foreground">
No tools are allowed for this agent. Bind a tool profile to grant access.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium">Tool</th>
<th className="px-3 py-2 font-medium">Capability</th>
<th className="px-3 py-2 font-medium">Risk</th>
<th className="px-3 py-2 font-medium">Source</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{allowedTools.map((tool) => (
<tr key={tool.id} className="align-top">
<td className="px-3 py-2">
<div className="font-mono text-xs text-foreground">{tool.toolName}</div>
{tool.title ? (
<div className="text-(length:--text-micro) text-muted-foreground">{tool.title}</div>
) : null}
</td>
<td className="px-3 py-2">
<CapabilityBadges
isReadOnly={tool.isReadOnly}
isWrite={tool.isWrite}
isDestructive={tool.isDestructive}
/>
</td>
<td className="px-3 py-2">
<RiskBadge risk={tool.riskLevel} />
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{connectionNameById.get(tool.connectionId) ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Why these tools? side panel */}
<aside className="space-y-4">
<div className="rounded-lg border border-border bg-background/60 p-3">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
Why these tools?
</h3>
{/* Access profiles */}
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Access profiles
</div>
<Link
to={`${profilesHref}?check=1`}
className="text-(length:--text-micro) font-medium text-primary hover:underline"
>
Check access
</Link>
</div>
{profiles.length === 0 ? (
<p className="text-xs text-muted-foreground">
No active profile applies to this agent, so it has no allowed tools.
</p>
) : (
profiles.map((profile) => {
return (
<div key={profile.id} className="rounded-md border border-border/70 px-2.5 py-2">
<Link
to={`${profilesHref}/${profile.id}`}
className="text-xs font-medium text-primary hover:underline"
>
{profile.name}
</Link>
{profile.summary.isCompanyDefault ? (
<div className="mt-1">
<span className="rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
Company default
</span>
</div>
) : null}
</div>
);
})
)}
</div>
{/* Policies mutating the allow list */}
<div className="mt-3 space-y-1.5">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Active policies
</div>
{policiesQuery.isLoading ? (
<p className="text-xs text-muted-foreground">Loading policies…</p>
) : governingPolicies.length === 0 ? (
<p className="text-xs text-muted-foreground">
No enabled policy currently mutates this agent's allow list.
</p>
) : (
governingPolicies.map(({ policy, order }) => (
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<Link
to={policiesHref}
className="truncate text-xs font-medium text-primary hover:underline"
title={`Policy #${order}: ${policy.name}`}
>
#{order} {policy.name}
</Link>
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
</span>
</div>
{policy.description ? (
<div className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground">
{policy.description}
</div>
) : null}
</div>
))
)}
</div>
{/* Unavailable tools */}
<div className="mt-3 space-y-1.5">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Unavailable tools
</div>
{catalogLoading ? (
<p className="text-xs text-muted-foreground">Checking tools…</p>
) : deniedTools.length === 0 ? (
<p className="text-xs text-muted-foreground">
Every known tool this agent could name is allowed.
</p>
) : (
<>
<p className="text-(length:--text-micro) text-muted-foreground">
Tools the agent could name but Paperclip would block:
</p>
<div className="flex flex-wrap gap-1">
{deniedTools.slice(0, DENIED_TOOLS_DISPLAY_LIMIT).map((tool) => (
<span
key={tool.id}
className="inline-flex items-center gap-1 rounded border border-border/70 bg-muted/40 px-1.5 py-0.5 font-mono text-(length:--text-micro) text-muted-foreground"
title={connectionNameById.get(tool.connectionId) ?? undefined}
>
{tool.toolName}
</span>
))}
</div>
{deniedTools.length > DENIED_TOOLS_DISPLAY_LIMIT ? (
<p className="text-(length:--text-micro) text-muted-foreground">
+{deniedTools.length - DENIED_TOOLS_DISPLAY_LIMIT} more
</p>
) : null}
</>
)}
</div>
</div>
</aside>
</div>
</div>
);
}
+49 -132
View File
@@ -34,6 +34,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { CopyText } from "../components/CopyText";
import { Identity } from "../components/Identity";
import { AgentIcon } from "../components/AgentIconPicker";
import { AgentMultiSelect } from "../components/AgentMultiSelect";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import {
Dialog,
@@ -54,11 +55,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { buildLineDiff, type DiffRow } from "../lib/line-diff";
@@ -1897,140 +1893,61 @@ function AttachAgentsPopover({
onSubmit: (nextIds: string[], versionId: string | null) => void;
fullWidth?: boolean;
}) {
// Each popover instance owns its open state. The detail page renders two of
// these (agents tab + sidebar); sharing a single controlled flag made both
// open at once and swallowed clicks, so "Add to agent" appeared dead (PAP-10907 H).
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const [draft, setDraft] = useState<Set<string>>(new Set(attachedAgentIds));
const [draftVersionId, setDraftVersionId] = useState<string | null>(selectedVersionId);
useEffect(() => {
if (open) {
setDraft(new Set(attachedAgentIds));
setDraftVersionId(selectedVersionId);
setFilter("");
}
}, [open, attachedAgentIds, selectedVersionId]);
// Checked agents float to the top of the list (PAP-10907); within each group
// we keep a stable alphabetical order.
const filtered = agents
.filter((agent) => agent.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
const aChecked = draft.has(a.id);
const bChecked = draft.has(b.id);
if (aChecked !== bChecked) return aChecked ? -1 : 1;
return a.name.localeCompare(b.name);
});
const attachedIds = useMemo(() => new Set(attachedAgentIds), [attachedAgentIds]);
const eligible = agents.filter((agent) => agent.supportsSkills);
const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button size="sm" className={cn(fullWidth && "w-full")}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add to agent
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="border-b border-border px-3 py-2">
<Input
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Filter agents"
className="h-8"
/>
{sortedVersions.length > 0 ? (
<div className="mt-2 flex items-center gap-2 text-xs">
<span className="shrink-0 text-muted-foreground">Version</span>
<select
value={draftVersionId ?? "__latest__"}
onChange={(event) => setDraftVersionId(event.target.value === "__latest__" ? null : event.target.value)}
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground"
>
<option value="__latest__">Latest</option>
{sortedVersions.map((version) => (
<option key={version.id} value={version.id}>
v{version.revisionNumber}{version.label ? ` · ${version.label}` : ""}
</option>
))}
</select>
</div>
) : null}
</div>
{eligible.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
No agents in this company support skills yet.
</div>
) : (
<div className="max-h-60 overflow-y-auto py-1">
{filtered.map((agent) => {
const disabled = agent.required || !agent.supportsSkills;
const checked = draft.has(agent.id);
return (
<label
key={agent.id}
className={cn(
"flex items-start gap-2 px-3 py-1.5 text-sm hover:bg-accent/30",
disabled && "opacity-60",
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(value) => {
setDraft((current) => {
const next = new Set(current);
if (value) next.add(agent.id);
else next.delete(agent.id);
return next;
});
}}
/>
<AgentIcon icon={agent.icon} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-1.5">
<span className="truncate">{agent.name}</span>
{agent.paused ? (
<Badge variant="outline" className="[&>svg]:size-2.5 border-amber-500/30 bg-amber-500/10 px-1.5 text-(length:--text-nano) uppercase tracking-wide text-amber-500">
<Pause className="h-2.5 w-2.5" aria-hidden="true" />
Paused
</Badge>
) : null}
</span>
<span className="text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
{agent.adapterType}
{agent.required ? " · required" : ""}
{!agent.supportsSkills ? " · skills not supported" : ""}
</span>
</span>
</label>
);
})}
{filtered.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">No matches.</div>
) : null}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-border px-3 py-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)} disabled={pending}>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
onSubmit(Array.from(draft), draftVersionId);
setOpen(false);
}}
disabled={pending}
<AgentMultiSelect
agents={agents}
selectedAgentIds={attachedIds}
onSave={(nextIds) => onSubmit(Array.from(nextIds), draftVersionId)}
pending={pending}
triggerLabel="Add to agent"
triggerIcon={<Plus className="mr-1.5 h-3.5 w-3.5" />}
triggerVariant="default"
triggerSize="sm"
triggerFullWidth={fullWidth}
triggerClassName={cn(fullWidth && "w-full")}
contentAlign="end"
showSelectionPreview={false}
onOpenChange={(open) => {
if (open) setDraftVersionId(selectedVersionId);
}}
headerContent={sortedVersions.length > 0 ? (
<div className="mt-2 flex items-center gap-2 text-xs">
<span className="shrink-0 text-muted-foreground">Version</span>
<select
value={draftVersionId ?? "__latest__"}
onChange={(event) => setDraftVersionId(event.target.value === "__latest__" ? null : event.target.value)}
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground"
>
{pending ? "Saving…" : "Save"}
</Button>
<option value="__latest__">Latest</option>
{sortedVersions.map((version) => (
<option key={version.id} value={version.id}>
v{version.revisionNumber}{version.label ? ` · ${version.label}` : ""}
</option>
))}
</select>
</div>
</PopoverContent>
</Popover>
) : null}
emptyMessage={eligible.length === 0 ? "No agents in this company support skills yet." : "No agents yet."}
isAgentDisabled={(agent) => {
const option = agent as AttachAgentOption;
return option.required || !option.supportsSkills;
}}
getDescription={(agent) => {
const option = agent as AttachAgentOption;
return `${option.adapterType}${option.required ? " · required" : ""}${!option.supportsSkills ? " · skills not supported" : ""}`;
}}
renderNameSuffix={(agent) => (agent as AttachAgentOption).paused ? (
<Badge variant="outline" className="[&>svg]:size-2.5 border-amber-500/30 bg-amber-500/10 px-1.5 text-(length:--text-nano) uppercase tracking-wide text-amber-500">
<Pause className="h-2.5 w-2.5" aria-hidden="true" />
Paused
</Badge>
) : null}
/>
);
}
+3
View File
@@ -28,6 +28,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { Card } from "@/components/ui/card";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
import { SmokeLabDashboardCard } from "../components/SmokeLabDashboardCard";
const DASHBOARD_ACTIVITY_LIMIT = 10;
@@ -309,6 +310,8 @@ export function Dashboard() {
/>
</div>
<SmokeLabDashboardCard companyId={selectedCompanyId!} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart activity={data.runActivity} />
+154
View File
@@ -122,6 +122,8 @@ import {
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
import { EntityRow } from "@/components/EntityRow";
@@ -1778,6 +1780,158 @@ export function DesignGuide() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* TOOLS & ACCESS (PAP-10389) */}
{/* ============================================================ */}
<Section title="Tools & Access">
<SubSection title="EnforcementBanner — default / denied-detected">
<div className="space-y-3">
<EnforcementBanner companyId="" forceVariant="default" recentDenialCount={0} />
<EnforcementBanner companyId="" forceVariant="denied-detected" recentDenialCount={3} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
Persistent at the top of the Tools &amp; Access surface. Tints to <code>denied-detected</code> when
governed tool calls were denied or failed in the last hour. Observability only — enforcement lives
in the tool gateway.
</p>
</SubSection>
<SubSection title="EnforcementBanner — presentational tones (info / warning / error)">
<div className="space-y-3">
<EnforcementBanner
tone="info"
title="Effective access — server resolved."
body="This is exactly what the tool gateway will accept. Profile and policy edits reflect within ~5s; the prompt cannot expand it."
/>
<EnforcementBanner
tone="warning"
title="Local stdio is local code execution, not a security sandbox."
body="A local-stdio slot runs with the orchestrator's privileges. Only bind trusted commands; quarantine anything you would not run yourself."
/>
<EnforcementBanner
tone="error"
title="Runtime failed closed."
body="The supervisor is restarting (attempt 2/3). The gateway returns runtime-error and the agent does not see partial output."
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Static governance copy with a tone. Used for the PAP-10400 trust-tier banner on Runtime and the
effective-access banner on Agent → Tools. Pass <code>title</code>/<code>body</code> and an optional{" "}
<code>icon</code>.
</p>
</SubSection>
<SubSection title="Action approval card — pending / stale (surfaces 11/12)">
<div className="grid gap-4 lg:grid-cols-2">
<ActionCard
toolName="slack.post_message"
risk="medium"
isWrite
binding={{
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
}}
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
reason="This tool can write to your workspace, so a human signs off before the agent posts."
policyNumber={7}
expiresInLabel="expires in 23h 51m"
/>
<ActionCard
variant="stale"
toolName="slack.post_message"
risk="medium"
isWrite
binding={{
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
previousCatalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
}}
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
reason="This tool can write to your workspace, so a human signs off before the agent posts."
policyNumber={7}
expiresInLabel="expires in 18h 02m"
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Signed payload sha256 + expiry surface on every variant (PAP-10400). The{" "}
<code>stale</code> variant tints the border amber, banners the catalog-hash mismatch, strikes through
the previous hash next to the current one, and renders <code>Approve</code> disabled until the request
is re-issued.
</p>
</SubSection>
<SubSection title="Action approval card — mobile (390×844, surface 99)">
<div className="w-(--sz-390px) max-w-full rounded-xl border border-border bg-background p-3">
<ActionCardMobile
toolName="slack.post_message"
risk="medium"
isWrite
binding={{
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
}}
input={{ channel: "#launch", text: "Deploy v2 is live 🎉" }}
reason="This tool can write to your workspace, so a human signs off before the agent posts."
policyNumber={7}
expiresInLabel="expires in 23h 51m"
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Identical content; the three buttons stack full-width in the order Approve / Deny / Edit &amp; re-sign,
and the bindings table uses a 70px label column.
</p>
</SubSection>
<SubSection title="BindingsTable (reused in the audit row drilldown)">
<BindingsTable
rows={[
{ label: "Application", value: "Slack · manifest v2.4.1" },
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
{ label: "Catalog", value: "sha256:9f86d081…f00a08", mono: true },
{ label: "Payload", value: "sha256:2c26b46b…66e7ae", mono: true },
]}
/>
<p className="mt-2 text-xs text-muted-foreground">
Two-column key/value block with mono values. Lives inside <code>ActionCard</code> and is reused
standalone in the audit row drilldown.
</p>
</SubSection>
<SubSection title="Tool-access status keys (StatusBadge)">
<div className="flex flex-wrap items-center gap-2">
{[
"allowed", "denied", "block", "require-approval", "redacted", "rate-limit",
"deferred", "hidden", "quarantined", "healthy", "degraded", "runtime-error", "unchecked",
].map((s) => (
<StatusBadge key={s} status={s} />
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Policy decisions, connection/runtime health, and catalog quarantine all route through the canonical{" "}
<code>StatusBadge</code> keys defined in <code>lib/status-colors</code>.
</p>
</SubSection>
<SubSection title="EmptyState (canonical, with description + action)">
<EmptyState
icon={Inbox}
message="No connections yet"
description="Add a connection to an application to configure credentials and discover its tools."
action="New connection"
onAction={() => {}}
/>
</SubSection>
</Section>
<Section title="Environment Variables Editor">
<p className="text-sm text-muted-foreground">
Reusable env-var editor (agents, projects, environments, routines). One shared grid, an
@@ -55,6 +55,7 @@ const SERVER_INFO_TOGGLE_SELECTOR =
'button[aria-label="Toggle server info debug view experimental setting"]';
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
'button[aria-label="Toggle built-in agents experimental setting"]';
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
const AUTO_RECOVERY_TOGGLE_SELECTOR =
'button[aria-label="Toggle task graph liveness auto-recovery"]';
@@ -187,6 +188,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(warning?.textContent).toContain("no compatibility guarantees");
});
it("enables the Apps UI from experimental settings", async () => {
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(() => toggle?.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true");
});
it("does not render the Conference Room Chat experimental setting for now", async () => {
await renderPage();
@@ -284,6 +284,7 @@ export function InstanceExperimentalSettings() {
);
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableApps = experimentalQuery.data?.enableApps === true;
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
@@ -299,6 +300,7 @@ export function InstanceExperimentalSettings() {
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
const enableCases = experimentalQuery.data?.enableCases === true;
const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true;
const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
@@ -426,6 +428,26 @@ export function InstanceExperimentalSettings() {
</Card>
) : null}
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">Apps</h2>
<Badge variant="secondary">Experimental</Badge>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.
</p>
</div>
<ToggleSwitch
checked={enableApps}
onCheckedChange={() => toggleMutation.mutate({ enableApps: !enableApps })}
disabled={toggleMutation.isPending}
aria-label="Toggle apps experimental setting"
/>
</div>
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
@@ -686,6 +708,25 @@ export function InstanceExperimentalSettings() {
</div>
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Smoke Lab</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Add a "Smoke Lab" tab under Apps → Developer and an "Integration smoke" card on the
dashboard for exercising every integration path against deterministic local fixtures
(fake OAuth provider + loopback MCP servers). Private (non-public) deployments only.
</p>
</div>
<ToggleSwitch
checked={enableSmokeLab}
onCheckedChange={() => toggleMutation.mutate({ enableSmokeLab: !enableSmokeLab })}
disabled={toggleMutation.isPending}
aria-label="Toggle smoke lab experimental setting"
/>
</div>
</Card>
<Card className="block p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
+684
View File
@@ -0,0 +1,684 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
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";
import { AppDetail } from "./AppDetail";
const getConnectionMock = vi.hoisted(() => vi.fn());
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
const listGalleryMock = vi.hoisted(() => vi.fn());
const listCatalogMock = vi.hoisted(() => vi.fn());
const listProfilesMock = vi.hoisted(() => vi.fn());
const listPoliciesMock = vi.hoisted(() => vi.fn());
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
const listActionRequestsMock = vi.hoisted(() => vi.fn());
const updateConnectionMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const refreshCatalogMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "setup" as string | undefined }));
const navigateComponentMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
getConnection: (connectionId: string) => getConnectionMock(connectionId),
getConnectionInstalls: (connectionId: string) => getConnectionInstallsMock(connectionId),
listGallery: (companyId: string) => listGalleryMock(companyId),
listCatalog: (connectionId: string) => listCatalogMock(connectionId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
listPolicies: (companyId: string) => listPoliciesMock(companyId),
listConnectionActivity: (connectionId: string, limit: number) =>
listConnectionActivityMock(connectionId, limit),
listActionRequests: (companyId: string, status: string) =>
listActionRequestsMock(companyId, status),
updateConnection: (connectionId: string, input: unknown) =>
updateConnectionMock(connectionId, input),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
finishAppMock(companyId, connectionId, input),
putConnectionInstalls: (connectionId: string, installs: unknown) =>
putConnectionInstallsMock(connectionId, installs),
archiveConnection: vi.fn(),
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
reconnectConnection: vi.fn(),
},
}));
vi.mock("@/api/agents", () => ({
agentsApi: {
list: vi.fn().mockResolvedValue([
{ id: "agent-1", name: "Coder", title: "Engineer", status: "active" },
]),
},
}));
vi.mock("@/lib/router", () => ({
useParams: () => mockParams,
useNavigate: () => mockNavigate,
useSearchParams: () => [new URLSearchParams(), vi.fn()],
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
navigateComponentMock({ to, replace });
return <div data-navigate-to={to} />;
},
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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<string, unknown> = {}) {
return {
id: "conn-1",
companyId: "company-1",
applicationId: "app-1",
name: "GitHub",
connectionKind: "managed",
transport: "remote_http",
status: "active",
transportConfig: { url: "https://github.example/mcp" },
config: { url: "https://github.example/mcp" },
credentialSecretRefs: [],
credentialRefs: [],
healthStatus: "healthy",
healthCheckedAt: null,
lastError: null,
enabled: true,
lastUsedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function catalogEntry(overrides: Record<string, unknown> = {}) {
return {
id: "catalog-read",
companyId: "company-1",
connectionId: "conn-1",
toolName: "read_repo",
title: "Read repo",
description: "Read repository metadata",
status: "active",
isReadOnly: true,
riskLevel: "read",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("AppDetail", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockParams.connectionId = "conn-1";
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(connection());
getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
listGalleryMock.mockResolvedValue({
apps: [
{
key: "github",
name: "GitHub",
logoUrl: "https://example.com/github.png",
tagline: "GitHub tagline",
description: "Give agents a governed way to inspect repositories and pull requests.",
authKind: "api_key",
transportTemplate: { transport: "remote_http", url: "https://github.example/mcp" },
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
},
],
});
listCatalogMock.mockResolvedValue({
catalog: [
catalogEntry(),
catalogEntry({
id: "catalog-write",
toolName: "write_issue",
title: "Write issue",
description: "Create or update an issue",
isReadOnly: false,
}),
catalogEntry({
id: "catalog-quarantined",
toolName: "delete_repo",
title: "Delete repo",
status: "quarantined",
isReadOnly: false,
}),
],
});
listProfilesMock.mockResolvedValue({
profiles: [
{
profileKey: "app:conn-1",
entries: [
{ effect: "include", catalogEntryId: "catalog-read" },
{ effect: "include", catalogEntryId: "catalog-write" },
],
bindings: [{ targetType: "company" }],
},
],
});
listPoliciesMock.mockResolvedValue({
policies: [
{
policyType: "require_approval",
enabled: true,
config: {
source: "app_gallery_finish",
connectionId: "conn-1",
catalogEntryId: "catalog-write",
},
},
],
});
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
finishAppMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] });
startOAuthMock.mockResolvedValue({
connectionId: "conn-1",
provider: "smoke_lab",
authorizationUrl: "http://example.test/oauth",
expiresAt: "2026-07-10T00:00:00.000Z",
});
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
async function renderAppDetail() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<AppDetail />
</QueryClientProvider>,
);
});
await flushReact();
}
it("pauses the app by flipping the connection enabled flag", async () => {
await renderAppDetail();
const toggle = container.querySelector<HTMLButtonElement>(
'button[role="switch"][aria-label="Pause this app"]',
);
expect(toggle).toBeTruthy();
await act(async () => {
toggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", { enabled: false });
});
it("redirects a missing tab to setup", async () => {
mockParams.tab = undefined;
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/setup", replace: true });
});
it.each([
["setup", "Agents can use this app"],
["review", "1 new action to review"],
["permissions", "Action permissions"],
["activity", "No activity yet."],
["advanced", "Technical details"],
])("renders the %s tab panel", async (tab, expectedText) => {
mockParams.tab = tab;
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("2 actions available");
expect(container.textContent).toContain(expectedText);
});
it("hides secret URL parameters in advanced technical details", async () => {
mockParams.tab = "advanced";
getConnectionMock.mockResolvedValue(
connection({
config: {
url: "https://mcp.zapier.com/api/v1/connect?token=zapier-secret&region=us",
},
}),
);
await renderAppDetail();
expect(container.textContent).toContain(
"https://mcp.zapier.com/api/v1/connect?token=REDACTED&region=us",
);
expect(container.textContent).not.toContain("zapier-secret");
});
it("shows new quarantined actions on the review tab instead of an empty state", async () => {
mockParams.tab = "review";
await renderAppDetail();
expect(container.textContent).toContain("1 new action to review");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Review")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Delete repo");
expect(container.textContent).not.toContain("Nothing is waiting for your OK right now.");
});
it("keeps setup focused on description and lifecycle", async () => {
mockParams.tab = "setup";
await renderAppDetail();
expect(container.textContent).toContain("Give agents a governed way to inspect repositories and pull requests.");
expect(container.textContent).toContain("Agents can use this app");
expect(container.textContent).not.toContain("Read repo");
expect(container.textContent).not.toContain("Action permissions");
});
it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => {
getConnectionMock.mockResolvedValue(connection({
name: "Smoke Lab HTTP MCP fixture",
config: {
smokeLabFixture: "oauth-http",
oauth: {
provider: "smoke_lab",
smokeLabFixture: true,
scopes: ["smoke:openid"],
},
},
}));
await renderAppDetail();
expect(container.textContent).toContain("Connect with Smoke OAuth");
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Connect with Smoke OAuth",
),
).toBe(true);
});
it("lets Google Sheets connections add spreadsheet links from setup", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(connection({
name: "Google Sheets",
transport: "local_stdio",
config: {
templateId: "paperclip.google-sheets",
sourceTemplateKey: "google-sheets",
allowedSpreadsheetIds: ["sheet_existing"],
env: { GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "sheet_existing" },
},
}));
listGalleryMock.mockResolvedValue({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
logoUrl: "https://example.com/sheets.png",
tagline: "Read and update selected spreadsheets.",
description: "Share each sheet with the robot email, then paste the sheet links here.",
authKind: "none",
transportTemplate: { transport: "local_stdio", templateKey: "paperclip.google-sheets" },
credentialFields: [],
recommendedDefaults: {},
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
],
});
await renderAppDetail();
expect(container.textContent).toContain("Sheets agents can use");
expect(container.textContent).toContain("https://docs.google.com/spreadsheets/d/sheet_existing/edit");
expect(container.textContent).toContain("sheet_existing");
const input = container.querySelector<HTMLInputElement>(
'input[placeholder="https://docs.google.com/spreadsheets/d/..."]',
);
expect(input).toBeTruthy();
await act(async () => setInputValue(input!, "https://docs.google.com/spreadsheets/d/sheet_new/edit"));
await flushReact();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Add sheet")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", {
config: expect.objectContaining({
allowedSpreadsheetIds: ["sheet_existing", "sheet_new"],
env: expect.objectContaining({ GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "sheet_existing,sheet_new" }),
}),
transportConfig: { url: "https://github.example/mcp" },
});
});
it("renders unified action permission dropdowns in the permissions tab", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
expect(container.textContent).toContain("Read only");
expect(container.textContent).toContain("Can make changes");
expect(container.textContent).toContain("Read repo");
expect(container.textContent).toContain("Write issue");
expect(container.textContent).toContain("1 new action to review");
const readSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Read repo permission"]');
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
expect(readSelect?.value).toBe("allowed");
expect(writeSelect?.value).toBe("ask");
});
it("persists ask-first for read-only actions from the unified dropdown", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
const readSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Read repo permission"]');
expect(readSelect).toBeTruthy();
await act(async () => {
readSelect!.value = "ask";
readSelect!.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]),
askFirstCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]),
access: "all_agents",
});
});
it("persists off by removing an action from enabled and ask-first sets", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
expect(writeSelect).toBeTruthy();
await act(async () => {
writeSelect!.value = "off";
writeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["catalog-read"],
askFirstCatalogEntryIds: [],
access: "all_agents",
});
});
it("persists installed agents from the permissions tab", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
expect(container.textContent).toContain("Installed on agents");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Choose agents to install on"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const coderCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Coder"]');
expect(coderCheckbox).toBeTruthy();
await act(async () => {
coderCheckbox!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
{ targetType: "agent", targetId: "agent-1" },
]);
});
it("renders activity attribution with issue context and human resolver names", async () => {
mockParams.tab = "activity";
listConnectionActivityMock.mockResolvedValue({
events: [
{
id: "evt-1",
eventType: "call_completed",
agentId: "agent-1",
issueId: "issue-1",
actionRequestId: null,
toolName: "Get value",
outcome: "success",
createdAt: new Date("2026-06-12T10:00:00Z"),
},
{
id: "evt-2",
eventType: "approval_resolved",
agentId: "agent-1",
issueId: "issue-1",
actionRequestId: "request-1",
toolName: "Mark done",
outcome: "success",
createdAt: new Date("2026-06-12T10:01:00Z"),
},
],
issues: {
"issue-1": { identifier: "PAP-10912", title: "Fix app connection copy" },
},
actionRequests: {
"request-1": {
status: "approved",
resolverDisplayName: "Dotta",
resolvedByAgentId: null,
resolvedByUserId: "board-user",
},
},
});
await renderAppDetail();
expect(container.textContent).toContain("Coder used Get value");
expect(container.textContent).toContain("while working on PAP-10912");
expect(container.textContent).toContain("Dotta approved Mark done");
expect(container.querySelector('a[href="/issues/PAP-10912"]')).toBeTruthy();
expect(container.textContent).not.toContain("You reviewed");
});
it("humanizes the raw gateway-prefixed tool name in activity", async () => {
mockParams.tab = "activity";
listConnectionActivityMock.mockResolvedValue({
events: [
{
id: "evt-1",
eventType: "call_completed",
agentId: "agent-1",
issueId: null,
actionRequestId: null,
toolName: "mcp.app-gallery-link-ccad39e8-6798a369:kv-set",
outcome: "success",
createdAt: new Date("2026-06-12T10:00:00Z"),
},
],
issues: {},
actionRequests: {},
});
await renderAppDetail();
expect(container.textContent).toContain("Coder used Kv Set");
expect(container.textContent).not.toContain("mcp.app-gallery-link");
});
it("renders connection lifecycle events humanized on the timeline", async () => {
mockParams.tab = "activity";
listConnectionActivityMock.mockResolvedValue({
events: [
{
id: "evt-1",
eventType: "call_completed",
agentId: "agent-1",
issueId: null,
actionRequestId: null,
toolName: "Get value",
outcome: "success",
createdAt: new Date("2026-06-12T10:30:00Z"),
},
],
lifecycleEvents: [
{
id: "life-connected",
connectionId: "conn-1",
type: "app_connected",
actorType: "user",
actorId: "board-user",
agentId: null,
actorDisplayName: "Dotta",
details: null,
createdAt: new Date("2026-06-12T09:00:00Z"),
},
{
id: "life-paused",
connectionId: "conn-1",
type: "app_paused",
actorType: "user",
actorId: "board-user",
agentId: null,
actorDisplayName: "Dotta",
details: { enabled: false },
createdAt: new Date("2026-06-12T11:00:00Z"),
},
{
id: "life-allowlist",
connectionId: "conn-1",
type: "allowlist_changed",
actorType: "user",
actorId: "board-user",
agentId: null,
actorDisplayName: "Dotta",
details: { added: 1, removed: 0, total: 2 },
createdAt: new Date("2026-06-12T10:45:00Z"),
},
{
id: "life-quarantine",
connectionId: "conn-1",
type: "actions_quarantined",
actorType: "system",
actorId: null,
agentId: null,
actorDisplayName: null,
details: { count: 2 },
createdAt: new Date("2026-06-12T10:50:00Z"),
},
],
issues: {},
actionRequests: {},
});
await renderAppDetail();
expect(container.textContent).toContain("Dotta connected GitHub");
expect(container.textContent).toContain("Dotta paused this app");
expect(container.textContent).toContain("Dotta added 1 sheet to the allowlist");
expect(container.textContent).toContain("2 new actions need review");
// Lifecycle rows deep-link to the Setup tab; quarantine uses the review label.
expect(container.querySelector('a[href="/apps/conn-1/setup"]')).toBeTruthy();
expect(container.textContent).toContain("Review in Setup");
// Merged timeline: the newest event (the pause at 11:00) renders before the
// tool call at 10:30, which renders before the connect at 09:00.
const pausedAt = container.textContent?.indexOf("Dotta paused this app") ?? -1;
const usedAt = container.textContent?.indexOf("Coder used Get value") ?? -1;
const connectedAt = container.textContent?.indexOf("Dotta connected GitHub") ?? -1;
expect(pausedAt).toBeGreaterThanOrEqual(0);
expect(pausedAt).toBeLessThan(usedAt);
expect(usedAt).toBeLessThan(connectedAt);
});
it("keeps the header and reconnect banner across tabs", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
healthStatus: "degraded",
healthMessage: "Token expired.",
}));
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("Needs attention");
expect(container.textContent).toContain("This app needs reconnecting");
expect(container.textContent).toContain("Token expired.");
expect(container.textContent).toContain("Who can use it");
});
});
+639
View File
@@ -0,0 +1,639 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Pencil } from "lucide-react";
import type {
AppGalleryEntry,
ToolConnection,
ToolPolicy,
ToolProfileWithDetails,
} from "@paperclipai/shared";
import {
connectionDisplaySecondaryHint,
humanizeConnectionDisplayName,
isToolConnectionAttentionHealth as isAttentionHealthStatus,
} from "@paperclipai/shared";
import { Navigate, useParams, useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { authApi } from "@/api/auth";
import { buildCompanyUserLabelMap } from "@/lib/company-members";
import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { AppLogo } from "./AppLogo";
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
import { SetupPanel } from "./app-detail/SetupPanel";
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
import { TestPanel } from "./app-detail/TestPanel";
import { ReviewPanel } from "./app-detail/ReviewPanel";
import { ActivityPanel } from "./app-detail/ActivityPanel";
import {
AdvancedPanel,
ReconnectCard,
DangerZone,
connectionAddress,
connectionTransportLabel,
} from "./app-detail/AdvancedPanel";
import type { AccessDraft } from "./app-detail/types";
export { DangerZone, connectionAddress, connectionTransportLabel };
export function AppDetail() {
const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
const connectionQuery = useQuery({
queryKey: queryKeys.tools.connection(connectionId),
queryFn: () => toolsApi.getConnection(connectionId),
enabled: !!connectionId && !!activeTab,
});
const installsQuery = useQuery({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
enabled: !!connectionId && activeTab === "permissions",
});
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const catalogQuery = useQuery({
queryKey: queryKeys.tools.catalog(connectionId),
queryFn: () => toolsApi.listCatalog(connectionId),
enabled: !!connectionId && !!activeTab,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const activityQuery = useQuery({
queryKey: queryKeys.tools.connectionActivity(connectionId),
queryFn: () => toolsApi.listConnectionActivity(connectionId, 20),
enabled: !!connectionId && activeTab === "activity",
});
// Resolve who ran Test-tab calls ("<User> tested as <Agent>") in the Activity feed (PAP-11415).
const userDirectoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId && activeTab === "activity",
});
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: activeTab === "activity",
});
const connection = connectionQuery.data;
const appName = connection ? humanizeConnectionDisplayName(connection) : "App";
useEffect(() => {
if (!activeTab) return;
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: appName, href: appTabHref(connectionId, "setup") },
{ label: appTabLabel(activeTab) },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name, appName, connectionId, activeTab]);
const catalog = catalogQuery.data?.catalog ?? [];
const profile = useMemo(
() => (profilesQuery.data?.profiles ?? []).find((p) => p.profileKey === `app:${connectionId}`),
[profilesQuery.data, connectionId],
);
const enabledIds = useMemo(() => enabledCatalogIds(profile), [profile]);
const askFirstIds = useMemo(
() => askFirstCatalogIds(policiesQuery.data?.policies ?? [], connectionId),
[policiesQuery.data, connectionId],
);
const access = useMemo(() => accessFrom(profile), [profile]);
const install = useMemo(
() => installStateFrom(installsQuery.data?.installs ?? connection?.installs),
[connection?.installs, installsQuery.data?.installs],
);
const agents = agentsQuery.data ?? [];
const userLabelById = useMemo(() => {
const labels = buildCompanyUserLabelMap(userDirectoryQuery.data?.users);
const session = sessionQuery.data;
// Prefer the viewer's own profile name for their own test runs ("Dotta", not a fallback).
if (session?.user?.id && session.user.name?.trim()) {
labels.set(session.user.id, session.user.name.trim());
}
return labels;
}, [userDirectoryQuery.data, sessionQuery.data]);
const logoEntry = useMemo(
() => galleryEntryFor(galleryQuery.data?.apps ?? [], connection),
[galleryQuery.data, connection],
);
const [pending, setPending] = useState(false);
const persist = useMutation({
mutationFn: (next: { enabled: Set<string>; askFirst: Set<string>; access: AccessDraft }) =>
toolsApi.finishApp(selectedCompanyId!, connectionId, {
enabledCatalogEntryIds: [...next.enabled],
askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)),
access: next.access.mode === "all" ? "all_agents" : { agentIds: [...next.access.agentIds] },
}),
onMutate: () => setPending(true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't save that",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setPending(false),
});
const persistInstall = useMutation({
mutationFn: (next: InstallState) =>
toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, next)),
onSuccess: (snapshot) => {
queryClient.setQueryData(queryKeys.tools.connectionInstalls(connectionId), snapshot);
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't save installs",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const [renaming, setRenaming] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const rename = useMutation({
mutationFn: (name: string) => toolsApi.updateConnection(connectionId, { name }),
onSuccess: () => {
setRenaming(false);
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't rename the app",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const updateConfig = useMutation({
mutationFn: (config: Record<string, unknown>) => toolsApi.updateConnection(connectionId, {
config,
transportConfig: connection?.transportConfig ?? {},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't save that",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const startOAuth = useMutation({
mutationFn: () => toolsApi.startOAuth(connectionId),
onSuccess: ({ authorizationUrl }) => {
window.location.assign(authorizationUrl);
},
onError: (error) =>
pushToast({
title: "Couldn't start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const removeApp = useMutation({
mutationFn: () => toolsApi.archiveConnection(connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: "App removed",
body: `${appName} no longer has access. You can connect it again any time.`,
tone: "success",
});
navigate("/apps");
},
onError: (error) =>
pushToast({
title: "Couldn't remove the app",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const toggleEnabled = useMutation({
mutationFn: () => toolsApi.updateConnection(connectionId, { enabled: !connection?.enabled }),
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: updated.enabled ? "App resumed" : "App paused",
body: updated.enabled
? `${humanizeConnectionDisplayName(updated)} is available to agents again.`
: `${humanizeConnectionDisplayName(updated)} is paused for agents.`,
tone: "success",
});
},
onError: (error) =>
pushToast({
title: "Couldn't update the app",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const refreshTools = useMutation({
mutationFn: () => toolsApi.refreshCatalog(connectionId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
pushToast({
title: `Found ${result.discoveredCount} ${result.discoveredCount === 1 ? "action" : "actions"}`,
body: result.quarantinedCount > 0
? `${result.quarantinedCount} new ${result.quarantinedCount === 1 ? "action needs" : "actions need"} your OK.`
: undefined,
tone: "success",
});
},
onError: (error) =>
pushToast({
title: "Couldn't refresh actions",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const apply = (mutate: { enabled?: Set<string>; askFirst?: Set<string>; access?: AccessDraft }) =>
persist.mutate({
enabled: mutate.enabled ?? new Set(enabledIds),
askFirst: mutate.askFirst ?? new Set(askFirstIds),
access: mutate.access ?? access,
});
if (!connectionId || !activeTab) {
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps"} />;
}
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
}
if (connectionQuery.isLoading || catalogQuery.isLoading) {
return (
<div className="max-w-3xl space-y-4">
<Skeleton className="h-10 w-56" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-40 w-full" />
</div>
);
}
if (!connection) {
return (
<div className="max-w-3xl p-6">
<p className="text-sm text-muted-foreground">We couldn't find that app.</p>
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps")}>
Back to apps
</Button>
</div>
);
}
const status = statusFor(connection);
const needsReconnect = status.tone === "attention" && connection.healthStatus !== "unknown";
const quarantined = catalog.filter((e) => e.status === "quarantined");
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
const readOnly = active.filter((e) => e.isReadOnly);
const canChange = active.filter((e) => !e.isReadOnly);
const actionCount = active.length;
return (
<div className="max-w-3xl space-y-6 pb-12">
<AppDetailHeader
appName={appName}
connection={connection}
logoEntry={logoEntry}
status={status}
actionCount={actionCount}
renaming={renaming}
nameDraft={nameDraft}
renamePending={rename.isPending}
onNameDraftChange={setNameDraft}
onRenameStart={() => {
setNameDraft(appName);
setRenaming(true);
}}
onRenameCancel={() => setRenaming(false)}
onRenameSubmit={(next) => {
if (next && next !== appName) rename.mutate(next);
else setRenaming(false);
}}
/>
{needsReconnect && (
<ReconnectCard
connection={connection}
galleryEntry={logoEntry}
onReconnected={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
}}
/>
)}
{activeTab === "setup" && (
<SetupPanel
connection={connection}
galleryEntry={logoEntry}
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
onToggleApp={() => toggleEnabled.mutate()}
configUpdateDisabled={updateConfig.isPending}
onUpdateConfig={(config) => updateConfig.mutate(config)}
oauthStartDisabled={startOAuth.isPending}
onStartOAuth={() => startOAuth.mutate()}
/>
)}
{activeTab === "review" && (
<ReviewPanel
connectionId={connectionId}
quarantined={quarantined}
pending={pending}
onTurnOnQuarantined={(ids) => apply({ enabled: addAll(new Set(enabledIds), ids) })}
/>
)}
{activeTab === "permissions" && (
<PermissionsPanel
appName={appName}
access={access}
agents={agents}
install={install}
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
pending={pending}
installPending={persistInstall.isPending || installsQuery.isLoading}
refreshPending={refreshTools.isPending}
onSaveAccess={(next) => apply({ access: next })}
onSaveInstall={(next) => persistInstall.mutate(next)}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
onTurnOnQuarantined={(ids) => apply({ enabled: addAll(new Set(enabledIds), ids) })}
/>
)}
{activeTab === "test" && (
<TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
)}
{activeTab === "activity" && (
<ActivityPanel
events={activityQuery.data?.events ?? []}
lifecycleEvents={activityQuery.data?.lifecycleEvents ?? []}
issues={activityQuery.data?.issues ?? {}}
actionRequests={activityQuery.data?.actionRequests ?? {}}
loading={activityQuery.isLoading}
agents={agents}
connectionId={connectionId}
appName={appName}
userLabelById={userLabelById}
/>
)}
{activeTab === "advanced" && (
<AdvancedPanel
connection={connection}
appName={appName}
galleryEntry={logoEntry}
removing={removeApp.isPending}
onRemove={() => removeApp.mutate()}
onReplaced={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
}}
/>
)}
</div>
);
}
function AppDetailHeader({
appName,
connection,
logoEntry,
status,
actionCount,
renaming,
nameDraft,
renamePending,
onNameDraftChange,
onRenameStart,
onRenameCancel,
onRenameSubmit,
}: {
appName: string;
connection: ToolConnection;
logoEntry: AppGalleryEntry | null;
status: StatusInfo;
actionCount: number;
renaming: boolean;
nameDraft: string;
renamePending: boolean;
onNameDraftChange: (value: string) => void;
onRenameStart: () => void;
onRenameCancel: () => void;
onRenameSubmit: (value: string) => void;
}) {
return (
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={44} />
<div>
{renaming ? (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
onRenameSubmit(nameDraft.trim());
}}
>
<Input
aria-label="App name"
value={nameDraft}
onChange={(event) => onNameDraftChange(event.target.value)}
className="h-9 w-64 text-lg font-bold"
autoFocus
/>
<Button type="submit" size="sm" disabled={renamePending || !nameDraft.trim()}>
{renamePending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onRenameCancel} disabled={renamePending}>
Cancel
</Button>
</form>
) : (
<div className="flex items-center gap-1.5">
<h1 className="text-2xl font-bold tracking-tight">{appName}</h1>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
aria-label="Rename app"
onClick={onRenameStart}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
)}
{connectionDisplaySecondaryHint(connection) && (
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
)}
<div className="mt-1 flex items-center gap-2">
<StatusBadge status={status} />
<span className="text-xs text-muted-foreground">
{actionCount} {actionCount === 1 ? "action" : "actions"} available
</span>
</div>
</div>
</div>
</header>
);
}
type StatusInfo = { label: string; tone: "connected" | "attention" | "paused" };
function statusFor(connection: ToolConnection): StatusInfo {
if (connection.enabled === false || connection.status === "disabled") {
return { label: "Paused", tone: "paused" };
}
if (isAttentionHealthStatus(connection.healthStatus)) {
return { label: "Needs attention", tone: "attention" };
}
return { label: "Connected", tone: "connected" };
}
function StatusBadge({ status }: { status: StatusInfo }) {
const klass: Record<StatusInfo["tone"], string> = {
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
paused: "border-border bg-muted text-muted-foreground",
};
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
klass[status.tone],
)}
>
{status.tone === "connected" && <Check className="h-3 w-3" />}
{status.label}
</span>
);
}
function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set<string> {
const ids = new Set<string>();
for (const entry of profile?.entries ?? []) {
if (entry.effect === "include" && entry.catalogEntryId) ids.add(entry.catalogEntryId);
}
return ids;
}
function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<string> {
const ids = new Set<string>();
for (const policy of policies) {
if (policy.policyType !== "require_approval" || policy.enabled === false) continue;
const config = (policy.config ?? {}) as { source?: unknown; connectionId?: unknown; catalogEntryId?: unknown };
if (config.source === "app_gallery_finish" && config.connectionId === connectionId && typeof config.catalogEntryId === "string") {
ids.add(config.catalogEntryId);
}
}
return ids;
}
function accessFrom(profile: ToolProfileWithDetails | undefined): AccessDraft {
const bindings = profile?.bindings ?? [];
if (bindings.some((b) => b.targetType === "company")) {
return { mode: "all", agentIds: new Set() };
}
const agentIds = new Set(bindings.filter((b) => b.targetType === "agent").map((b) => b.targetId));
if (agentIds.size === 0) return { mode: "all", agentIds: new Set() };
return { mode: "specific", agentIds };
}
function galleryEntryFor(apps: AppGalleryEntry[], connection: ToolConnection | undefined): AppGalleryEntry | null {
if (!connection) return null;
const name = connection.name.toLowerCase();
return apps.find((a) => a.name.toLowerCase() === name) ?? apps.find((a) => a.key === name) ?? null;
}
function addAll(set: Set<string>, ids: string[]): Set<string> {
const next = new Set(set);
for (const id of ids) next.add(id);
return next;
}
function actionPermissionMutation(
id: string,
next: "off" | "allowed" | "ask",
enabledIds: Set<string>,
askFirstIds: Set<string>,
) {
const enabled = new Set(enabledIds);
const askFirst = new Set(askFirstIds);
if (next === "off") {
enabled.delete(id);
askFirst.delete(id);
} else {
enabled.add(id);
if (next === "ask") askFirst.add(id);
else askFirst.delete(id);
}
return { enabled, askFirst };
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
const TILE_COLORS = [
"bg-(--app-logo-tile-1)",
"bg-(--app-logo-tile-2)",
"bg-(--app-logo-tile-3)",
"bg-(--app-logo-tile-4)",
"bg-(--app-logo-tile-5)",
"bg-(--app-logo-tile-6)",
"bg-(--app-logo-tile-7)",
"bg-(--app-logo-tile-8)",
];
function colorFor(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
return TILE_COLORS[hash % TILE_COLORS.length]!;
}
interface AppLogoProps {
name: string;
logoUrl?: string | null;
size?: number;
className?: string;
}
/**
* App icon for the gallery and connected-apps surfaces. Renders the manifest
* favicon when available, falling back to a coloured letter tile (deterministic
* colour per app name) when the image is missing or fails to load.
*/
export function AppLogo({ name, logoUrl, size = 36, className }: AppLogoProps) {
const [failed, setFailed] = useState(false);
const letter = (name.trim()[0] ?? "?").toUpperCase();
const dimension = { width: size, height: size };
if (logoUrl && !failed) {
return (
<span
className={cn("inline-flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted", className)}
style={dimension}
>
<img
src={logoUrl}
alt=""
width={size}
height={size}
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-lg font-bold text-white",
colorFor(name),
className,
)}
style={{ ...dimension, fontSize: Math.round(size * 0.42) }}
aria-hidden="true"
>
{letter}
</span>
);
}
+223
View File
@@ -0,0 +1,223 @@
// @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 { AppNotConnected } from "./AppNotConnected";
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listGalleryMock = vi.hoisted(() => vi.fn());
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
const listActionRequestsMock = vi.hoisted(() => vi.fn());
const updateApplicationMock = vi.hoisted(() => vi.fn());
const mockAgentsList = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const navigateComponentMock = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({ applicationId: "app-1", tab: "setup" as string | undefined }));
vi.mock("@/api/tools", () => ({
toolsApi: {
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listGallery: (companyId: string) => listGalleryMock(companyId),
listConnectionActivity: (connectionId: string, limit: number) =>
listConnectionActivityMock(connectionId, limit),
listActionRequests: (companyId: string, status: string) =>
listActionRequestsMock(companyId, status),
updateApplication: (applicationId: string, input: unknown) =>
updateApplicationMock(applicationId, input),
approveActionRequest: vi.fn(),
declineActionRequest: vi.fn(),
createTrustRuleFromActionRequest: vi.fn(),
},
}));
vi.mock("@/api/agents", () => ({
agentsApi: {
list: (companyId: string) => mockAgentsList(companyId),
},
}));
vi.mock("@/lib/router", () => ({
useParams: () => mockParams,
useNavigate: () => mockNavigate,
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
navigateComponentMock({ to, replace });
return <div data-navigate-to={to} />;
},
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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 application(overrides: Record<string, unknown> = {}) {
return {
id: "app-1",
companyId: "company-1",
applicationKey: "github",
name: "GitHub",
description: "Repository app",
type: "mcp_http",
status: "active",
pluginId: null,
ownerAgentId: null,
ownerUserId: null,
metadata: null,
archivedAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function connection(overrides: Record<string, unknown> = {}) {
return {
id: "conn-old",
companyId: "company-1",
applicationId: "app-1",
name: "GitHub",
connectionKind: "managed",
transport: "remote_http",
status: "archived",
transportConfig: { url: "https://github.example/mcp" },
config: { url: "https://github.example/mcp" },
credentialSecretRefs: [],
credentialRefs: [],
healthStatus: "error",
healthMessage: "Token expired.",
healthCheckedAt: null,
lastError: null,
enabled: true,
lastUsedAt: new Date("2026-06-10T00:00:00Z"),
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-06-11T00:00:00Z"),
...overrides,
};
}
describe("AppNotConnected", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockParams.applicationId = "app-1";
mockParams.tab = "setup";
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
listGalleryMock.mockResolvedValue({
apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }],
});
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
mockAgentsList.mockResolvedValue([]);
updateApplicationMock.mockResolvedValue(application({ status: "archived" }));
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<AppNotConnected />
</QueryClientProvider>,
);
});
await flushReact();
}
it("redirects the application root route to setup", async () => {
mockParams.tab = undefined;
await renderPage();
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/app/app-1/setup", replace: true });
expect(listApplicationsMock).not.toHaveBeenCalled();
});
it("redirects to the connected app tab when a live connection exists", async () => {
mockParams.tab = "permissions";
listConnectionsMock.mockResolvedValue({
connections: [connection({ id: "conn-live", status: "active" })],
});
await renderPage();
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-live/permissions", replace: true });
});
it.each([
["setup", "Reconnect this app"],
["review", "Nothing is waiting for your OK right now."],
["permissions", "Permissions paused"],
["test", "Reconnect to test this app."],
["activity", "No activity yet."],
["advanced", "Danger zone"],
])("renders the %s tab with persistent app identity", async (tab, expectedText) => {
mockParams.tab = tab;
await renderPage();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("Not connected");
expect(container.textContent).toContain(expectedText);
});
it("keeps previous setup context on reconnect tabs", async () => {
mockParams.tab = "setup";
await renderPage();
expect(container.textContent).toContain("Previous setup");
expect(container.textContent).toContain("Last error: Token expired.");
expect(container.textContent).toContain("https://github.example/mcp");
});
});
+371
View File
@@ -0,0 +1,371 @@
import { useEffect, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ToolConnection } from "@paperclipai/shared";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { timeAgo } from "@/lib/timeAgo";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { AppLogo } from "./AppLogo";
import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail";
import { ActivityPanel } from "./app-detail/ActivityPanel";
import { ReviewPanel } from "./app-detail/ReviewPanel";
import { appApplicationTabHref, appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
export function AppNotConnected() {
const { applicationId = "", tab } = useParams<{ applicationId: string; tab?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId && !!activeTab,
});
const application = useMemo(
() => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId),
[applicationsQuery.data, applicationId],
);
const appConnections = useMemo(
() => (connectionsQuery.data?.connections ?? []).filter((c) => c.applicationId === applicationId),
[connectionsQuery.data, applicationId],
);
const activeConnection = appConnections.find((c) => c.status !== "archived") ?? null;
const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]);
const activityQuery = useQuery({
queryKey: queryKeys.tools.connectionActivity(previousConnection?.id ?? "__none__"),
queryFn: () => toolsApi.listConnectionActivity(previousConnection!.id, 20),
enabled: !!previousConnection && activeTab === "activity",
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && activeTab === "activity",
});
const appName = application?.name ?? "App";
useEffect(() => {
if (!activeTab) return;
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: appName, href: appApplicationTabHref(applicationId, "setup") },
{ label: appTabLabel(activeTab) },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name, appName, applicationId, activeTab]);
const remove = useMutation({
mutationFn: () => toolsApi.updateApplication(applicationId, { status: "archived" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__") });
pushToast({
title: "App removed",
body: `${appName} no longer shows in your apps. You can connect it again any time.`,
tone: "success",
});
navigate("/apps");
},
onError: (error) => {
pushToast({
title: "Couldn’t remove the app",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
}
if (!applicationId || !activeTab) {
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps"} replace />;
}
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
return (
<div className="max-w-3xl space-y-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}
if (!application) {
return (
<div className="max-w-3xl space-y-3 p-6 text-sm text-muted-foreground">
<p>This app doesn’t exist anymore.</p>
<Button variant="outline" size="sm" onClick={() => navigate("/apps")}>Back to apps</Button>
</div>
);
}
if (activeConnection) {
return <Navigate to={appTabHref(activeConnection.id, activeTab)} replace />;
}
const gallery = galleryQuery.data?.apps ?? [];
const logoUrl =
(application.applicationKey ? gallery.find((entry) => entry.key === application.applicationKey)?.logoUrl : undefined) ??
gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.logoUrl;
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
const connectHref = reconnectHref({
applicationId,
appName: application.name,
previousAddress,
});
return (
<div className="max-w-3xl space-y-6 pb-12">
<ApplicationHeader applicationName={application.name} description={application.description} logoUrl={logoUrl} />
{activeTab === "setup" && (
<SetupTab
previousConnection={previousConnection}
previousAddress={previousAddress}
onConnect={() => navigate(connectHref)}
/>
)}
{activeTab === "review" && (
previousConnection ? (
<ReviewPanel connectionId={previousConnection.id} />
) : (
<EmptyTab
title="Nothing is waiting for your OK right now."
body="Review requests will appear here after this app is connected."
/>
)
)}
{activeTab === "permissions" && (
<PermissionsTab previousConnection={previousConnection} />
)}
{activeTab === "test" && (
<EmptyTab
title="Reconnect to test this app."
body="Testing becomes available after this app is connected again."
/>
)}
{activeTab === "activity" && (
previousConnection ? (
<ActivityPanel
events={activityQuery.data?.events ?? []}
lifecycleEvents={activityQuery.data?.lifecycleEvents ?? []}
issues={activityQuery.data?.issues ?? {}}
actionRequests={activityQuery.data?.actionRequests ?? {}}
loading={activityQuery.isLoading}
agents={agentsQuery.data ?? []}
connectionId={previousConnection.id}
appName={appName}
/>
) : (
<ActivityPanel
events={[]}
lifecycleEvents={[]}
issues={{}}
actionRequests={{}}
loading={false}
agents={[]}
connectionId=""
appName={appName}
/>
)
)}
{activeTab === "advanced" && (
<AdvancedTab
appName={application.name}
previousConnection={previousConnection}
previousAddress={previousAddress}
removing={remove.isPending}
onRemove={() => remove.mutate()}
/>
)}
</div>
);
}
function ApplicationHeader({
applicationName,
description,
logoUrl,
}: {
applicationName: string;
description: string | null;
logoUrl: string | undefined;
}) {
return (
<header className="flex flex-wrap items-center gap-4">
<AppLogo name={applicationName} logoUrl={logoUrl} size={48} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="truncate text-2xl font-bold tracking-tight">{applicationName}</h1>
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
Not connected
</span>
</div>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
</header>
);
}
function SetupTab({
previousConnection,
previousAddress,
onConnect,
}: {
previousConnection: ToolConnection | null;
previousAddress: string | null;
onConnect: () => void;
}) {
return (
<div className="space-y-6">
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-foreground">
{previousConnection ? "Reconnect this app" : "Connect this app"}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{previousConnection
? "We kept the previous setup. Add a working key to bring it back online."
: "Agents can't use it until it's connected."}
</p>
</div>
<Button onClick={onConnect}>
{previousConnection ? "Reconnect" : "Connect"}
</Button>
</div>
</section>
{previousConnection && (
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
)}
</div>
);
}
function PreviousSetup({
connection,
previousAddress,
}: {
connection: ToolConnection;
previousAddress: string | null;
}) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<h2 className="text-sm font-bold text-foreground">Previous setup</h2>
{connection.healthMessage && (
<p className="mt-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Last error: {connection.healthMessage}
</p>
)}
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
<dt className="text-muted-foreground">Address</dt>
<dd className="break-all font-mono text-foreground">{previousAddress}</dd>
<dt className="text-muted-foreground">Connection type</dt>
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
<dt className="text-muted-foreground">Last used</dt>
<dd className="text-foreground">
{connection.lastUsedAt ? timeAgo(connection.lastUsedAt) : "Never"}
</dd>
</dl>
</section>
);
}
function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<h2 className="text-sm font-bold text-foreground">Permissions paused</h2>
<p className="mt-1 text-sm text-muted-foreground">
Reconnect this app to edit who can use it and which actions need a human first.
</p>
{previousConnection && (
<p className="mt-3 text-xs text-muted-foreground">
Previous setup is retained for reconnect, but access controls stay read-only until the app is online.
</p>
)}
</section>
);
}
function AdvancedTab({
appName,
previousConnection,
previousAddress,
removing,
onRemove,
}: {
appName: string;
previousConnection: ToolConnection | null;
previousAddress: string | null;
removing: boolean;
onRemove: () => void;
}) {
return (
<div className="space-y-6">
{previousConnection ? (
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
) : (
<EmptyTab
title="No previous connection details"
body="Technical details will appear here after this app is connected."
/>
)}
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
</div>
);
}
function EmptyTab({ title, body }: { title: string; body: string }) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<h2 className="text-sm font-bold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
</section>
);
}
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
const archived = connections.filter((c) => c.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;
});
}
function reconnectHref({
applicationId,
appName,
previousAddress,
}: {
applicationId: string;
appName: string;
previousAddress: string | null;
}): string {
const params = new URLSearchParams({ byo: "1", applicationId, name: appName });
if (previousAddress && /^https?:\/\//i.test(previousAddress)) params.set("link", previousAddress);
return `/apps/connect?${params.toString()}`;
}
+700
View File
@@ -0,0 +1,700 @@
// @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 { AppsConnect } from "./AppsConnect";
const listGalleryMock = vi.hoisted(() => vi.fn());
const connectAppMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSearch = vi.hoisted(() => ({ value: "" }));
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
vi.mock("@/api/tools", () => ({
toolsApi: {
listGallery: (companyId: string) => listGalleryMock(companyId),
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
finishAppMock(companyId, connectionId, input),
putConnectionInstalls: (connectionId: string, installs: unknown) =>
putConnectionInstallsMock(connectionId, installs),
},
}));
vi.mock("@/api/agents", () => ({
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => mockNavigate,
useParams: () => mockParams,
useSearchParams: () => [new URLSearchParams(mockSearch.value), vi.fn()],
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
function buttonByText(text: string): HTMLButtonElement | undefined {
return Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === text,
) as HTMLButtonElement | undefined;
}
function buttonContaining(text: string): HTMLButtonElement | undefined {
return Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent?.includes(text),
) as HTMLButtonElement | undefined;
}
async function gotoLinkFrame(container: HTMLDivElement, url: string) {
const linkInput = Array.from(
container.querySelectorAll<HTMLInputElement>("input"),
).find((i) => i.getAttribute("placeholder")?.startsWith("https://"));
expect(linkInput).toBeTruthy();
await act(async () => setInputValue(linkInput!, url));
await flushReact();
await act(async () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
describe("AppsConnect — Connect with a link (M4 frame)", () => {
let container: HTMLDivElement;
beforeEach(() => {
mockSearch.value = "";
mockParams.appKey = undefined;
container = document.createElement("div");
document.body.appendChild(container);
listGalleryMock.mockResolvedValue({
apps: [
{
key: "zapier",
name: "Zapier",
tagline: "Automate things",
authKind: "api_key",
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
logoUrl: null,
credentialFields: [{ configPath: "credentials.authorization", label: "API key", required: true }],
},
],
});
finishAppMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
connectAppMock.mockResolvedValue({
connectionId: "conn-1",
application: { id: "app-1", name: "example.com" },
actions: { readOnly: [], canMakeChanges: [] },
catalog: [],
suggestedDefaults: {},
});
listAgentsMock.mockResolvedValue([
{ id: "agent-1", name: "Ada", title: "CTO", status: "active", icon: "Bot" },
{ id: "agent-2", name: "Grace", title: "Engineer", status: "active", icon: "Code" },
]);
});
afterEach(() => {
document.body.removeChild(container);
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render() {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<AppsConnect />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
return root;
}
it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => {
await render();
await gotoLinkFrame(container, "https://www.example.com/actions");
expect(container.textContent).toContain("Connect with a link");
expect(container.textContent).toContain("https://www.example.com/actions");
expect(container.textContent).toContain("Does it need a key?");
expect(buttonByText("No")).toBeTruthy();
expect(buttonByText("Yes")).toBeTruthy();
// Name is auto-filled from the host with www. stripped.
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
(i) => i.getAttribute("placeholder") === "My app",
);
expect(nameInput?.value).toBe("example.com");
});
it("opens the selected app directly on its setup route", async () => {
mockParams.appKey = "zapier";
await render();
expect(container.textContent).toContain("Connect Zapier");
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
});
it("choosing No and clicking Check link connects with no credentials", async () => {
await render();
await gotoLinkFrame(container, "https://www.example.com/actions");
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({ link: "https://www.example.com/actions", name: "example.com" });
expect(input.credentialValues).toBeUndefined();
});
it("choosing Yes reveals one masked key field plus the lock reassurance", async () => {
await render();
await gotoLinkFrame(container, "https://www.example.com/actions");
// No key field while No is selected.
expect(
Array.from(container.querySelectorAll<HTMLInputElement>("input")).some(
(i) => i.type === "password",
),
).toBe(false);
await act(async () => {
buttonByText("Yes")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const passwordInputs = Array.from(
container.querySelectorAll<HTMLInputElement>("input"),
).filter((i) => i.type === "password");
expect(passwordInputs).toHaveLength(1);
expect(container.textContent).toContain("Your key is stored securely.");
await act(async () => setInputValue(passwordInputs[0], "secret-key"));
await flushReact();
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
const [, input] = connectAppMock.mock.calls[0];
expect(input.credentialValues).toEqual({ "credentials.authorization": "secret-key" });
});
it("a Zapier MCP URL stays in the URL flow and includes its token in the submitted link", async () => {
await render();
const linkInput = Array.from(
container.querySelectorAll<HTMLInputElement>("input"),
).find((i) => i.getAttribute("placeholder")?.startsWith("https://"));
const zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=secret-token";
await act(async () => setInputValue(linkInput!, zapierUrl));
await flushReact();
expect(container.textContent).toContain("This looks like Zapier.");
await act(async () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Connect with a link");
expect(container.textContent).toContain(zapierUrl);
expect(nameInputFrom(container)?.value).toBe("Zapier");
expect(container.querySelector('input[type="password"]')).toBeNull();
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined();
});
it("keeps Zapier visible and uses the compact agent multi-selector throughout its wizard", async () => {
mockSearch.value = "byo=1&source=zapier";
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "zapier",
name: "Zapier",
tagline: "Automate things",
authKind: "api_key",
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
logoUrl: "https://example.com/zapier.png",
credentialFields: [],
},
],
});
connectAppMock.mockResolvedValueOnce({
connectionId: "conn-1",
application: { id: "app-1", name: "Zapier" },
actions: {
readOnly: [
{
catalogEntryId: "action-1",
toolName: "find_record",
title: "Find record",
description: "Find a record.",
riskLevel: "read",
},
],
canMakeChanges: [],
},
catalog: [],
suggestedDefaults: {},
});
await render();
expect(container.textContent).toContain("Step 1 of 4");
expect(container.textContent).toContain("Connect Zapier");
expect(container.textContent).toContain("Add MCP URL");
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
expect(container.textContent).not.toContain("More ways to connect");
const linkInput = container.querySelector<HTMLInputElement>(
'input[placeholder^="https://mcp.zapier.com"]',
);
const zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=secret-token";
await act(async () => setInputValue(linkInput!, zapierUrl));
await flushReact();
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
expect(container.textContent).toContain("Step 2 of 4");
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
await act(async () => {
buttonByText("Continue with 1 action on")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Step 3 of 4");
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
await act(async () => {
buttonContaining("Only specific agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Select agents");
expect(container.textContent).not.toContain("Ada");
expect(container.textContent).not.toContain("Grace");
await act(async () => {
buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Ada");
expect(document.body.textContent).toContain("Grace");
const adaCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]');
await act(async () => {
adaCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonByText("Done")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("1 agent selected");
expect(container.textContent).not.toContain("Grace");
await act(async () => {
buttonByText("Continue to install")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Step 4 of 4");
expect(container.textContent).toContain("Install Zapier tools?");
expect(container.textContent).toContain("Not yet");
await act(async () => {
buttonContaining("Specific agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("1 agent selected");
await act(async () => {
buttonByText("Finish setup")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["action-1"],
askFirstCatalogEntryIds: [],
access: { agentIds: ["agent-1"] },
});
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
{ targetType: "agent", targetId: "agent-1" },
]);
});
// PAP-10922: "Run your own" / "Paste a config" moved from the sidebar to rows
// under "Connect with a link" on the gallery step.
it("offers 'Run your own' and 'Paste a config' rows that route into the Advanced door", async () => {
await render();
expect(container.textContent).toContain("More ways to connect");
const buttonContaining = (text: string) =>
Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes(text),
);
await act(async () => {
buttonContaining("Run your own")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced");
await act(async () => {
buttonContaining("Paste a config")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced/paste-config");
});
// PAP-11091: discoverability copy for remote MCP URLs — the link field
// advertises that any remote tool URL (incl. a local MCP server) works. Per
// the UX re-review, the localhost example lives in the body copy (legible at
// every viewport) rather than the placeholder, which truncated on mobile.
it("advertises that remote/local MCP URLs work under 'Connect with a link'", async () => {
await render();
expect(container.textContent).toContain(
"Any remote tool URL works here — including a local MCP server like",
);
expect(container.textContent).toContain("http://127.0.0.1:8848/mcp");
const linkInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find((i) =>
i.getAttribute("placeholder")?.startsWith("https://example.com/actions"),
);
// Placeholder stays a single, short example so it never truncates.
expect(linkInput?.getAttribute("placeholder")).toBe("https://example.com/actions");
});
// Reconnect from the app page: ?link/?name/?applicationId prefill skips the
// gallery and re-attaches the connection to the existing application.
it("prefills the link frame from search params and passes applicationId to connect", async () => {
mockSearch.value =
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
await render();
expect(container.textContent).toContain("Connect with a link");
expect(container.textContent).toContain("https://www.example.com/actions");
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
(i) => i.getAttribute("placeholder") === "My app",
);
expect(nameInput?.value).toBe("Bla");
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({
link: "https://www.example.com/actions",
name: "Bla",
applicationId: "app-77",
});
});
it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
],
});
await render();
await act(async () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Share each sheet with this email");
expect(container.textContent).toContain("robot@paperclip.iam.gserviceaccount.com");
expect(container.textContent).toContain(
"In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.",
);
expect(buttonByText("Connect")?.disabled).toBe(true);
});
it("shows inline validation for invalid Google Sheets links", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
],
});
await render();
await act(async () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
await act(async () => setTextareaValue(textarea!, "https://example.com/not-a-sheet"));
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("That doesn't look like a Google Sheets link.");
expect(connectAppMock).not.toHaveBeenCalled();
});
// PAP-11283: the gallery step exposes a Name field (default = app name) so a
// connection can be named at create time, matching the link flow.
function nameInputFrom(root: HTMLDivElement): HTMLInputElement | undefined {
return Array.from(root.querySelectorAll<HTMLInputElement>("input")).find(
(i) => i.getAttribute("placeholder") === "My app",
);
}
it("gallery key step defaults the Name field to the app name", async () => {
await render();
await act(async () => {
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Connect Zapier");
expect(nameInputFrom(container)?.value).toBe("Zapier");
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=setup");
});
it("returns from an app key step to the BYO gallery", async () => {
mockSearch.value = "byo=1";
mockParams.appKey = "zapier";
await render();
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1");
});
it("leaving the default name connects with the app name", async () => {
await render();
await act(async () => {
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
await act(async () => setInputValue(keyField!, "secret-key"));
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=actions");
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier" });
});
it("a custom name in the gallery step is sent to the connect mutation", async () => {
await render();
await act(async () => {
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => setInputValue(nameInputFrom(container)!, "Zapier (stdio smoke)"));
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
await act(async () => setInputValue(keyField!, "secret-key"));
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier (stdio smoke)" });
});
it("a custom name on the Google Sheets step is sent to the connect mutation", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
],
});
await render();
await act(async () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// Default is the app name.
expect(nameInputFrom(container)?.value).toBe("Google Sheets");
await act(async () => setInputValue(nameInputFrom(container)!, "Google Sheets (stdio smoke)"));
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
await act(async () =>
setTextareaValue(textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit"),
);
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({
galleryKey: "google-sheets",
name: "Google Sheets (stdio smoke)",
configValues: { allowedSpreadsheetIds: ["sheet_123"] },
});
});
it("passes parsed Google Sheets IDs as connection config values", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
{
key: "google-sheets",
name: "Google Sheets",
tagline: "Read and update selected spreadsheets.",
authKind: "none",
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
logoUrl: "https://example.com/sheets.png",
credentialFields: [],
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
},
],
});
await render();
await act(async () => {
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
await act(async () =>
setTextareaValue(
textarea!,
"https://docs.google.com/spreadsheets/d/sheet_123/edit\nhttps://docs.google.com/spreadsheets/d/sheet_456",
)
);
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
const [, input] = connectAppMock.mock.calls[0];
expect(input).toMatchObject({
galleryKey: "google-sheets",
configValues: { allowedSpreadsheetIds: ["sheet_123", "sheet_456"] },
});
});
});
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from "react";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { ReviewQueueCard } from "./ReviewQueueCard";
/**
* Review — the "decisions waiting on you" inbox (PAP-12371, Finding B).
*
* Ask-first approvals used to live only inside the "Needs attention" page,
* folded together with health/error triage. That buried the one thing a user
* must act on for their agents to proceed. This is the explicit, top-level
* home for those approvals, aligned with the Inbox "waiting for your OK"
* language from the approved PAP-11178 gateway UX. Health issues stay on
* "Needs attention"; decisions live here.
*/
export function AppsReview() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: "Review" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name]);
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to review approvals.</div>;
}
return (
<div className="max-w-3xl space-y-6 pb-12">
<header>
<h1 className="text-2xl font-bold tracking-tight">Review</h1>
<p className="mt-1 text-sm text-muted-foreground">
Actions your agents want to run that need your OK first. Approve, always-allow, or decline.
</p>
</header>
<ReviewQueueCard emptyState="reassure" heading="Waiting for your OK" />
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
// @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 { Browse } from "./Browse";
const listGalleryMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listGallery: (companyId: string) => listGalleryMock(companyId),
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
useNavigate: () => navigateMock,
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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));
});
}
}
function galleryEntry(overrides: Record<string, unknown>) {
return {
key: "github",
name: "GitHub",
logoUrl: "https://example.com/github.png",
tagline: "Let agents open PRs and issues.",
authKind: "oauth",
transportTemplate: { transport: "remote_http", url: "https://api.github.com/mcp" },
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
...overrides,
};
}
describe("Browse store door (PAP-13254 door 1)", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
listGalleryMock.mockResolvedValue({
apps: [
galleryEntry({ key: "zapier", name: "Zapier", tagline: "Connect automations." }),
galleryEntry({ key: "github", name: "GitHub", tagline: "Open PRs and issues." }),
galleryEntry({ key: "slack", name: "Slack", tagline: "Post messages to channels." }),
galleryEntry({ key: "acme", name: "Acme CRM", tagline: "Sync deals and contacts." }),
],
});
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
async function renderBrowse() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<Browse />
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders the store header, popular grid, gallery, and BYO card", async () => {
await renderBrowse();
const text = container.textContent ?? "";
expect(text).toContain("Browse");
expect(text).toContain("Connect Zapier or your own MCP server.");
expect(text).toContain("Popular");
expect(text).toContain("All apps");
expect(text).toContain("GitHub");
expect(text).toContain("Slack");
expect(text).toContain("Acme CRM");
// Bring-your-own is a first-class row in the store.
expect(text).toContain("Connect your own tool");
});
it("enables Zapier and custom URLs while fading unfinished integrations", async () => {
await renderBrowse();
const zapierTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Zapier"),
);
const githubTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("GitHub"),
);
const tile = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Acme CRM"),
);
const byoCard = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Connect your own tool"),
);
expect(zapierTiles).toHaveLength(2);
expect(zapierTiles.every((button) => !button.disabled)).toBe(true);
expect(githubTiles.every((button) => button.disabled)).toBe(true);
expect(tile?.disabled).toBe(true);
expect(byoCard?.disabled).toBe(false);
expect(tile?.textContent).toContain("Coming soon");
expect(zapierTiles[0]?.textContent).toContain("Connect");
await act(async () => {
zapierTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&source=zapier");
await act(async () => {
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
});
it("filters the gallery by the search query", async () => {
await renderBrowse();
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, "slack");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
const text = container.textContent ?? "";
expect(text).toContain("Results (1)");
expect(text).toContain("Slack");
expect(text).not.toContain("Acme CRM");
// Popular grid is hidden while searching.
expect(text).not.toContain("Popular");
});
it("keeps the custom URL option available when gallery search has no matches", async () => {
await renderBrowse();
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, "missing app");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("No planned apps match");
expect(container.textContent).toContain("Connect your own tool");
});
});
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link2, Search } from "lucide-react";
import type { AppGalleryEntry } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { Skeleton } from "@/components/ui/skeleton";
import { AppLogo } from "./AppLogo";
import {
AdvancedToolsLink,
BYO_CONNECT_HREF,
ByoConnectCard,
POPULAR_KEYS,
ZAPIER_CONNECT_HREF,
} from "./store-cards";
/**
* Door 1 — Browse (the store) (PAP-13254 / U3 §4).
*
* A persistent, browsable storefront: search + a Popular grid + the full
* gallery + a first-class bring-your-own card + a labelled Developer link.
* Browse remains the single discoverability surface. Zapier and bring-your-own
* MCP servers use the URL flow; the remaining integrations stay unavailable.
*/
export function Browse() {
const navigate = useNavigate();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [query, setQuery] = useState("");
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: "Browse" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name]);
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const gallery = galleryQuery.data?.apps ?? [];
const popular = useMemo(
() =>
POPULAR_KEYS.map((key) => gallery.find((entry) => entry.key === key)).filter(
(entry): entry is AppGalleryEntry => Boolean(entry),
),
[gallery],
);
const trimmed = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!trimmed) return gallery;
return gallery.filter(
(entry) =>
entry.name.toLowerCase().includes(trimmed) ||
entry.tagline.toLowerCase().includes(trimmed) ||
(entry.description?.toLowerCase().includes(trimmed) ?? false),
);
}, [gallery, trimmed]);
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to browse apps.</div>;
}
const loading = galleryQuery.isLoading;
return (
<div className="max-w-5xl space-y-8 pb-12">
<header>
<h1 className="text-2xl font-bold tracking-tight">Browse</h1>
<p className="mt-1 text-sm text-muted-foreground">
Connect Zapier or your own MCP server. More integrations are coming soon.
</p>
</header>
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search apps…"
aria-label="Search apps"
className="h-10 w-full rounded-lg border border-border bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/30"
/>
</div>
{loading ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={index} className="h-24 w-full rounded-xl" />
))}
</div>
) : (
<>
{!trimmed && popular.length > 0 && (
<section className="space-y-3">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Popular
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{popular.map((entry) => (
<AppTile
key={entry.key}
entry={entry}
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
compact
/>
))}
</div>
</section>
)}
<section className="space-y-3">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
{trimmed ? `Results (${filtered.length})` : "All apps"}
</div>
{filtered.length === 0 ? (
<p className="flex items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-4 py-6 text-sm text-muted-foreground">
<Link2 className="h-4 w-4" />
No planned apps match “{query.trim()}”.
</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((entry) => (
<AppTile
key={entry.key}
entry={entry}
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
/>
))}
</div>
)}
</section>
<ByoConnectCard onConnect={() => navigate(BYO_CONNECT_HREF)} />
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Zapier connects with the MCP URL it gives you. Other listed integrations are previews.
</p>
<AdvancedToolsLink />
</div>
</>
)}
</div>
);
}
function AppTile({
entry,
onConnect,
compact = false,
}: {
entry: AppGalleryEntry;
onConnect?: () => void;
compact?: boolean;
}) {
const disabled = !onConnect;
if (compact) {
return (
<button
type="button"
disabled={disabled}
onClick={onConnect}
className={disabled
? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60"
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center transition-colors hover:border-foreground/30 hover:bg-accent/40"}
>
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
<span className="text-xs font-medium text-foreground">{entry.name}</span>
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
{disabled ? "Coming soon" : "Connect →"}
</span>
</button>
);
}
return (
<button
type="button"
disabled={disabled}
onClick={onConnect}
className={disabled
? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"}
>
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">{entry.name}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{entry.tagline}</div>
</div>
<span className={disabled ? "shrink-0 text-xs font-semibold text-muted-foreground" : "shrink-0 text-xs font-semibold text-primary"}>
{disabled ? "Coming soon" : "Connect →"}
</span>
</button>
);
}
+325
View File
@@ -0,0 +1,325 @@
// @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 { Connections } from "./Connections";
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listAppsAttentionMock = vi.hoisted(() => vi.fn());
const listProfilesMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listGallery: (companyId: string) => listGalleryMock(companyId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listAppsAttention: (companyId: string) => listAppsAttentionMock(companyId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
},
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => mockNavigate,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
// 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 the
// flushSync-based helper the sibling apps tests use (PAP-12371 gotcha).
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function application(overrides: Record<string, unknown>) {
return {
id: "app-x",
companyId: "company-1",
applicationKey: undefined,
name: "GitHub",
description: null,
type: "mcp_http",
status: "active",
pluginId: null,
ownerAgentId: null,
ownerUserId: null,
metadata: null,
archivedAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function connection(overrides: Record<string, unknown>) {
return {
id: "conn-x",
companyId: "company-1",
applicationId: "app-x",
name: "GitHub",
connectionKind: "managed",
transport: "remote_http",
status: "active",
transportConfig: {},
config: {},
credentialSecretRefs: [],
healthStatus: "healthy",
healthCheckedAt: null,
lastError: null,
enabled: true,
lastUsedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function profile(connectionId: string, includedEntryIds: string[]) {
return {
id: `profile-${connectionId}`,
companyId: "company-1",
profileKey: `app:${connectionId}`,
name: connectionId,
description: null,
status: "active",
defaultAction: "deny",
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
entries: includedEntryIds.map((catalogEntryId, index) => ({
id: `entry-${connectionId}-${index}`,
companyId: "company-1",
profileId: `profile-${connectionId}`,
selectorType: "catalog_entry",
effect: "include",
applicationId: null,
connectionId,
catalogEntryId,
toolName: null,
riskLevel: null,
conditions: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
})),
bindings: [],
};
}
describe("Connections table (M1b / PAP-13254 door 2)", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
listGalleryMock.mockResolvedValue({ apps: [] });
listAppsAttentionMock.mockResolvedValue({ apps: [] });
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listProfilesMock.mockResolvedValue({ profiles: [] });
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
vi.clearAllMocks();
});
async function renderApps() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<Connections />
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders applications without connections as not connected with a Connect action", async () => {
listApplicationsMock.mockResolvedValue({
applications: [application({ id: "app-github", name: "GitHub" })],
});
await renderApps();
const text = container.textContent ?? "";
expect(text).toContain("All (1)");
expect(text).toContain("GitHub");
expect(text).toContain("Not connected");
expect(text).toContain("Connect it so agents can use it.");
expect(text).toContain("Connect");
const row = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
tr.textContent?.includes("GitHub"),
);
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github");
mockNavigate.mockClear();
const connectButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Connect") && !button.textContent.includes("Connect an app"),
);
connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github");
});
it("rolls up multi-connection status, attention count, actions, and navigation by application", async () => {
listApplicationsMock.mockResolvedValue({
applications: [
application({ id: "app-github", name: "GitHub" }),
application({ id: "app-slack", name: "Slack" }),
application({ id: "app-notion", name: "Notion" }),
],
});
listConnectionsMock.mockResolvedValue({
connections: [
connection({ id: "c-connected", applicationId: "app-github", name: "GitHub", healthStatus: "healthy" }),
connection({
id: "c-attention",
applicationId: "app-slack",
name: "Slack",
healthStatus: "error",
lastUsedAt: new Date("2026-06-09T00:00:00Z"),
}),
connection({ id: "c-attention-2", applicationId: "app-slack", name: "Slack Team", healthStatus: "healthy" }),
connection({ id: "c-paused", applicationId: "app-notion", name: "Notion", enabled: false }),
],
});
listAppsAttentionMock.mockResolvedValue({
apps: [
{
connection: connection({ id: "c-attention", applicationId: "app-slack", name: "Slack", healthStatus: "error" }),
healthNeedsAttention: true,
quarantinedCatalogEntryCount: 0,
pendingActionRequestCount: 0,
reasons: ["health"],
},
{
connection: connection({ id: "c-attention-2", applicationId: "app-slack", name: "Slack Team", healthStatus: "healthy" }),
healthNeedsAttention: false,
quarantinedCatalogEntryCount: 1,
pendingActionRequestCount: 0,
reasons: ["quarantined_catalog_entries"],
},
],
});
listProfilesMock.mockResolvedValue({
profiles: [
profile("c-connected", ["a", "b", "c"]),
profile("c-attention", ["a"]),
profile("c-attention-2", ["b", "c"]),
],
});
await renderApps();
const text = container.textContent ?? "";
// 1. Connected rows lose the repeated hint.
expect(text).not.toContain("Connected and ready");
// 2. Attention + Paused rows keep their explanatory hint.
expect(text).toContain("The key stopped working");
expect(text).toContain("Paused — agents can");
// 3. Filter chips and attention banner are application-counted, not connection-counted.
expect(text).toContain("All (3)");
expect(text).toContain("Needs attention (1)");
expect(text).toContain("1 app needs attention");
// 3. New header columns are present.
const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent?.trim());
expect(headers).toEqual(["App", "Status", "Actions", "Last used", ""]);
// 4. Actions column reflects enabled catalog entries rolled up by application; missing profile => 0 on.
expect(text).toContain("3 on");
expect(text).toContain("0 on");
// 5. Last used renders a relative timestamp when present, dash when absent.
expect(text).toContain("—");
// 6. Multi-connection app appears once and opens its first connection detail.
expect(Array.from(container.querySelectorAll("tbody tr")).filter((tr) => tr.textContent?.includes("Slack"))).toHaveLength(1);
const slackRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
tr.textContent?.includes("Slack"),
);
slackRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-attention");
// 7. Button labels are honest: broken health says Reconnect, healthy/paused say Open.
const rowButtonLabel = (name: string) =>
Array.from(container.querySelectorAll("tbody tr"))
.find((tr) => tr.textContent?.includes(name))
?.querySelector("td:last-child button")?.textContent;
expect(rowButtonLabel("GitHub")).toBe("Open");
expect(rowButtonLabel("Slack")).toBe("Reconnect");
expect(rowButtonLabel("Notion")).toBe("Open");
});
// F6 (PAP-13254 §4): the row highlight and the Status pill derive from ONE
// health signal, so they can never disagree. A healthy connection stays
// "Healthy" / "Open" and is not amber-highlighted, even if the broader
// attention endpoint would once have flagged it (quarantine/new-tools review
// now live on the app detail + Review door, not the Connections highlight).
it("keeps a healthy app un-highlighted and Open — pill and highlight agree (F6)", async () => {
listApplicationsMock.mockResolvedValue({
applications: [application({ id: "app-github", name: "GitHub" })],
});
listConnectionsMock.mockResolvedValue({
connections: [connection({ id: "c-healthy", applicationId: "app-github", healthStatus: "healthy" })],
});
listAppsAttentionMock.mockResolvedValue({
apps: [
{
connection: connection({ id: "c-healthy", applicationId: "app-github", healthStatus: "healthy" }),
healthNeedsAttention: false,
quarantinedCatalogEntryCount: 2,
pendingActionRequestCount: 0,
reasons: ["quarantined_catalog_entries"],
},
],
});
await renderApps();
expect(container.textContent).toContain("Healthy");
// No attention: the danger banner and attention filter chip are inert.
expect(container.textContent).not.toContain("needs attention");
expect(container.textContent).toContain("Needs attention (0)");
const row = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
tr.textContent?.includes("GitHub"),
);
expect(row?.className).not.toContain("amber");
const button = row?.querySelector("td:last-child button");
expect(button?.textContent).toBe("Open");
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy");
});
});
+441
View File
@@ -0,0 +1,441 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react";
import type {
AppGalleryEntry,
ToolApplication,
ToolConnection,
ToolProfileWithDetails,
} from "@paperclipai/shared";
import {
humanizeConnectionDisplayName,
isToolConnectionAttentionHealth as isAttentionHealthStatus,
} from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { AppLogo } from "./AppLogo";
import { useReviewCount } from "./useReviewCount";
import { AdvancedToolsLink } from "./store-cards";
const BROWSE_HREF = "/apps/browse";
type StatusFilter = "all" | "attention";
type AppStatus = {
label: "Healthy" | "Needs attention" | "Paused" | "Not connected";
tone: "connected" | "attention" | "paused" | "not_connected";
};
type AppRow = {
application: ToolApplication;
primaryConnection: ToolConnection | null;
status: AppStatus;
actionCount: number;
lastUsedAt: Date | string | null;
logoUrl?: string | null;
};
/**
* F6 (PAP-13254 / U3 §4): a single health signal is the source of truth for
* BOTH the row highlight and the Status pill so they can never disagree. The
* pill's `attention` tone and the row highlight are now the *same* predicate.
*/
function statusFor(application: ToolApplication, connections: ToolConnection[]): AppStatus {
if (connections.length === 0) {
return { label: "Not connected", tone: "not_connected" };
}
if (
application.status === "disabled" ||
application.status === "archived" ||
connections.every((connection) => connection.enabled === false || connection.status === "disabled")
) {
return { label: "Paused", tone: "paused" };
}
if (connections.some((connection) => isAttentionHealthStatus(connection.healthStatus))) {
return { label: "Needs attention", tone: "attention" };
}
return { label: "Healthy", tone: "connected" };
}
/** The single health-derived predicate that drives highlight, pill, banner, filter (F6). */
function rowNeedsAttention(row: AppRow): boolean {
return row.status.tone === "attention";
}
const STATUS_CLASS: Record<AppStatus["tone"], string> = {
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
paused: "border-border bg-muted text-muted-foreground",
not_connected: "border-border bg-background text-muted-foreground",
};
export function Connections() {
const navigate = useNavigate();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const reviewCount = useReviewCount();
const [filter, setFilter] = useState<StatusFilter>("all");
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: "Connections" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name]);
const galleryQuery = useQuery({
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const gallery = galleryQuery.data?.apps ?? [];
const logoByName = useMemo(() => {
const map = new Map<string, AppGalleryEntry>();
for (const entry of gallery) map.set(entry.name.toLowerCase(), entry);
return map;
}, [gallery]);
const logoByKey = useMemo(() => {
const map = new Map<string, AppGalleryEntry>();
for (const entry of gallery) map.set(entry.key, entry);
return map;
}, [gallery]);
// "Actions on" = enabled tools in each app's per-connection access profile,
// mirroring what App detail shows so the count never disagrees with the page.
const actionCountByConnection = useMemo(() => {
const map = new Map<string, number>();
for (const profile of profilesQuery.data?.profiles ?? []) {
map.set(profile.profileKey, enabledActionCount(profile));
}
return map;
}, [profilesQuery.data]);
const connections = (connectionsQuery.data?.connections ?? []).filter(
(c) => c.status !== "archived",
);
const applications = (applicationsQuery.data?.applications ?? []).filter(
(application) => application.status !== "archived",
);
const connectionsByApplication = useMemo(() => {
const map = new Map<string, ToolConnection[]>();
for (const connection of connections) {
map.set(connection.applicationId, [...(map.get(connection.applicationId) ?? []), connection]);
}
return map;
}, [connections]);
const rows = useMemo<AppRow[]>(() => {
return applications.map((application) => {
const appConnections = connectionsByApplication.get(application.id) ?? [];
const primaryConnection = appConnections[0] ?? null;
const actionCount = appConnections.reduce(
(sum, connection) => sum + (actionCountByConnection.get(`app:${connection.id}`) ?? 0),
0,
);
const lastUsedAt = appConnections.reduce<Date | string | null>((latest, connection) => {
if (!connection.lastUsedAt) return latest;
if (!latest) return connection.lastUsedAt;
return new Date(connection.lastUsedAt).getTime() > new Date(latest).getTime()
? connection.lastUsedAt
: latest;
}, null);
const galleryEntry = application.applicationKey
? logoByKey.get(application.applicationKey)
: undefined;
return {
application,
primaryConnection,
status: statusFor(application, appConnections),
actionCount,
lastUsedAt,
logoUrl: galleryEntry?.logoUrl ?? logoByName.get(application.name.toLowerCase())?.logoUrl,
};
});
}, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]);
const rowsNeedingAttention = rows.filter(rowNeedsAttention);
const visibleRows = filter === "attention" ? rowsNeedingAttention : rows;
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
}
const loading = applicationsQuery.isLoading || connectionsQuery.isLoading || galleryQuery.isLoading;
return (
<div className="max-w-5xl">
{loading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-64 w-full" />
</div>
) : rows.length === 0 ? (
<EmptyConnections onBrowse={() => navigate(BROWSE_HREF)} />
) : (
<div className="space-y-5">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Connections</h1>
<p className="mt-1 text-sm text-muted-foreground">
The tools you’ve connected, and whether they’re working.
</p>
</div>
<Button onClick={() => navigate(BROWSE_HREF)}>Connect an app</Button>
</header>
<div className="flex flex-wrap items-center gap-2">
<FilterChip active={filter === "all"} onClick={() => setFilter("all")}>
All ({rows.length})
</FilterChip>
<FilterChip
active={filter === "attention"}
tone="danger"
disabled={rowsNeedingAttention.length === 0}
onClick={() => setFilter("attention")}
>
Needs attention ({rowsNeedingAttention.length})
</FilterChip>
</div>
{reviewCount > 0 && (
<button
type="button"
onClick={() => navigate("/apps/review")}
className="flex w-full items-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left transition-colors hover:bg-amber-500/15"
>
<ShieldQuestion className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{reviewCount} {reviewCount === 1 ? "action is" : "actions are"} waiting for your OK
</div>
<div className="truncate text-xs text-amber-700 dark:text-amber-300">
Your agents paused to check with you before making a change.
</div>
</div>
<span className="shrink-0 text-xs font-semibold text-amber-800 dark:text-amber-200">Review →</span>
</button>
)}
{rowsNeedingAttention.length > 0 && (
<button
type="button"
onClick={() => setFilter("attention")}
className="flex w-full items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-left transition-colors hover:bg-red-500/15"
>
<ShieldAlert className="h-5 w-5 shrink-0 text-red-600 dark:text-red-400" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-red-900 dark:text-red-100">
{rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "app needs" : "apps need"} attention
</div>
<div className="truncate text-xs text-red-700 dark:text-red-300">
{floatSummary(rowsNeedingAttention)}
</div>
</div>
<span className="shrink-0 text-xs font-semibold text-red-800 dark:text-red-200">Fix →</span>
</button>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">App</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Actions</th>
<th className="px-4 py-2.5">Last used</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{visibleRows.map((row) => {
const { application, primaryConnection, status } = row;
const attention = rowNeedsAttention(row);
const hint =
status.tone === "attention"
? "The key stopped working — reconnect to fix."
: status.tone === "paused"
? "Paused — agents can’t use it right now."
: status.tone === "not_connected"
? "Connect it so agents can use it."
: null;
const appHref = primaryConnection
? `/apps/${primaryConnection.id}`
: `/apps/app/${application.id}`;
const actionLabel = !primaryConnection
? "Connect"
: status.tone === "attention"
? "Reconnect"
: "Open";
return (
<tr
key={application.id}
onClick={() => navigate(appHref)}
className={cn(
"cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30",
attention && "bg-amber-500/[0.06]",
)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<AppLogo
name={application.name}
logoUrl={row.logoUrl}
size={32}
/>
<div className="min-w-0">
<div className="font-medium text-foreground">
{application.name}
</div>
{hint && (
<div className="truncate text-xs text-muted-foreground">{hint}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
STATUS_CLASS[status.tone],
)}
>
{status.label}
</span>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground">{row.actionCount} on</span>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground">
{row.lastUsedAt ? timeAgo(row.lastUsedAt) : "—"}
</span>
</td>
<td className="px-4 py-3 text-right">
<Button
variant={attention ? "default" : "outline"}
size="sm"
onClick={(event) => {
event.stopPropagation();
navigate(appHref);
}}
>
{actionLabel}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Apps you connect become available to every agent unless you change “Who can use it”.
</p>
<AdvancedToolsLink />
</div>
</div>
)}
</div>
);
}
function FilterChip({
active,
tone = "default",
disabled = false,
onClick,
children,
}: {
active: boolean;
tone?: "default" | "danger";
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
disabled && "cursor-not-allowed opacity-50",
active
? tone === "danger"
? "border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300"
: "border-foreground/30 bg-foreground/[0.06] text-foreground"
: "border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
>
{children}
</button>
);
}
function enabledActionCount(profile: ToolProfileWithDetails): number {
let count = 0;
for (const entry of profile.entries ?? []) {
if (entry.effect === "include" && entry.catalogEntryId) count += 1;
}
return count;
}
function floatSummary(rows: AppRow[]): string {
const names = rows.map((row) => humanizeConnectionDisplayName(row.application.name));
if (names.length <= 2) return names.join(" and ");
return `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
}
function EmptyConnections({ onBrowse }: { onBrowse: () => void }) {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold tracking-tight">Connections</h1>
<p className="mt-1 text-sm text-muted-foreground">
The tools you’ve connected, and whether they’re working.
</p>
</header>
<div className="rounded-2xl border border-dashed border-border bg-card p-10 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<AppWindow className="h-6 w-6 text-muted-foreground" />
</div>
<p className="mt-4 text-sm font-medium text-foreground">No connections yet.</p>
<p className="mt-1 text-sm text-muted-foreground">
Add one from <span className="font-medium text-foreground">Apps</span> to give your agents
the tools they need.
</p>
<Button className="mt-6" onClick={onBrowse}>
Browse apps
</Button>
</div>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
// @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 { ReviewQueueCard } from "./ReviewQueueCard";
const listActionRequestsMock = vi.hoisted(() => vi.fn());
const approveActionRequestMock = vi.hoisted(() => vi.fn());
const createTrustRuleFromActionRequestMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listActionRequests: (companyId: string, status: string) => listActionRequestsMock(companyId, status),
approveActionRequest: (companyId: string, actionRequestId: string) =>
approveActionRequestMock(companyId, actionRequestId),
createTrustRuleFromActionRequest: (companyId: string, actionRequestId: string, input: unknown) =>
createTrustRuleFromActionRequestMock(companyId, actionRequestId, input),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function pendingRequest() {
return {
request: {
id: "request-1",
companyId: "company-1",
invocationId: "invocation-1",
issueId: "issue-1",
interactionId: null,
approvalId: null,
status: "pending",
canonicalArgumentsHash: "hash-1",
canonicalArgumentsSummary: {},
signedArguments: "signed",
previewMarkdown: null,
requestedByAgentId: "agent-1",
requestedByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
decidedByAgentId: null,
decidedByUserId: null,
decidedAt: null,
expiresAt: null,
resolvedAt: null,
createdAt: new Date("2026-06-16T12:00:00Z"),
updatedAt: new Date("2026-06-16T12:00:00Z"),
},
toolName: "send_email",
toolTitle: "Send email",
connectionId: "connection-1",
connectionName: "Mail",
applicationName: "Mail",
riskLevel: "write",
requestedByAgentId: "agent-1",
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function buttonContaining(text: string): HTMLButtonElement | undefined {
return Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes(text),
) as HTMLButtonElement | undefined;
}
describe("ReviewQueueCard", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listActionRequestsMock.mockResolvedValue({ actionRequests: [pendingRequest()] });
approveActionRequestMock.mockResolvedValue({ ...pendingRequest().request, status: "approved" });
createTrustRuleFromActionRequestMock.mockResolvedValue({ id: "policy-1" });
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={client}>
<ReviewQueueCard emptyState="reassure" />
</QueryClientProvider>,
);
});
await flushReact();
}
it("promotes Always allow only after the action request is approved", async () => {
const calls: string[] = [];
approveActionRequestMock.mockImplementation(async () => {
calls.push("approve");
return { ...pendingRequest().request, status: "approved" };
});
createTrustRuleFromActionRequestMock.mockImplementation(async () => {
calls.push("trust-rule");
return { id: "policy-1" };
});
await render();
await act(async () => {
buttonContaining("Always allow")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
await flushReact();
expect(calls).toEqual(["approve", "trust-rule"]);
expect(approveActionRequestMock).toHaveBeenCalledWith("company-1", "request-1");
expect(createTrustRuleFromActionRequestMock).toHaveBeenCalledWith(
"company-1",
"request-1",
{ approvalThreshold: 1 },
);
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ title: "Always allowed" }));
});
});
+196
View File
@@ -0,0 +1,196 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, ShieldQuestion, X } from "lucide-react";
import type { ToolActionRequestListItem } from "@paperclipai/shared";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { timeAgo } from "@/lib/timeAgo";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "@/components/MarkdownBody";
/**
* "Ask first" review queue (M1b float / M9 card, PAP-10859).
*
* Renders pending `tool_action_requests` as prosumer cards with three choices:
* • Allow once → approve this single request
* • Always allow → approve + create a trust rule (won't ask again)
* • Decline → reject this request
*
* Pass `connectionId` to scope the queue to a single app (App detail); omit it
* to show every pending request (Needs attention page).
*/
export function ReviewQueueCard({
connectionId,
emptyState = "hidden",
heading = "Waiting for your OK",
}: {
connectionId?: string;
emptyState?: "hidden" | "reassure";
heading?: string;
}) {
const { selectedCompanyId } = useCompany();
const query = useQuery({
queryKey: queryKeys.tools.actionRequests(selectedCompanyId ?? "__none__", "pending"),
queryFn: () => toolsApi.listActionRequests(selectedCompanyId!, "pending"),
enabled: !!selectedCompanyId,
refetchInterval: 20_000,
});
const items = useMemo(() => {
const all = query.data?.actionRequests ?? [];
return connectionId ? all.filter((item) => item.connectionId === connectionId) : all;
}, [query.data, connectionId]);
if (!selectedCompanyId) return null;
if (query.isLoading) return null;
if (items.length === 0) {
if (emptyState === "hidden") return null;
return (
<div className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
Nothing is waiting for your OK right now.
</div>
);
}
return (
<section className="space-y-3">
<div className="flex items-center gap-2">
<ShieldQuestion className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<h2 className="text-sm font-bold text-foreground">{heading}</h2>
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:text-amber-300">
{items.length}
</span>
</div>
<div className="space-y-3">
{items.map((item) => (
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} />
))}
</div>
</section>
);
}
function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionRequestListItem }) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [resolving, setResolving] = useState<null | "allow" | "always" | "decline">(null);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.actionRequests(companyId, "pending") });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(companyId) });
};
const allowOnce = useMutation({
mutationFn: () => toolsApi.approveActionRequest(companyId, item.request.id),
onMutate: () => setResolving("allow"),
onSuccess: () => {
pushToast({ title: "Allowed once", body: `${actionLabel(item)} can run this time.`, tone: "success" });
invalidate();
},
onError: (error) => {
invalidate();
failToast(pushToast, error);
},
onSettled: () => setResolving(null),
});
const alwaysAllow = useMutation({
mutationFn: async () => {
const approved = await toolsApi.approveActionRequest(companyId, item.request.id);
await toolsApi.createTrustRuleFromActionRequest(companyId, item.request.id, { approvalThreshold: 1 });
return approved;
},
onMutate: () => setResolving("always"),
onSuccess: () => {
pushToast({
title: "Always allowed",
body: `${actionLabel(item)} won’t ask again.`,
tone: "success",
});
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.tools.trustRules(companyId) });
},
onError: (error) => {
invalidate();
failToast(pushToast, error);
},
onSettled: () => setResolving(null),
});
const decline = useMutation({
mutationFn: () => toolsApi.declineActionRequest(companyId, item.request.id),
onMutate: () => setResolving("decline"),
onSuccess: () => {
pushToast({ title: "Declined", body: `${actionLabel(item)} won’t run.`, tone: "info" });
invalidate();
},
onError: (error) => {
invalidate();
failToast(pushToast, error);
},
onSettled: () => setResolving(null),
});
const busy = resolving !== null;
const preview = item.request.previewMarkdown?.trim();
return (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-bold text-foreground">{actionLabel(item)}</span>
{item.applicationName && (
<span className="text-muted-foreground">
in {humanizeConnectionDisplayName(item.applicationName)}
</span>
)}
<span className="text-xs text-muted-foreground">· asked {timeAgo(item.request.createdAt)}</span>
</div>
{preview ? (
<div className="mt-2 rounded-lg border border-border bg-background px-3 py-2 text-sm">
<MarkdownBody>{preview}</MarkdownBody>
</div>
) : (
<p className="mt-1 text-sm text-muted-foreground">
An agent wants to run this action. It can change something, so we’re checking with you first.
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={() => allowOnce.mutate()} disabled={busy}>
{resolving === "allow" ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1.5 h-3.5 w-3.5" />}
Allow once
</Button>
<Button size="sm" variant="outline" onClick={() => alwaysAllow.mutate()} disabled={busy}>
{resolving === "always" ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Always allow
</Button>
<Button size="sm" variant="ghost" onClick={() => decline.mutate()} disabled={busy}>
{resolving === "decline" ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <X className="mr-1.5 h-3.5 w-3.5" />}
Decline
</Button>
</div>
</div>
);
}
function actionLabel(item: ToolActionRequestListItem): string {
if (!item.toolTitle && !item.toolName) return "This action";
return humanizeConnectionDisplayName(item.toolName ?? "", { title: item.toolTitle });
}
function failToast(
pushToast: ReturnType<typeof useToast>["pushToast"],
error: unknown,
) {
pushToast({
title: "Couldn’t save that",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
}
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Agent, ToolCallEvent } from "@paperclipai/shared";
import { ActivityPanel } from "./ActivityPanel";
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(callback);
}
function event(overrides: Partial<ToolCallEvent>): ToolCallEvent {
return {
id: "evt-1",
companyId: "co-1",
eventType: "call_completed",
actorType: "agent",
actorId: null,
agentId: "agent-1",
runId: null,
issueId: null,
applicationId: null,
connectionId: "conn-1",
catalogEntryId: null,
invocationId: null,
actionRequestId: null,
runtimeSlotId: null,
toolName: "mcp.app-gallery-link-abc:read_values",
decision: null,
matchedPolicyIds: [],
reasonCode: null,
outcome: "success",
latencyMs: null,
requestHash: null,
requestSummary: null,
resultHash: null,
resultSummary: null,
resultSizeBytes: null,
redactionPlan: null,
rateLimitState: null,
metadata: null,
errorCode: null,
errorMessage: null,
createdAt: new Date("2026-06-18T00:00:00Z"),
...overrides,
} as ToolCallEvent;
}
const agents: Agent[] = [{ id: "agent-1", name: "BenchmarkForensics" } as Agent];
let container: HTMLDivElement | null = null;
afterEach(() => {
container?.remove();
container = null;
});
function renderPanel(props: Parameters<typeof ActivityPanel>[0]) {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<ActivityPanel {...props} />));
return container.textContent ?? "";
}
describe("ActivityPanel render", () => {
const base = {
lifecycleEvents: [],
issues: {},
actionRequests: {},
loading: false,
agents,
connectionId: "conn-1",
appName: "Google Sheets",
};
it("renders 'tested as' attribution for a Test-tab call", () => {
const text = renderPanel({
...base,
events: [event({ actorType: "user", actorId: "user-1", metadata: { source: "test" } })],
userLabelById: new Map([["user-1", "Dotta"]]),
});
expect(text).toContain("Dotta tested as BenchmarkForensics used Read Values");
});
it("renders a real heartbeat call without the prefix", () => {
const text = renderPanel({ ...base, events: [event({})] });
expect(text).toContain("BenchmarkForensics used Read Values");
expect(text).not.toContain("tested as");
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { ToolCallEvent } from "@paperclipai/shared";
import { humanizeEvent, isTestEvent, resolveActorLabel } from "./ActivityPanel";
function event(overrides: Partial<ToolCallEvent>): ToolCallEvent {
return {
id: "evt-1",
companyId: "co-1",
eventType: "call_completed",
actorType: "agent",
actorId: null,
agentId: "agent-1",
runId: null,
issueId: null,
applicationId: null,
connectionId: "conn-1",
catalogEntryId: null,
invocationId: null,
actionRequestId: null,
runtimeSlotId: null,
toolName: "mcp.app-gallery-link-abc:read_values",
decision: null,
matchedPolicyIds: [],
reasonCode: null,
outcome: "success",
latencyMs: null,
requestHash: null,
requestSummary: null,
resultHash: null,
resultSummary: null,
resultSizeBytes: null,
redactionPlan: null,
rateLimitState: null,
metadata: null,
errorCode: null,
errorMessage: null,
createdAt: new Date("2026-06-18T00:00:00Z"),
...overrides,
} as ToolCallEvent;
}
describe("isTestEvent", () => {
it("is true only when metadata.source === 'test'", () => {
expect(isTestEvent(event({ metadata: { source: "test" } }))).toBe(true);
expect(isTestEvent(event({ metadata: { source: "heartbeat" } }))).toBe(false);
expect(isTestEvent(event({ metadata: null }))).toBe(false);
});
});
describe("resolveActorLabel", () => {
it("prefers the directory display name", () => {
expect(resolveActorLabel("user-1", new Map([["user-1", "Dotta"]]))).toBe("Dotta");
});
it("falls back to 'Board' for the local board principal", () => {
expect(resolveActorLabel("local-board", new Map())).toBe("Board");
expect(resolveActorLabel("local-board", undefined)).toBe("Board");
});
it("falls back to 'Someone' for an unknown actor", () => {
expect(resolveActorLabel("user-x", new Map())).toBe("Someone");
expect(resolveActorLabel(null, new Map())).toBe("Someone");
});
});
describe("humanizeEvent test-tab attribution", () => {
it("surfaces '<User> tested as <Agent>' for a successful test call", () => {
const { primary } = humanizeEvent(
event({ actorType: "user", actorId: "user-1", metadata: { source: "test" } }),
"BenchmarkForensics",
undefined,
"Dotta",
);
expect(primary).toBe("Dotta tested as BenchmarkForensics used Read Values");
});
it("renders a real heartbeat call without the tested-as prefix", () => {
const { primary } = humanizeEvent(event({}), "BenchmarkForensics");
expect(primary).toBe("BenchmarkForensics used Read Values");
});
it("attributes a denied test call to the runner instead of an anonymous block", () => {
const { primary } = humanizeEvent(
event({ eventType: "call_denied", outcome: "denied", metadata: { source: "test" } }),
"BenchmarkForensics",
undefined,
"Board",
);
expect(primary).toBe("Board tested as BenchmarkForensics - Read Values is turned off");
});
it("keeps the anonymous block wording for non-test denied calls", () => {
const { primary } = humanizeEvent(
event({ eventType: "call_denied", outcome: "denied" }),
"BenchmarkForensics",
);
expect(primary).toBe("Blocked Read Values - it isn't turned on");
});
});
@@ -0,0 +1,278 @@
import { useMemo } from "react";
import {
humanizeConnectionDisplayName,
type Agent,
type ToolCallEvent,
type ToolConnectionLifecycleEvent,
} from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { appTabHref } from "../app-tabs";
import type { ActivityPanelProps } from "./types";
export function ActivityPanel(props: ActivityPanelProps) {
return <RecentActivity {...props} />;
}
type TimelineRow = {
key: string;
createdAt: Date | string;
primary: string;
dotClass: string;
/** Secondary "while working on PAP-…" issue link, tool-call rows only. */
issue?: { identifier: string } | null;
/** Deep-link rendered after the timestamp ("View in Setup"), lifecycle rows only. */
link?: { to: string; label: string } | null;
};
function RecentActivity({
events,
lifecycleEvents,
issues,
actionRequests,
loading,
agents,
connectionId,
appName,
userLabelById,
}: ActivityPanelProps) {
const nameById = useMemo(() => new Map(agents.map((a) => [a.id, a.name])), [agents]);
const rows = useMemo<TimelineRow[]>(() => {
const callRows: TimelineRow[] = events
.filter((e) => HUMANIZED_EVENTS.has(e.eventType))
.map((event) => {
const row = humanizeEvent(
event,
nameById.get(event.agentId ?? "") ?? null,
event.actionRequestId ? actionRequests[event.actionRequestId] : undefined,
isTestEvent(event) ? resolveActorLabel(event.actorId, userLabelById) : null,
);
return {
key: `call:${event.id}`,
createdAt: event.createdAt,
primary: row.primary,
dotClass: dotColor(event),
issue: event.issueId ? issues[event.issueId] ?? null : null,
};
});
const setupHref = appTabHref(connectionId, "setup");
const lifecycleRows: TimelineRow[] = lifecycleEvents.map((event) => ({
key: `lifecycle:${event.id}`,
createdAt: event.createdAt,
primary: humanizeLifecycleEvent(event, appName, nameById.get(event.agentId ?? "") ?? null),
dotClass: lifecycleDotColor(event),
link: { to: setupHref, label: lifecycleLinkLabel(event) },
}));
return [...callRows, ...lifecycleRows].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}, [events, lifecycleEvents, issues, actionRequests, nameById, connectionId, appName, userLabelById]);
return (
<section className="space-y-2">
<div>
<h2 className="text-sm font-bold text-foreground">Recent activity</h2>
</div>
{loading ? (
<div className="space-y-2 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
</div>
) : rows.length === 0 ? (
<p className="py-5 text-sm text-muted-foreground">No activity yet.</p>
) : (
<ul className="divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="flex items-start gap-3 py-3 text-sm">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", row.dotClass)} aria-hidden />
<span className="min-w-0 flex-1">
<span className="block text-foreground">{row.primary}</span>
<span className="block truncate text-xs text-muted-foreground">
{row.issue ? (
<>
while working on{" "}
<Link
to={`/issues/${row.issue.identifier}`}
className="font-medium text-muted-foreground hover:text-foreground hover:underline"
>
{row.issue.identifier}
</Link>
{" · "}
</>
) : null}
{timeAgo(row.createdAt)}
{row.link ? (
<>
{" · "}
<Link
to={row.link.to}
className="font-medium text-muted-foreground hover:text-foreground hover:underline"
>
{row.link.label}
</Link>
</>
) : null}
</span>
</span>
</li>
))}
</ul>
)}
</section>
);
}
const HUMANIZED_EVENTS = new Set<ToolCallEvent["eventType"]>([
"call_completed",
"call_failed",
"call_denied",
"approval_requested",
"approval_resolved",
]);
/**
* A row is a prosumer Test-tab call (vs. a real heartbeat-driven agent run) when
* the gateway tagged the audit event `metadata.source === "test"` (PAP-11349).
*/
export function isTestEvent(event: ToolCallEvent): boolean {
return (event.metadata as { source?: unknown } | null)?.source === "test";
}
/** Display name for the human who ran a Test-tab call, from the company directory. */
export function resolveActorLabel(
actorId: string | null,
userLabelById: Map<string, string> | undefined,
): string {
if (actorId) {
const label = userLabelById?.get(actorId);
if (label) return label;
if (actorId === "local-board") return "Board";
}
return "Someone";
}
export function humanizeEvent(
event: ToolCallEvent,
agentName: string | null,
actionRequest?: ActivityPanelProps["actionRequests"][string],
/** When set, this row is a Test-tab call run by the named user; prefix accordingly. */
testRunnerLabel?: string | null,
): { primary: string } {
// For Test-tab calls, surface "<User> tested as <Agent>" so prosumer test runs are
// distinguishable from real heartbeat agent activity in the audit trail (PAP-11415).
const who = testRunnerLabel
? `${testRunnerLabel} tested as ${agentName ?? "an agent"}`
: agentName ?? "An agent";
// The raw gateway tool name is prefixed (e.g. `mcp.app-gallery-link-…:kv-set`);
// humanize it to "Kv Set" to match the cross-app Activity view (PAP-11105).
const action = event.toolName ? humanizeConnectionDisplayName(event.toolName) : "an action";
switch (event.eventType) {
case "call_completed":
return {
primary: event.outcome === "success"
? `${who} used ${action}`
: `${who} ran ${action}, but it didn't finish`,
};
case "call_failed":
return { primary: `${action} didn't work for ${lower(who)}` };
case "call_denied":
return {
primary: testRunnerLabel
? `${who} - ${action} is turned off`
: `Blocked ${action} - it isn't turned on`,
};
case "approval_requested":
return { primary: `${who} asked before running ${action}` };
case "approval_resolved":
return { primary: humanizeApprovalResolved(action, actionRequest) };
default:
return { primary: `${who} used ${action}` };
}
}
function humanizeApprovalResolved(
action: string,
actionRequest?: ActivityPanelProps["actionRequests"][string],
): string {
const resolver = actionRequest?.resolverDisplayName ?? "Someone";
if (actionRequest?.status === "approved") return `${resolver} approved ${action}`;
if (actionRequest?.status === "rejected") return `${resolver} said no to ${action}`;
return `${resolver} reviewed ${action}`;
}
/** Humanize a connection lifecycle event into a prosumer sentence (PAP-11284). */
function humanizeLifecycleEvent(
event: ToolConnectionLifecycleEvent,
appName: string,
agentName: string | null,
): string {
const who = event.actorDisplayName ?? agentName ?? "Someone";
switch (event.type) {
case "app_connected":
return `${who} connected ${appName}`;
case "app_paused":
return `${who} paused this app`;
case "app_resumed":
return `${who} resumed this app`;
case "reconnected":
return `${who} reconnected ${appName}`;
case "disconnected":
return `${who} disconnected ${appName}`;
case "allowlist_changed":
return humanizeAllowlistChange(who, event.details);
case "actions_quarantined": {
const count = numberFrom(event.details?.count);
return `${count} new ${count === 1 ? "action" : "actions"} need review`;
}
default:
return `${who} updated this app`;
}
}
function humanizeAllowlistChange(who: string, details: Record<string, unknown> | null): string {
const added = numberFrom(details?.added);
const removed = numberFrom(details?.removed);
if (added > 0 && removed === 0) {
return `${who} added ${added} ${added === 1 ? "sheet" : "sheets"} to the allowlist`;
}
if (removed > 0 && added === 0) {
return `${who} removed ${removed} ${removed === 1 ? "sheet" : "sheets"} from the allowlist`;
}
if (added > 0 && removed > 0) {
return `${who} updated the allowlist (added ${added}, removed ${removed})`;
}
return `${who} updated the allowlist`;
}
function lifecycleLinkLabel(event: ToolConnectionLifecycleEvent): string {
return event.type === "actions_quarantined" ? "Review in Setup" : "View in Setup";
}
function numberFrom(value: unknown): number {
const n = Number(value ?? 0);
return Number.isFinite(n) ? n : 0;
}
function lower(who: string): string {
return who === "An agent" ? "an agent" : who;
}
function dotColor(event: ToolCallEvent): string {
if (event.eventType === "call_failed" || event.outcome === "failure" || event.outcome === "timeout") {
return "bg-red-400";
}
if (event.eventType === "call_denied" || event.outcome === "denied") return "bg-amber-400";
if (event.eventType === "approval_requested") return "bg-amber-400";
return "bg-emerald-400";
}
function lifecycleDotColor(event: ToolConnectionLifecycleEvent): string {
if (event.type === "disconnected") return "bg-red-400";
if (event.type === "app_paused" || event.type === "actions_quarantined") return "bg-amber-400";
return "bg-emerald-400";
}
@@ -0,0 +1,274 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ArrowUpRight, Loader2, Lock } from "lucide-react";
import type { AppGalleryEntry, ToolConnection } from "@paperclipai/shared";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import type { AppDetailSectionProps } from "./types";
export function AdvancedPanel({
connection,
appName,
galleryEntry,
removing,
onRemove,
onReplaced,
}: Pick<AppDetailSectionProps, "connection" | "appName" | "galleryEntry"> & {
removing: boolean;
onRemove: () => void;
onReplaced: () => void;
}) {
return (
<div className="space-y-6">
<KeySection connection={connection} galleryEntry={galleryEntry} onReplaced={onReplaced} />
<TechnicalDetails connection={connection} />
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
</div>
);
}
function KeySection({
connection,
galleryEntry,
onReplaced,
}: {
connection: ToolConnection;
galleryEntry: AppGalleryEntry | null;
onReplaced: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<section className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-start gap-3">
<Lock className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
<h2 className="text-sm font-bold text-foreground">Key</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Your key is stored securely. Replace it if it stopped working or you rotated it.
</p>
</div>
</div>
{!open && (
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
Replace key
</Button>
)}
</div>
{open && (
<div className="border-t border-border px-5 py-4">
<ReconnectForm
connection={connection}
galleryEntry={galleryEntry}
onCancel={() => setOpen(false)}
onReconnected={() => {
setOpen(false);
onReplaced();
}}
/>
</div>
)}
</section>
);
}
export function ReconnectCard({
connection,
galleryEntry,
onReconnected,
}: {
connection: ToolConnection;
galleryEntry: AppGalleryEntry | null;
onReconnected: () => void;
}) {
return (
<div className="rounded-xl border border-amber-500/50 bg-amber-500/10 p-5">
<h2 className="text-sm font-bold text-amber-900 dark:text-amber-100">This app needs reconnecting</h2>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
{connection.healthMessage?.trim() || "The key stopped working. Paste a new one to get it back online."}
</p>
<div className="mt-3">
<ReconnectForm connection={connection} galleryEntry={galleryEntry} onReconnected={onReconnected} />
</div>
</div>
);
}
function ReconnectForm({
connection,
galleryEntry,
onCancel,
onReconnected,
}: {
connection: ToolConnection;
galleryEntry: AppGalleryEntry | null;
onCancel?: () => void;
onReconnected: () => void;
}) {
const { pushToast } = useToast();
const fields = galleryEntry?.credentialFields ?? [];
const [values, setValues] = useState<Record<string, string>>({});
const [single, setSingle] = useState("");
const usesGallery = fields.length > 0 && !!galleryEntry;
const reconnect = useMutation({
mutationFn: () => {
const credentialValues = usesGallery
? values
: { "credentials.authorization": single.trim() };
return toolsApi.reconnectConnection(connection.id, credentialValues);
},
onSuccess: (result) => {
const healthy =
result.connection.healthStatus === "healthy" || result.connection.healthStatus === "unknown";
if (healthy) {
pushToast({
title: "Reconnected",
body: `${humanizeConnectionDisplayName(connection)} is back online.`,
tone: "success",
});
onReconnected();
} else {
pushToast({
title: "Still not working",
body: result.connection.healthMessage?.trim() || "That key didn't check out. Try another.",
tone: "error",
});
}
},
onError: (error) =>
pushToast({
title: "That key didn't work",
body: error instanceof Error ? error.message : "Check the key and try again.",
tone: "error",
}),
});
const filled = usesGallery
? fields.every((f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0)
: single.trim().length > 0;
return (
<div className="space-y-3">
{usesGallery ? (
fields.map((field) => (
<div key={field.configPath}>
<label className="text-xs font-medium text-foreground">{field.label}</label>
<Input
type="password"
autoComplete="off"
value={values[field.configPath] ?? ""}
onChange={(e) => setValues({ ...values, [field.configPath]: e.target.value })}
placeholder="****************"
className="mt-1 h-10 font-mono"
/>
{field.helpUrl && (
<a
href={field.helpUrl}
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
>
Where do I find this? <ArrowUpRight className="h-3 w-3" />
</a>
)}
</div>
))
) : (
<Input
type="password"
autoComplete="off"
value={single}
onChange={(e) => setSingle(e.target.value)}
placeholder="Paste your new key"
className="h-10 font-mono"
/>
)}
<div className="flex items-center gap-2">
<Button size="sm" disabled={!filled || reconnect.isPending} onClick={() => reconnect.mutate()}>
{reconnect.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{reconnect.isPending ? "Checking..." : "Check & reconnect"}
</Button>
{onCancel && (
<Button size="sm" variant="ghost" onClick={onCancel} disabled={reconnect.isPending}>
Cancel
</Button>
)}
</div>
</div>
);
}
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<h2 className="text-sm font-bold text-foreground">Technical details</h2>
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
<dt className="text-muted-foreground">Address</dt>
<dd className="break-all font-mono text-foreground">{connectionAddress(connection)}</dd>
<dt className="text-muted-foreground">Connection type</dt>
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
</dl>
</section>
);
}
export function DangerZone({
appName,
removing,
onRemove,
}: {
appName: string;
removing: boolean;
onRemove: () => void;
}) {
const [confirming, setConfirming] = useState(false);
return (
<section className="rounded-xl border border-destructive/40 bg-card">
<div className="border-b border-destructive/40 px-5 py-3 text-sm font-bold text-destructive">
Danger zone
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div>
<p className="text-sm font-medium text-foreground">Remove this app</p>
<p className="text-xs text-muted-foreground">
Agents lose access to {appName} right away. You can connect it again later.
</p>
</div>
{confirming ? (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)} disabled={removing}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={onRemove} disabled={removing}>
{removing && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Yes, remove it
</Button>
</div>
) : (
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
Remove app
</Button>
)}
</div>
</section>
);
}
export function connectionAddress(connection: ToolConnection): string {
const config = connection.config ?? connection.transportConfig ?? {};
const value = config.url ?? config.endpoint ?? config.remoteUrl;
if (typeof value === "string" && value.trim().length > 0) return redactUrlSecrets(value);
if (connection.transport === "local_stdio") return "Local command";
return "Not set";
}
export function connectionTransportLabel(transport: ToolConnection["transport"]): string {
if (transport === "remote_http") return "Remote HTTP";
if (transport === "local_stdio") return "Local command";
return "Unknown";
}
@@ -0,0 +1,476 @@
import { useEffect, useRef, useState } from "react";
import { Loader2, PackageCheck, RefreshCw } from "lucide-react";
import type { Agent, ToolCatalogEntry } from "@paperclipai/shared";
import { useSearchParams } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { InlineBanner } from "@/components/InlineBanner";
import { cn } from "@/lib/utils";
import { brandChipBadge } from "@/lib/status-colors";
import {
autoExtendNotice,
INSTALL_ALL_WARNING,
installInfoNotice,
type InstallState,
} from "@/lib/tool-installs";
import { QuarantinePill } from "./SetupPanel";
import type { AccessDraft, AppDetailSectionProps } from "./types";
type ActionPermission = "off" | "allowed" | "ask";
export function PermissionsPanel({
appName,
access,
agents,
install,
readOnly,
canChange,
quarantined,
enabledIds,
askFirstIds,
pending,
installPending,
onSaveAccess,
onSaveInstall,
onSetActionPermission,
onTurnOnQuarantined,
onRefreshActions,
refreshPending,
}: Pick<
AppDetailSectionProps,
"access" | "agents" | "readOnly" | "canChange" | "quarantined" | "enabledIds" | "askFirstIds" | "pending"
> & {
appName: string;
install: InstallState;
installPending: boolean;
onSaveAccess: (next: AccessDraft) => void;
onSaveInstall: (next: InstallState) => void;
onSetActionPermission: (id: string, next: ActionPermission) => void;
onTurnOnQuarantined: (ids: string[]) => void;
onRefreshActions: () => void;
refreshPending: boolean;
}) {
// Deep-link from the Test tab's "off" panel: ?focus={catalogEntryId} scrolls
// to and highlights that action row.
const [searchParams] = useSearchParams();
const focusId = searchParams.get("focus");
return (
<div className="space-y-6">
<AccessSection access={access} agents={agents} disabled={pending} onSave={onSaveAccess} />
<InstalledSection
appName={appName}
agents={agents}
access={access}
install={install}
disabled={installPending}
onSave={onSaveInstall}
/>
<ActionsSection
readOnly={readOnly}
canChange={canChange}
quarantined={quarantined}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
disabled={pending}
refreshPending={refreshPending}
focusId={focusId}
onSetPermission={onSetActionPermission}
onTurnOnQuarantined={onTurnOnQuarantined}
onRefreshActions={onRefreshActions}
/>
</div>
);
}
function AccessSection({
access,
agents,
disabled,
onSave,
}: {
access: AccessDraft;
agents: Agent[];
disabled: boolean;
onSave: (next: AccessDraft) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<AccessDraft>(access);
const liveAgents = agents.filter((a) => a.status !== "terminated");
useEffect(() => {
if (!editing) setDraft(access);
}, [access, editing]);
const summary =
access.mode === "all"
? "Every agent can use it"
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} can use it`;
const canSave = draft.mode === "all" || draft.agentIds.size > 0;
return (
<section className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between px-5 py-4">
<div>
<h2 className="text-sm font-bold text-foreground">Who can use it</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
</div>
{!editing && (
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
Change
</Button>
)}
</div>
{editing && (
<div className="space-y-3 border-t border-border px-5 py-4">
<label className="flex items-start gap-3">
<input
type="radio"
className="mt-1"
checked={draft.mode === "all"}
onChange={() => setDraft({ mode: "all", agentIds: new Set() })}
/>
<span>
<span className="text-sm font-semibold text-foreground">All agents</span>
<span className="block text-xs text-muted-foreground">Anyone you've added to Paperclip.</span>
</span>
</label>
<label className="flex items-start gap-3">
<input
type="radio"
className="mt-1"
checked={draft.mode === "specific"}
onChange={() => setDraft({ mode: "specific", agentIds: new Set(draft.agentIds) })}
/>
<span>
<span className="text-sm font-semibold text-foreground">Only specific agents</span>
<span className="block text-xs text-muted-foreground">Pick who can use it.</span>
</span>
</label>
{draft.mode === "specific" && (
<AgentMultiSelect
agents={liveAgents}
selectedAgentIds={draft.agentIds}
onChange={(agentIds) => setDraft({ mode: "specific", agentIds })}
disabled={disabled}
/>
)}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
disabled={disabled || !canSave}
onClick={() => {
onSave(draft);
setEditing(false);
}}
>
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)} disabled={disabled}>
Cancel
</Button>
</div>
</div>
)}
</section>
);
}
function InstalledSection({
appName,
agents,
access,
install,
disabled,
onSave,
}: {
appName: string;
agents: Agent[];
access: AccessDraft;
install: InstallState;
disabled: boolean;
onSave: (next: InstallState) => void;
}) {
const liveAgents = agents.filter((a) => a.status !== "terminated");
const hasAccess = (agentId: string) => access.mode === "all" || access.agentIds.has(agentId);
// Agents that are installed but not (yet) in the access set — installing on
// them auto-extends access server-side. Surfaced amber so it's never silent.
const extendingAgents =
access.mode === "all"
? []
: [...install.agentIds].filter((id) => !access.agentIds.has(id));
const installedCount = install.onAll ? liveAgents.length : install.agentIds.size;
return (
<section className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div>
<h2 className="text-sm font-bold text-foreground">Installed on agents</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Whose harness carries {appName}'s tools on every run.
</p>
</div>
<div className="flex items-center gap-2">
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
{install.onAll ? (
<InstalledBadge label="Installed on all agents" />
) : install.agentIds.size > 0 ? (
<InstalledBadge label={`${installedCount} installed`} />
) : (
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
Permitted only — not installed on any agent
</span>
)}
</div>
</div>
<div className="space-y-3 border-t border-border px-5 py-4">
<InlineBanner tone="info" compact>
{installInfoNotice(appName)}
</InlineBanner>
{!install.onAll && (
<AgentMultiSelect
agents={liveAgents}
selectedAgentIds={install.agentIds}
disabled={disabled}
triggerLabel={
install.agentIds.size === 0
? "Choose agents to install on"
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} installed`
}
getDescription={(agent) => (hasAccess(agent.id) ? "has access" : "no access yet")}
renderNameSuffix={(agent) =>
!hasAccess(agent.id) && install.agentIds.has(agent.id) ? (
<span className={cn("rounded border px-1 py-0 text-xs font-medium", brandChipBadge.amber)}>
will grant access
</span>
) : null
}
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
/>
)}
<label
className={cn(
"flex items-start gap-3 rounded-lg border px-3 py-2.5",
install.onAll ? "border-foreground bg-muted/40" : "border-border bg-muted/20",
)}
>
<Checkbox
checked={install.onAll}
disabled={disabled}
aria-label="Install on all agents"
onCheckedChange={(checked) =>
onSave(checked ? { onAll: true, agentIds: new Set() } : { onAll: false, agentIds: new Set() })
}
/>
<span className="text-xs text-foreground">
<span className="font-semibold">Install on all agents</span>
<span className="mt-0.5 block text-muted-foreground">
{INSTALL_ALL_WARNING}
</span>
</span>
</label>
{extendingAgents.length > 0 ? (
<InlineBanner tone="warning" compact>
<span>
{autoExtendNotice(
extendingAgents.length === 1
? liveAgents.find((a) => a.id === extendingAgents[0])?.name ?? "1 agent"
: `${extendingAgents.length} agents`,
)}{" "}
<span className="font-medium">
Review the {extendingAgents.length} access change
{extendingAgents.length === 1 ? "" : "s"}
</span>
</span>
</InlineBanner>
) : null}
</div>
</section>
);
}
function InstalledBadge({ label }: { label: string }) {
return (
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium", brandChipBadge.green)}>
<PackageCheck className="h-3 w-3" />
{label}
</span>
);
}
function ActionsSection({
readOnly,
canChange,
quarantined,
enabledIds,
askFirstIds,
disabled,
refreshPending,
focusId,
onSetPermission,
onTurnOnQuarantined,
onRefreshActions,
}: {
readOnly: ToolCatalogEntry[];
canChange: ToolCatalogEntry[];
quarantined: ToolCatalogEntry[];
enabledIds: Set<string>;
askFirstIds: Set<string>;
disabled: boolean;
refreshPending: boolean;
focusId?: string | null;
onSetPermission: (id: string, next: ActionPermission) => void;
onTurnOnQuarantined: (ids: string[]) => void;
onRefreshActions: () => void;
}) {
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-bold text-foreground">Action permissions</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Choose what agents can do and what needs a human first.
</p>
</div>
<div className="flex items-center gap-2">
{disabled && <span className="text-xs text-muted-foreground">Saving...</span>}
<Button
variant="outline"
size="sm"
onClick={onRefreshActions}
disabled={refreshPending || disabled}
>
{refreshPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
Refresh actions
</Button>
</div>
</div>
{quarantined.length > 0 && (
<QuarantinePill
count={quarantined.length}
entries={quarantined}
disabled={disabled}
onTurnOn={onTurnOnQuarantined}
/>
)}
<ActionGroup
title="Read only"
hint="Can look up context without changing anything."
actions={readOnly}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
disabled={disabled}
focusId={focusId}
onSetPermission={onSetPermission}
/>
<ActionGroup
title="Can make changes"
hint="Can change something in another app."
actions={canChange}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
disabled={disabled}
focusId={focusId}
onSetPermission={onSetPermission}
/>
</section>
);
}
function ActionGroup({
title,
hint,
actions,
enabledIds,
askFirstIds,
disabled,
focusId,
onSetPermission,
}: {
title: string;
hint: string;
actions: ToolCatalogEntry[];
enabledIds: Set<string>;
askFirstIds: Set<string>;
disabled: boolean;
focusId?: string | null;
onSetPermission: (id: string, next: ActionPermission) => void;
}) {
const focusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (focusId && focusRef.current) {
focusRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [focusId]);
if (actions.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card">
<div className="border-b border-border px-5 py-3 text-sm">
<span className="font-bold text-foreground">{title}</span>
<span className="ml-2 text-muted-foreground">- {hint}</span>
</div>
<div className="divide-y divide-border">
{actions.map((action) => {
const value = actionPermission(action.id, enabledIds, askFirstIds);
const focused = focusId === action.id;
return (
<div
key={action.id}
ref={focused ? focusRef : undefined}
className={cn(
"flex items-center gap-4 px-5 py-3",
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
)}
data-action-id={action.id}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{action.title ?? action.toolName}</div>
{action.description && (
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
)}
</div>
<select
aria-label={`${action.title ?? action.toolName} permission`}
className={cn(
"h-9 w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none",
"focus-visible:border-ring focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
value={value}
disabled={disabled}
onChange={(event) => onSetPermission(action.id, event.currentTarget.value as ActionPermission)}
>
<option value="off">Off</option>
<option value="allowed">Allowed</option>
<option value="ask">Ask a human first</option>
</select>
</div>
);
})}
</div>
</div>
);
}
function actionPermission(
id: string,
enabledIds: Set<string>,
askFirstIds: Set<string>,
): ActionPermission {
if (!enabledIds.has(id)) return "off";
return askFirstIds.has(id) ? "ask" : "allowed";
}
@@ -0,0 +1,33 @@
import { ReviewQueueCard } from "../ReviewQueueCard";
import { QuarantinePill } from "./SetupPanel";
import type { AppDetailSectionProps } from "./types";
export function ReviewPanel({
connectionId,
quarantined = [],
pending = false,
onTurnOnQuarantined,
}: Pick<AppDetailSectionProps, "connectionId"> &
Partial<Pick<AppDetailSectionProps, "quarantined" | "pending">> & {
onTurnOnQuarantined?: (ids: string[]) => void;
}) {
const showsQuarantinedActions = quarantined.length > 0 && !!onTurnOnQuarantined;
return (
<div className="space-y-4">
{showsQuarantinedActions ? (
<QuarantinePill
count={quarantined.length}
entries={quarantined}
disabled={pending}
onTurnOn={onTurnOnQuarantined}
/>
) : null}
<ReviewQueueCard
connectionId={connectionId}
heading="Waiting for your OK"
emptyState={showsQuarantinedActions ? "hidden" : "reassure"}
/>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useState } from "react";
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import type { AppDetailSectionProps } from "./types";
import { googleSheetsConfigWithAllowlist, parseGoogleSheetIds } from "../google-sheets";
export function SetupPanel({
connection,
galleryEntry,
onToggleApp,
appToggleDisabled,
onUpdateConfig,
configUpdateDisabled,
onStartOAuth,
oauthStartDisabled,
}: Pick<
AppDetailSectionProps,
"connection" | "galleryEntry"
> & {
onToggleApp: () => void;
appToggleDisabled: boolean;
onUpdateConfig: (config: Record<string, unknown>) => void;
configUpdateDisabled: boolean;
onStartOAuth: () => void;
oauthStartDisabled: boolean;
}) {
const description = galleryEntry?.description ?? galleryEntry?.tagline ?? null;
const oauth = connection.config?.oauth;
const hasOAuthSignIn = Boolean(oauth && typeof oauth === "object" && !Array.isArray(oauth));
const isSmokeLabFixture = connection.config?.smokeLabFixture === "oauth-http";
return (
<div className="space-y-6">
{description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
)}
{galleryEntry?.key === "google-sheets" && (
<GoogleSheetsAllowlistSection
connection={connection}
disabled={configUpdateDisabled}
onUpdateConfig={onUpdateConfig}
/>
)}
{hasOAuthSignIn && (
<OAuthConnectionSection
connected={Boolean((oauth as Record<string, unknown>).connectedAt)}
isSmokeLabFixture={isSmokeLabFixture}
disabled={oauthStartDisabled}
onStart={onStartOAuth}
/>
)}
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
</div>
);
}
function OAuthConnectionSection({
connected,
isSmokeLabFixture,
disabled,
onStart,
}: {
connected: boolean;
isSmokeLabFixture: boolean;
disabled: boolean;
onStart: () => void;
}) {
const providerName = isSmokeLabFixture ? "Smoke OAuth" : "OAuth";
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-foreground">
{connected ? `Connected with ${providerName}` : `Connect with ${providerName}`}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{connected
? "Sign in again to replace this connection's OAuth session."
: "Open the provider's consent page to finish connecting this app."}
</p>
</div>
<Button type="button" disabled={disabled} onClick={onStart}>
{connected ? "Reconnect" : `Connect with ${providerName}`}
</Button>
</div>
</section>
);
}
function currentSpreadsheetIds(connection: ToolConnection): string[] {
const raw = connection.config?.allowedSpreadsheetIds;
return Array.isArray(raw) ? raw.map((value) => String(value).trim()).filter(Boolean) : [];
}
function googleSheetsUrlForId(id: string): string {
return `https://docs.google.com/spreadsheets/d/${encodeURIComponent(id)}/edit`;
}
function GoogleSheetsAllowlistSection({
connection,
disabled,
onUpdateConfig,
}: {
connection: ToolConnection;
disabled: boolean;
onUpdateConfig: (config: Record<string, unknown>) => void;
}) {
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const ids = currentSpreadsheetIds(connection);
const saveIds = (nextIds: string[]) =>
onUpdateConfig(googleSheetsConfigWithAllowlist(connection.config, nextIds));
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div>
<h2 className="text-sm font-bold text-foreground">Sheets agents can use</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Agents can only use the sheets listed here.
</p>
</div>
<div className="mt-4 space-y-2">
{ids.length === 0 ? (
<div className="text-sm text-muted-foreground">No sheets are connected yet.</div>
) : (
ids.map((id) => {
const sheetUrl = googleSheetsUrlForId(id);
return (
<div key={id} className="flex items-center gap-3 border-t border-border py-2 first:border-t-0">
<a
href={sheetUrl}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="block truncate">Open sheet</span>
<span className="block truncate font-mono text-xs font-normal text-muted-foreground">
{sheetUrl}
</span>
<span className="block truncate font-mono text-(length:--text-micro) font-normal text-muted-foreground/80">
ID: {id}
</span>
</a>
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || ids.length <= 1}
title={ids.length <= 1 ? "Add another sheet before removing this one." : undefined}
onClick={() => saveIds(ids.filter((current) => current !== id))}
>
Remove
</Button>
</div>
);
})
)}
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<Input
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setError(null);
}}
placeholder="https://docs.google.com/spreadsheets/d/..."
className="h-10"
/>
<Button
type="button"
variant="outline"
disabled={disabled}
onClick={() => {
const parsed = parseGoogleSheetIds(draft);
if (parsed.ids.length === 0) {
setError("Paste a Google Sheets link.");
return;
}
if (parsed.invalidCount > 0) {
setError("That doesn't look like a Google Sheets link.");
return;
}
saveIds(Array.from(new Set([...ids, ...parsed.ids])));
setDraft("");
}}
>
Add sheet
</Button>
</div>
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
</section>
);
}
export function AppLifecycleSection({
connection,
disabled,
onToggle,
}: {
connection: ToolConnection;
disabled: boolean;
onToggle: () => void;
}) {
const enabled = connection.enabled !== false && connection.status !== "disabled";
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-foreground">
{enabled ? "Agents can use this app" : "This app is paused"}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{enabled
? "Pause it to stop every agent from using its actions."
: "Resume it when agents should be able to use its actions again."}
</p>
</div>
<ToggleSwitch
aria-label={enabled ? "Pause this app" : "Resume this app"}
checked={enabled}
disabled={disabled}
onCheckedChange={onToggle}
size="lg"
/>
</div>
</section>
);
}
export function QuarantinePill({
count,
entries,
disabled,
onTurnOn,
}: {
count: number;
entries: ToolCatalogEntry[];
disabled: boolean;
onTurnOn: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.08] p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">
{count} new {count === 1 ? "action" : "actions"} to review
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Review"}
</Button>
<Button size="sm" disabled={disabled} onClick={() => onTurnOn(entries.map((e) => e.id))}>
Turn on all
</Button>
</div>
</div>
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
This app added actions since you set it up. They stay off until you turn them on.
</p>
{open && (
<div className="mt-3 divide-y divide-amber-500/25 rounded-lg border border-amber-500/40 bg-background">
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{entry.title ?? entry.toolName}</div>
{entry.description && (
<div className="truncate text-xs text-muted-foreground">{entry.description}</div>
)}
</div>
<Button size="sm" variant="outline" disabled={disabled} onClick={() => onTurnOn([entry.id])}>
Turn on
</Button>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,505 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
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";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import { TestPanel, errorHints } from "./TestPanel";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
getTestCallStatusMock(connectionId, actionRequestId),
declineActionRequest: (companyId: string, actionRequestId: string) =>
declineActionRequestMock(companyId, actionRequestId),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
/** The form field input — excludes the "Find an action" search box. */
function formInput(): HTMLInputElement {
const inputs = [...container.querySelectorAll("input")].filter(
(i) => i.getAttribute("aria-label") !== "Find an action",
);
return inputs[0] as HTMLInputElement;
}
async function fillFormField(value: string) {
const input = formInput();
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
await act(async () => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
}
/** Wait past the 200ms minimum-spinner delay so the result panel renders. */
async function settle() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 260));
});
await flushReact();
}
async function clickByText(text: string) {
const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text);
expect(btn).toBeTruthy();
await act(async () => btn!.click());
await flushReact();
}
function catalogEntry(overrides: Partial<ToolCatalogEntry> = {}): ToolCatalogEntry {
return {
id: "catalog-read",
companyId: "company-1",
applicationId: "app-1",
connectionId: "conn-1",
entryKind: "tool",
toolName: "read_sheet",
title: "Read a sheet",
description: "Get rows and cell values from a sheet.",
inputSchema: {
type: "object",
properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } },
required: ["spreadsheetId"],
},
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-01-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-01-01T00:00:00Z"),
lastSeenAt: new Date("2026-01-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
} as ToolCatalogEntry;
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-claude",
name: "ClaudeCoder",
role: "engineer",
title: "Engineer",
status: "active",
effectiveAccess: {
connectionId: "conn-1",
toolCount: 3,
allowedCount: 1,
askFirstCount: 1,
offCount: 1,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools: [
{ toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] },
{ toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] },
{ toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] },
],
},
...overrides,
};
}
const readEntry = catalogEntry();
const writeAskEntry = catalogEntry({
id: "catalog-append",
toolName: "append_row",
title: "Append a row",
description: "Add a new row to the bottom of a sheet.",
isReadOnly: false,
isWrite: true,
riskLevel: "write",
});
const offEntry = catalogEntry({
id: "catalog-delete",
toolName: "delete_row",
title: "Delete a row",
description: "Remove a row from a sheet permanently.",
isReadOnly: false,
isWrite: true,
isDestructive: true,
riskLevel: "destructive",
});
let container: HTMLDivElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let root: any;
function renderPanel(
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
quarantined: ToolCatalogEntry[] = [],
) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root.render(
<QueryClientProvider client={client}>
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
</QueryClientProvider>,
);
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
listTestAgentsMock.mockReset();
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset();
declineActionRequestMock.mockReset();
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
// Default ask-first polls report the request still waiting on approval.
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "waiting",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: null,
durationMs: null,
});
window.sessionStorage.clear();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
window.sessionStorage.clear();
});
describe("TestPanel", () => {
it("renders the Test-as header and grouped actions with access badges", async () => {
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Test as");
expect(container.textContent).toContain("ClaudeCoder");
expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
expect(container.textContent).toContain("Read (1)");
expect(container.textContent).toContain("Write (2)");
// Each action shows its decision badge.
expect(container.textContent).toContain("Allowed");
expect(container.textContent).toContain("Ask first");
expect(container.textContent).toContain("Off");
});
it("shows the empty state when there are no actions", async () => {
await act(async () => renderPanel([]));
await flushReact();
expect(container.textContent).toContain("Nothing to test yet");
expect(container.textContent).toContain("Go to Setup");
});
it("renders an allowed result panel with a row-count headline after a successful run", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-1",
result: [
{ name: "Acme", stage: "Demo" },
{ name: "Globex", stage: "Trial" },
],
});
await act(async () => renderPanel());
await flushReact();
// Expand the allowed (read) action.
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
expect(trigger).toBeTruthy();
await act(async () => trigger!.click());
await flushReact();
// Fill the required field then Run.
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
agentId: "agent-claude",
toolName: "read_sheet",
parameters: { spreadsheetId: "sheet-123" },
});
expect(container.textContent).toContain("Worked. 2 rows came back.");
expect(container.textContent).toContain("Ran as ClaudeCoder");
expect(container.textContent).toContain("Preview");
});
it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
// The gateway let the call through (decision:"allowed") but the upstream MCP
// tool failed at the tool layer — the envelope carries isError + an error.
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-err",
result: {
content: "Invalid arguments: range is required",
data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true },
error: "MCP tool returned an error result",
},
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("It didn't work.");
expect(container.textContent).not.toContain("Worked.");
// The actual MCP message wins over the generic gateway wrapper string.
expect(container.textContent).toContain("Invalid arguments: range is required");
});
it("renders the ask-first card linking to Review", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Sent for your OK.");
expect(container.textContent).toContain("Cancel this request");
// "Where" is populated from the polled action-request snapshot.
expect(container.textContent).toContain("spreadsheetId: sheet-123");
expect(container.textContent).toContain("Waiting ·");
const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab"));
expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review");
});
it("restores the ask-first card after leaving and returning to the Test tab", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Sent for your OK.");
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => renderPanel());
await flushReact();
expect(runTestCallMock).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Sent for your OK.");
expect(container.textContent).toContain("spreadsheetId: sheet-123");
expect(container.textContent).toContain("Cancel this request");
});
it("shows 'Approved · running' while an approved ask-first call is executing", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "running",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: null,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Approved · running");
// While running we don't offer cancel — the call is already on its way.
expect(container.textContent).not.toContain("Cancel this request");
});
it("mutates the ask-first card into the allowed result once approved and done", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "done",
parameters: { spreadsheetId: "sheet-123" },
result: [{ name: "Acme", stage: "Demo" }],
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: 1200,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
// No longer the pending card — the real response is shown without re-running.
expect(container.textContent).not.toContain("Sent for your OK.");
expect(container.textContent).toContain("Worked.");
expect(container.textContent).toContain("1.2s");
});
it("shows the denied status when an ask-first call is declined in Review", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "denied",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: null,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Denied — see Review for why");
expect(container.textContent).not.toContain("Cancel this request");
});
it("explains an off action and links to Permissions without calling the API", async () => {
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("Delete a row is off for ClaudeCoder.");
const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions");
expect(permLink).toBeTruthy();
// No Run button is offered for an off action.
expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false);
expect(runTestCallMock).not.toHaveBeenCalled();
});
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
listTestAgentsMock.mockResolvedValue({
agents: [
agent({
effectiveAccess: {
...agent().effectiveAccess,
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
}),
],
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("Last changed by Dotta");
});
it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => {
const quarantinedEntry = catalogEntry({
id: "catalog-new",
toolName: "rename_sheet",
title: "Rename a sheet",
description: "Change a sheet's name.",
isReadOnly: false,
isWrite: true,
riskLevel: "write",
status: "quarantined",
});
await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry]));
await flushReact();
expect(container.textContent).toContain("New (1)");
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet"));
expect(trigger).toBeTruthy();
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("This action is new and hasn't been turned on yet.");
expect(runTestCallMock).not.toHaveBeenCalled();
});
});
describe("errorHints", () => {
it("maps known reason codes to tailored hints", () => {
expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0);
expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission");
expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0);
expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit");
});
it("returns the locked generic fallback for an unknown error", () => {
expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]);
});
});
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
import type {
Agent,
AppGalleryEntry,
ToolCallEvent,
ToolConnectionActivityResponse,
ToolConnectionLifecycleEvent,
ToolCatalogEntry,
ToolConnection,
} from "@paperclipai/shared";
export type AccessDraft = { mode: "all" | "specific"; agentIds: Set<string> };
export interface AppDetailSectionProps {
connectionId: string;
connection: ToolConnection;
appName: string;
galleryEntry: AppGalleryEntry | null;
catalog: ToolCatalogEntry[];
active: ToolCatalogEntry[];
readOnly: ToolCatalogEntry[];
canChange: ToolCatalogEntry[];
quarantined: ToolCatalogEntry[];
enabledIds: Set<string>;
askFirstIds: Set<string>;
access: AccessDraft;
agents: Agent[];
pending: boolean;
}
export interface ActivityPanelProps {
events: ToolCallEvent[];
lifecycleEvents: ToolConnectionLifecycleEvent[];
issues: ToolConnectionActivityResponse["issues"];
actionRequests: ToolConnectionActivityResponse["actionRequests"];
loading: boolean;
agents: Agent[];
connectionId: string;
appName: string;
/** Maps a user principalId → display name, for "tested as" attribution on Test-tab calls. */
userLabelById?: Map<string, string>;
}
+35
View File
@@ -0,0 +1,35 @@
import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-react";
export const APP_TABS = [
{ key: "setup", label: "Setup", icon: Settings2 },
{ key: "review", label: "Review", icon: Inbox },
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
{ key: "activity", label: "Activity", icon: Activity },
{ key: "test", label: "Test", icon: Beaker },
{ key: "advanced", label: "Advanced", icon: Wrench },
] as const;
export type AppTabKey = (typeof APP_TABS)[number]["key"];
/**
* Tabs hidden for an application that has no live connection (the
* `AppNotConnected` shell). The Test tab runs real calls against a connected
* app, so it only appears once the app is connected.
*/
export const CONNECTED_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>(["test"]);
export function appTabHref(connectionId: string, tab: AppTabKey): string {
return `/apps/${connectionId}/${tab}`;
}
export function appApplicationTabHref(applicationId: string, tab: AppTabKey): string {
return `/apps/app/${applicationId}/${tab}`;
}
export function isAppTabKey(value: string | undefined): value is AppTabKey {
return APP_TABS.some((tab) => tab.key === value);
}
export function appTabLabel(tabKey: AppTabKey): string {
return APP_TABS.find((tab) => tab.key === tabKey)?.label ?? "Setup";
}
+40
View File
@@ -0,0 +1,40 @@
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
type SubNavKey = "connected" | "gateways" | "activity";
const ITEMS: { key: SubNavKey; label: string; href: string }[] = [
{ key: "connected", label: "Connected", href: "/apps" },
{ key: "gateways", label: "Gateways", href: "/apps/gateways" },
{ key: "activity", label: "Activity", href: "/activity" },
];
/**
* Shared Apps section sub-navigation (Connected · Gateways · Activity). Keeps
* the Gateways surface reachable as a first-class Apps tab per the PAP-11178
* design of record, rather than buried under the Advanced developer door.
*/
export function AppsSubNav({ active }: { active: SubNavKey }) {
return (
<nav className="flex items-center gap-6 border-b border-border text-sm" aria-label="Apps sections">
{ITEMS.map((item) => {
const isActive = item.key === active;
return (
<Link
key={item.href}
to={item.href}
className={cn(
"-mb-px border-b-2 pb-2.5 pt-1 font-medium transition-colors",
isActive
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
aria-current={isActive ? "page" : undefined}
>
{item.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from "react";
import { Check, Copy } from "lucide-react";
import type { ToolMcpGatewayTokenCreated, ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { formatSnippetConfig, maskedTokenLabel, orderedSnippets } from "./gateway-helpers";
type PanelKey = string; // snippet client key, or "raw_url"
/**
* "Connect a client" dialog (PAP-11178 design of record). Shows the copy-paste
* config for each supported client plus a raw URL fallback. If a token was just
* minted it can be revealed once here; otherwise the config carries a masked
* placeholder and the value never persists in the DOM.
*/
export function ConnectClientDialog({
gateway,
open,
onOpenChange,
createdToken,
}: {
gateway: ToolMcpGatewayWithTokens;
open: boolean;
onOpenChange: (open: boolean) => void;
createdToken?: ToolMcpGatewayTokenCreated | null;
}) {
const { pushToast } = useToast();
const snippets = useMemo(() => orderedSnippets(gateway.clientSnippets ?? []), [gateway.clientSnippets]);
const endpoint = useMemo(() => {
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}${gateway.endpointPath}`;
}, [gateway.endpointPath]);
const [active, setActive] = useState<PanelKey>(snippets[0]?.client ?? "raw_url");
const [revealed, setRevealed] = useState(false);
useEffect(() => {
if (open) {
setActive(snippets[0]?.client ?? "raw_url");
setRevealed(false);
}
}, [open, snippets]);
async function copyText(value: string, label: string) {
try {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
}
await navigator.clipboard.writeText(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch (error) {
pushToast({
title: "Copy failed",
body: error instanceof Error ? error.message : "Clipboard access is unavailable.",
tone: "error",
});
}
}
const activeSnippet = snippets.find((snippet) => snippet.client === active) ?? null;
const configText = activeSnippet ? formatSnippetConfig(activeSnippet.config) : "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Connect a client</DialogTitle>
<DialogDescription>
Pick how you’ll point your client at this gateway.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-(--gtc-10)">
<nav className="flex gap-1 overflow-x-auto sm:flex-col" aria-label="Clients">
{snippets.map((snippet) => (
<button
key={snippet.client}
type="button"
onClick={() => setActive(snippet.client)}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
active === snippet.client
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
{snippet.label}
</button>
))}
<button
type="button"
onClick={() => setActive("raw_url")}
className={cn(
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
active === "raw_url"
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
Raw URL
</button>
</nav>
<div className="min-w-0 space-y-3">
{active === "raw_url" ? (
<div className="space-y-1.5">
<div className="text-sm font-medium text-foreground">Endpoint URL</div>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
{endpoint}
</code>
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
<p className="text-xs text-muted-foreground">
Authenticate with <code>Authorization: Bearer &lt;token&gt;</code> over streamable HTTP.
</p>
</div>
) : activeSnippet ? (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">{activeSnippet.label}</div>
<Button
variant="outline"
size="sm"
onClick={() => void copyText(configText, `${activeSnippet.label} config`)}
>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
{configText}
</pre>
{activeSnippet.notes.length > 0 ? (
<ul className="space-y-1 text-xs text-muted-foreground">
{activeSnippet.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">No client snippets available for this gateway.</p>
)}
<div className="space-y-1.5 rounded-md border border-border p-3">
<div className="text-xs font-medium text-muted-foreground">Token</div>
{createdToken ? (
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1.5 font-mono text-xs text-foreground">
{revealed ? createdToken.token : maskedTokenLabel(createdToken)}
</code>
{revealed ? (
<Button
variant="outline"
size="sm"
onClick={() => void copyText(createdToken.token, "Access token")}
>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
Show
</Button>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">
Mint a token on the <span className="font-medium">Tokens</span> tab, then paste it where the
snippet shows <code>Bearer …</code>. You won’t see a token’s full value again after it’s
created.
</p>
)}
<p className="text-xs text-muted-foreground">
Treat this like a password. Anyone with the token can call exactly the tools this gateway
allows. If it leaks, revoke it — the client goes silent immediately.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>
<Check className="mr-1.5 h-4 w-4" />
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import type { ToolMcpGatewayTokenCreated } from "@paperclipai/shared";
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { projectsApi } from "@/api/projects";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { ErrorState } from "@/pages/tools/shared";
import { GATEWAY_TABS, gatewayTabHref, isGatewayTabKey, type GatewayTabKey } from "./gateway-tabs";
import { gatewaysQueryKey } from "./NewGatewayDialog";
import { ConnectClientDialog } from "./ConnectClientDialog";
import { deriveGatewayApps, isGatewayOn } from "./gateway-helpers";
import { OverviewPanel } from "./panels/OverviewPanel";
import { AppsToolsPanel } from "./panels/AppsToolsPanel";
import { TokensPanel } from "./panels/TokensPanel";
import { GatewayActivityPanel } from "./panels/GatewayActivityPanel";
import { GatewayAdvancedPanel } from "./panels/GatewayAdvancedPanel";
export function GatewayDetail() {
const { gatewayId = "", tab } = useParams<{ gatewayId: string; tab?: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [snippetOpen, setSnippetOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<ToolMcpGatewayTokenCreated | null>(null);
const activeTab: GatewayTabKey | null = isGatewayTabKey(tab) ? tab : null;
const gatewaysQuery = useQuery({
queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGateways(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const projectsQuery = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId ?? "__none__"),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const gateway = useMemo(
() => (gatewaysQuery.data?.gateways ?? []).find((g) => g.id === gatewayId),
[gatewaysQuery.data, gatewayId],
);
const profile = useMemo(
() => (profilesQuery.data?.profiles ?? []).find((p) => p.id === gateway?.profileId),
[profilesQuery.data, gateway?.profileId],
);
const apps = useMemo(
() =>
deriveGatewayApps(
profile,
applicationsQuery.data?.applications ?? [],
connectionsQuery.data?.connections ?? [],
),
[profile, applicationsQuery.data, connectionsQuery.data],
);
const agentNames = useMemo(
() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent.name])),
[agentsQuery.data],
);
const projectNames = useMemo(
() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project.name])),
[projectsQuery.data],
);
useEffect(() => {
if (!gateway) return;
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: "Gateways", href: "/apps/gateways" },
{ label: gateway.name },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name, gateway]);
const toggleMutation = useMutation({
mutationFn: () =>
toolsApi.updateGateway(selectedCompanyId!, gatewayId, {
status: gateway && isGatewayOn(gateway) ? "disabled" : "active",
}),
onSuccess: async (updated) => {
pushToast({
title: updated.status === "active" ? "Gateway on" : "Gateway off",
body:
updated.status === "active"
? `${updated.name} is exposing its tools again.`
: `${updated.name} is off — every client goes silent.`,
tone: "success",
});
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't update the gateway",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
});
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage gateways.</div>;
}
if (!activeTab) {
return <Navigate replace to={gatewayTabHref(gatewayId, "overview")} />;
}
if (gatewaysQuery.isLoading) {
return (
<div className="max-w-4xl space-y-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-9 w-full max-w-lg" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (gatewaysQuery.isError) {
return <ErrorState error={gatewaysQuery.error} onRetry={() => gatewaysQuery.refetch()} />;
}
if (!gateway) {
return (
<div className="max-w-3xl p-6">
<p className="text-sm text-muted-foreground">We couldn’t find that gateway.</p>
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps/gateways")}>
Back to gateways
</Button>
</div>
);
}
const endpointHost = (() => {
try {
return `${new URL(window.location.origin).host}${gateway.endpointPath}`;
} catch {
return gateway.endpointPath;
}
})();
return (
<div className="max-w-4xl space-y-5 pb-12">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs text-muted-foreground">
<Link to="/apps/gateways" className="hover:underline">
Apps · Gateways
</Link>
</div>
<h1 className="mt-1 text-2xl font-bold tracking-tight">{gateway.name}</h1>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{endpointHost}</p>
</div>
<Button onClick={() => setSnippetOpen(true)}>
<Send className="mr-1.5 h-4 w-4" />
Show snippet
</Button>
</div>
<nav className="flex items-center gap-6 overflow-x-auto border-b border-border text-sm" aria-label="Gateway tabs">
{GATEWAY_TABS.map((item) => {
const isActive = item.key === activeTab;
return (
<Link
key={item.key}
to={gatewayTabHref(gatewayId, item.key)}
className={cn(
"-mb-px shrink-0 border-b-2 pb-2.5 pt-1 font-medium transition-colors",
isActive
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
aria-current={isActive ? "page" : undefined}
>
{item.label}
</Link>
);
})}
</nav>
{activeTab === "overview" && (
<OverviewPanel
gateway={gateway}
profile={profile}
apps={apps}
agentNames={agentNames}
projectNames={projectNames}
toggleDisabled={toggleMutation.isPending}
onToggle={() => toggleMutation.mutate()}
/>
)}
{activeTab === "apps" && <AppsToolsPanel apps={apps} profile={profile} />}
{activeTab === "tokens" && (
<TokensPanel
companyId={selectedCompanyId}
gateway={gateway}
onTokenCreated={(token) => setCreatedToken(token)}
/>
)}
{activeTab === "activity" && (
<GatewayActivityPanel companyId={selectedCompanyId} gateway={gateway} />
)}
{activeTab === "advanced" && (
<GatewayAdvancedPanel companyId={selectedCompanyId} gateway={gateway} />
)}
<ConnectClientDialog
gateway={gateway}
open={snippetOpen}
onOpenChange={setSnippetOpen}
createdToken={createdToken}
/>
</div>
);
}
+355
View File
@@ -0,0 +1,355 @@
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { projectsApi } from "@/api/projects";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
import { AppsSubNav } from "./AppsSubNav";
import { NewGatewayDialog, gatewaysQueryKey } from "./NewGatewayDialog";
import { gatewayTabHref } from "./gateway-tabs";
import {
activeTokenCount,
allowedToolsLabel,
deriveGatewayApps,
expiringTokenCount,
formatScope,
isGatewayOn,
latestTokenActivity,
} from "./gateway-helpers";
export function GatewaysList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [search, setSearch] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Apps", href: "/apps" },
{ label: "Gateways" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name]);
const gatewaysQuery = useQuery({
queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listGateways(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const projectsQuery = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId ?? "__none__"),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const profileById = useMemo(
() => new Map((profilesQuery.data?.profiles ?? []).map((profile) => [profile.id, profile])),
[profilesQuery.data],
);
const agentNames = useMemo(
() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent.name])),
[agentsQuery.data],
);
const projectNames = useMemo(
() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project.name])),
[projectsQuery.data],
);
const toggleMutation = useMutation({
mutationFn: ({ gateway }: { gateway: ToolMcpGatewayWithTokens }) =>
toolsApi.updateGateway(selectedCompanyId!, gateway.id, {
status: isGatewayOn(gateway) ? "disabled" : "active",
}),
onSuccess: async (gateway) => {
pushToast({
title: gateway.status === "active" ? "Gateway on" : "Gateway off",
body:
gateway.status === "active"
? `${gateway.name} is exposing its tools again.`
: `${gateway.name} is off — every client goes silent.`,
tone: "success",
});
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(selectedCompanyId!) });
},
onError: (error) =>
pushToast({
title: "Couldn't update the gateway",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
});
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage gateways.</div>;
}
const gateways = gatewaysQuery.data?.gateways ?? [];
const term = search.trim().toLowerCase();
const filtered = term
? gateways.filter((gateway) => {
const scope = formatScope(gateway, projectNames, agentNames).toLowerCase();
return (
gateway.name.toLowerCase().includes(term) ||
gateway.displaySlug.toLowerCase().includes(term) ||
scope.includes(term)
);
})
: gateways;
return (
<div className="max-w-5xl space-y-5">
<header className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Apps</h1>
<p className="text-sm text-muted-foreground">
A gateway is one safe MCP endpoint that exposes only the apps you assign. Hand it to a client
like Cursor or Claude Desktop.
</p>
</header>
<AppsSubNav active="gateways" />
{gatewaysQuery.isLoading ? (
<div className="space-y-3 pt-2">
<Skeleton className="h-9 w-full max-w-sm" />
<Skeleton className="h-52 w-full" />
</div>
) : gatewaysQuery.isError ? (
<ErrorState error={gatewaysQuery.error} onRetry={() => gatewaysQuery.refetch()} />
) : gateways.length === 0 ? (
<EmptyGateways onCreate={() => setCreating(true)} />
) : (
<div className="space-y-4 pt-1">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, app, or owner"
className="pl-9"
aria-label="Search gateways"
/>
</div>
<Button onClick={() => setCreating(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New gateway
</Button>
</div>
{(() => {
const rows = filtered.map((gateway) => {
const profile = profileById.get(gateway.profileId);
const apps = deriveGatewayApps(
profile,
applicationsQuery.data?.applications ?? [],
connectionsQuery.data?.connections ?? [],
);
return {
gateway,
profile,
scope: formatScope(gateway, projectNames, agentNames),
appsLabel: `${apps.length} ${apps.length === 1 ? "app" : "apps"}${
profile ? ` · ${allowedToolsLabel(profile)}` : ""
}`,
active: activeTokenCount(gateway),
expiring: expiringTokenCount(gateway),
lastUsed: latestTokenActivity(gateway),
href: gatewayTabHref(gateway.id, "overview"),
};
});
const toggle = (gateway: ToolMcpGatewayWithTokens) => (
<ToggleSwitch
checked={isGatewayOn(gateway)}
disabled={toggleMutation.isPending}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleMutation.mutate({ gateway })}
aria-label={`Turn ${gateway.name} ${isGatewayOn(gateway) ? "off" : "on"}`}
/>
);
const empty = (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No gateways match “{search.trim()}”.
</div>
);
return (
<>
{/* Desktop / tablet: full table. */}
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
<table className="w-full min-w-(--sz-40rem) text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">Gateway</th>
<th className="px-4 py-2.5">Scope</th>
<th className="px-4 py-2.5">Apps</th>
<th className="px-4 py-2.5">Tokens</th>
<th className="px-4 py-2.5">Last used</th>
<th className="px-4 py-2.5 text-right">On</th>
</tr>
</thead>
<tbody>
{rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
<tr
key={gateway.id}
onClick={() => navigate(href)}
className="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30"
>
<td className="px-4 py-3">
<div className="font-medium text-foreground">{gateway.name}</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{endpointHost(gateway.endpointPath, gateway.displaySlug)}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{scope}</td>
<td className="px-4 py-3 text-muted-foreground">{appsLabel}</td>
<td className="px-4 py-3 text-muted-foreground">
{active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
</td>
<td className="px-4 py-3 text-muted-foreground">
{lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
</td>
<td className="px-4 py-3">
<div className="flex justify-end">{toggle(gateway)}</div>
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6}>{empty}</td>
</tr>
) : null}
</tbody>
</table>
</div>
{/* Mobile: stacked cards so the On toggle stays reachable and thumb-sized. */}
<div className="space-y-3 sm:hidden">
{rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
<div
key={gateway.id}
onClick={() => navigate(href)}
className="cursor-pointer rounded-lg border border-border p-4 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{gateway.name}</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{endpointHost(gateway.endpointPath, gateway.displaySlug)}
</div>
</div>
<div className="shrink-0">{toggle(gateway)}</div>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<MobileField label="Scope" value={scope} />
<MobileField label="Apps" value={appsLabel} />
<MobileField
label="Tokens"
value={`${active} active${expiring > 0 ? ` · ${expiring} expiring` : ""}`}
/>
<MobileField
label="Last used"
value={lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
/>
</dl>
</div>
))}
{rows.length === 0 ? (
<div className="rounded-lg border border-border">{empty}</div>
) : null}
</div>
</>
);
})()}
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
<div className="text-sm font-semibold text-foreground">Why a gateway?</div>
<p className="mt-1 text-sm text-muted-foreground">
You pick which apps go through it, who can use it, and how. Revoke the token, the whole
gateway goes silent — no app-by-app cleanup.
</p>
</div>
</div>
)}
<NewGatewayDialog
companyId={selectedCompanyId}
open={creating}
onOpenChange={setCreating}
onCreated={(gatewayId) => navigate(gatewayTabHref(gatewayId, "overview"))}
/>
</div>
);
}
/** Show `mcp.host/g/<slug>` when the endpoint is absolute, else the raw path. */
function endpointHost(endpointPath: string, slug: string): string {
if (typeof window !== "undefined") {
try {
const host = new URL(window.location.origin).host;
return `${host}${endpointPath}`;
} catch {
/* fall through */
}
}
return endpointPath || `/g/${slug}`;
}
/** One label:value pair inside a mobile stacked card. */
function MobileField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="mt-0.5 truncate text-foreground">{value}</dd>
</div>
);
}
function EmptyGateways({ onCreate }: { onCreate: () => void }) {
return (
<div className="rounded-2xl border border-dashed border-border p-12 text-center">
<h2 className="text-lg font-semibold text-foreground">No gateways yet</h2>
<p className="mx-auto mt-2 max-w-sm text-sm text-muted-foreground">
Group your connected apps into one safe endpoint you can hand to a client, then revoke it in one
move.
</p>
<Button className="mt-5" onClick={onCreate}>
<Plus className="mr-1.5 h-4 w-4" />
New gateway
</Button>
</div>
);
}
@@ -0,0 +1,163 @@
import { type FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ToolMcpGatewayContextScopeType, ToolProfileWithDetails } from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { allowedToolsLabel } from "./gateway-helpers";
export const gatewaysQueryKey = (companyId: string) => ["tools", "gateways", companyId] as const;
/**
* "New gateway" dialog. A gateway is one safe MCP endpoint that exposes only
* the tools in its access profile. Matches the prosumer create flow from the
* PAP-11178 design of record.
*/
export function NewGatewayDialog({
companyId,
open,
onOpenChange,
onCreated,
}: {
companyId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (gatewayId: string) => void;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [profileId, setProfileId] = useState("");
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(companyId),
queryFn: () => toolsApi.listProfiles(companyId),
enabled: open,
});
const activeProfiles = (profilesQuery.data?.profiles ?? []).filter(
(profile: ToolProfileWithDetails) => profile.status !== "archived",
);
useEffect(() => {
if (open && !profileId && activeProfiles[0]) setProfileId(activeProfiles[0].id);
}, [open, profileId, activeProfiles]);
const createMutation = useMutation({
mutationFn: () =>
toolsApi.createGateway(companyId, {
name: name.trim(),
description: description.trim() || null,
profileId,
defaultProfileMode: "gateway_only",
contextScopeType: "company" satisfies ToolMcpGatewayContextScopeType,
}),
onSuccess: async (gateway) => {
pushToast({ title: "Gateway created", body: gateway.name, tone: "success" });
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
setName("");
setDescription("");
onOpenChange(false);
onCreated?.(gateway.id);
},
onError: (error) => {
pushToast({
title: "Gateway was not created",
body: error instanceof Error ? error.message : String(error),
tone: "error",
});
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim() || !profileId) return;
createMutation.mutate();
}
const profilesLoading = profilesQuery.isLoading;
const noProfiles = !profilesLoading && activeProfiles.length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New gateway</DialogTitle>
<DialogDescription>
One safe MCP endpoint that exposes only the apps in its access profile. Hand it to a client
like Cursor or Claude Desktop.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Name</span>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="CTO agents"
required
autoFocus
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Access profile</span>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={profileId}
onChange={(event) => setProfileId(event.target.value)}
required
disabled={noProfiles}
>
<option value="" disabled>
{profilesLoading ? "Loading profiles…" : "Choose a profile"}
</option>
{activeProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name} — {allowedToolsLabel(profile)}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
The profile decides which tools this gateway allows. You can change it later.
</span>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Description (optional)</span>
<textarea
className="min-h-16 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Who this endpoint is for and when it should be rotated."
/>
</label>
{noProfiles ? (
<p className="text-xs text-destructive">
Create an access profile under Advanced before adding a gateway.
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending || noProfiles || !name.trim() || !profileId}
>
{createMutation.isPending ? "Creating…" : "Create gateway"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,215 @@
import type {
ToolApplication,
ToolConnection,
ToolMcpGatewayClientSnippet,
ToolMcpGatewayToken,
ToolMcpGatewayWithTokens,
ToolProfileWithDetails,
} from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import {
activeTokenCount,
deriveGatewayApps,
expiringTokenCount,
formatScope,
isGatewayOn,
maskedTokenLabel,
orderedSnippets,
tokenStatus,
} from "./gateway-helpers";
const NOW = new Date("2026-06-16T12:00:00.000Z").getTime();
function token(overrides: Partial<ToolMcpGatewayToken> = {}): ToolMcpGatewayToken {
return {
id: "t",
companyId: "c",
gatewayId: "g",
name: "tok",
tokenPrefix: "pcgw_abc",
subjectType: "gateway_client",
subjectId: null,
clientLabel: "Cursor",
ownerNote: "",
allowedActions: ["tools/list", "tools/call"],
expiresAt: null,
expiryOverrideReason: null,
expiryOverrideByUserId: null,
expiryOverrideByAgentId: null,
expiryOverrideAt: null,
lastUsedAt: null,
revokedAt: null,
createdByAgentId: null,
createdByUserId: "u",
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
...overrides,
};
}
function gateway(overrides: Partial<ToolMcpGatewayWithTokens> = {}): ToolMcpGatewayWithTokens {
return {
id: "gateway-1",
companyId: "c",
gatewayPublicId: "gw",
name: "CTO agents",
displaySlug: "cto-agents",
slug: "cto-agents",
description: null,
status: "active",
profileId: "profile-1",
defaultProfileMode: "gateway_only",
contextScopeType: "none",
contextScopeId: null,
agentId: null,
projectId: null,
issueId: null,
approvalIssueId: null,
endpointPath: "/g/cto-agents/mcp",
authConfig: {} as ToolMcpGatewayWithTokens["authConfig"],
headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"],
metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"],
onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"],
metadata: null,
createdByAgentId: null,
createdByUserId: "u",
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
tokens: [],
clientSnippets: [],
...overrides,
};
}
describe("tokenStatus", () => {
it("classifies revoked, expired, expiring, and active", () => {
expect(tokenStatus(token({ revokedAt: "2026-06-10T00:00:00.000Z" }), NOW)).toBe("revoked");
expect(tokenStatus(token({ expiresAt: "2026-06-01T00:00:00.000Z" }), NOW)).toBe("expired");
expect(tokenStatus(token({ expiresAt: "2026-06-20T00:00:00.000Z" }), NOW)).toBe("expiring");
expect(tokenStatus(token({ expiresAt: "2026-12-01T00:00:00.000Z" }), NOW)).toBe("active");
expect(tokenStatus(token({ expiresAt: null }), NOW)).toBe("active");
});
});
describe("token counts", () => {
it("counts active (incl. expiring) but not revoked/expired", () => {
const g = gateway({
tokens: [
token({ id: "a", expiresAt: "2026-12-01T00:00:00.000Z" }),
token({ id: "b", expiresAt: "2026-06-20T00:00:00.000Z" }),
token({ id: "c", revokedAt: "2026-06-10T00:00:00.000Z" }),
token({ id: "d", expiresAt: "2026-06-01T00:00:00.000Z" }),
],
});
expect(activeTokenCount(g, NOW)).toBe(2);
expect(expiringTokenCount(g, NOW)).toBe(1);
});
});
describe("maskedTokenLabel", () => {
it("never reveals the full value — only prefix plus dots", () => {
expect(maskedTokenLabel({ tokenPrefix: "pcgw_live_8x4Pa" })).toBe("pcgw_live_8x4Pa•••");
});
});
describe("isGatewayOn", () => {
it("is on only when status is active", () => {
expect(isGatewayOn(gateway({ status: "active" }))).toBe(true);
expect(isGatewayOn(gateway({ status: "disabled" }))).toBe(false);
});
});
describe("formatScope", () => {
it("labels company, project, and agent scopes", () => {
expect(formatScope(gateway(), new Map(), new Map())).toBe("Company");
expect(
formatScope(
gateway({ contextScopeType: "project", contextScopeId: "p1" }),
new Map([["p1", "Support"]]),
new Map(),
),
).toBe("Project · Support");
});
});
describe("orderedSnippets", () => {
it("orders clients cursor → claude_desktop → vscode → claude_code → opencode", () => {
const snippets: ToolMcpGatewayClientSnippet[] = [
{ client: "opencode", label: "OpenCode", config: {}, notes: [] },
{ client: "cursor", label: "Cursor", config: {}, notes: [] },
{ client: "vscode", label: "VS Code", config: {}, notes: [] },
];
expect(orderedSnippets(snippets).map((s) => s.client)).toEqual(["cursor", "vscode", "opencode"]);
});
});
describe("deriveGatewayApps", () => {
const profile: ToolProfileWithDetails = {
id: "profile-1",
companyId: "c",
profileKey: "eng",
name: "Engineering",
description: null,
status: "active",
defaultAction: "deny",
newToolsReviewedAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
bindings: [],
summary: {
accessMode: "selected",
allowedToolCount: 3,
allowedApplicationCount: 2,
excludedToolCount: 0,
totalToolCount: 3,
assignmentCount: 0,
appliesToAgentCount: 0,
isCompanyDefault: false,
},
entries: [
{ effect: "include", applicationId: "app-gh", catalogEntryId: "ce-1" },
{ effect: "include", applicationId: "app-gh", catalogEntryId: "ce-2" },
{ effect: "include", connectionId: "conn-sheets", catalogEntryId: "ce-3" },
{ effect: "exclude", applicationId: "app-linear", catalogEntryId: "ce-4" },
] as unknown as ToolProfileWithDetails["entries"],
};
const applications = [
{ id: "app-gh", name: "GitHub", status: "active" },
{ id: "app-sheets", name: "Sheets", status: "active" },
] as unknown as ToolApplication[];
const connections = [
{ id: "conn-gh", applicationId: "app-gh", name: "GitHub", healthStatus: "healthy" },
{ id: "conn-sheets", applicationId: "app-sheets", name: "Sheets", healthStatus: "missing_secret" },
] as unknown as ToolConnection[];
it("lists included apps with tool counts and surfaces attention health", () => {
const rows = deriveGatewayApps(profile, applications, connections);
expect(rows.map((r) => r.application.id)).toEqual(["app-gh", "app-sheets"]);
const gh = rows.find((r) => r.application.id === "app-gh")!;
expect(gh.toolCount).toBe(2);
expect(gh.needsAttention).toBe(false);
const sheets = rows.find((r) => r.application.id === "app-sheets")!;
expect(sheets.toolCount).toBe(1);
expect(sheets.needsAttention).toBe(true);
});
it("prefers a live connection over an archived connection", () => {
const rows = deriveGatewayApps(profile, applications, [
{ ...connections[0], id: "conn-gh-archived", status: "archived" },
{ ...connections[0], id: "conn-gh-active", status: "active" },
connections[1],
] as ToolConnection[]);
expect(rows.find((row) => row.application.id === "app-gh")?.connection?.id).toBe(
"conn-gh-active",
);
});
it("returns nothing without a profile", () => {
expect(deriveGatewayApps(undefined, applications, connections)).toEqual([]);
});
});
@@ -0,0 +1,209 @@
import type {
ToolApplication,
ToolConnection,
ToolMcpGatewayClientSnippet,
ToolMcpGatewayToken,
ToolMcpGatewayWithTokens,
ToolProfileWithDetails,
} from "@paperclipai/shared";
import { humanizeConnectionDisplayName, isToolConnectionAttentionHealth } from "@paperclipai/shared";
/** Days before expiry at which we surface a token as "Expiring". */
export const TOKEN_EXPIRING_WINDOW_DAYS = 14;
export type TokenStatus = "active" | "expiring" | "expired" | "revoked";
export function toDate(value: Date | string | null | undefined): Date | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Reveal-once, then masked. We never persist the full token in the DOM. */
export function maskedTokenLabel(token: Pick<ToolMcpGatewayToken, "tokenPrefix">): string {
return `${token.tokenPrefix}•••`;
}
export function tokenStatus(
token: Pick<ToolMcpGatewayToken, "revokedAt" | "expiresAt">,
now: number = Date.now(),
): TokenStatus {
if (token.revokedAt) return "revoked";
const expires = toDate(token.expiresAt);
if (!expires) return "active";
const diffMs = expires.getTime() - now;
if (diffMs <= 0) return "expired";
if (diffMs <= TOKEN_EXPIRING_WINDOW_DAYS * 24 * 60 * 60 * 1000) return "expiring";
return "active";
}
export const TOKEN_STATUS_LABEL: Record<TokenStatus, string> = {
active: "Active",
expiring: "Expiring",
expired: "Expired",
revoked: "Revoked",
};
/** Count of tokens that can currently authenticate (not revoked, not expired). */
export function activeTokenCount(gateway: ToolMcpGatewayWithTokens, now: number = Date.now()): number {
return gateway.tokens.filter((token) => {
const status = tokenStatus(token, now);
return status === "active" || status === "expiring";
}).length;
}
export function expiringTokenCount(gateway: ToolMcpGatewayWithTokens, now: number = Date.now()): number {
return gateway.tokens.filter((token) => tokenStatus(token, now) === "expiring").length;
}
export function latestTokenActivity(gateway: ToolMcpGatewayWithTokens): Date | null {
return gateway.tokens.reduce<Date | null>((latest, token) => {
const candidate = toDate(token.lastUsedAt);
if (!candidate) return latest;
return !latest || candidate.getTime() > latest.getTime() ? candidate : latest;
}, null);
}
function shortId(value: string | null | undefined): string | null {
if (!value) return null;
return value.length > 12 ? `${value.slice(0, 8)}…` : value;
}
/** Prosumer "Scope" label — Company / Project · X / Agent · X. */
export function formatScope(
gateway: ToolMcpGatewayWithTokens,
projectNames: Map<string, string>,
agentNames: Map<string, string>,
): string {
if (gateway.contextScopeType !== "none" && gateway.contextScopeId) {
if (gateway.contextScopeType === "project") {
return `Project · ${projectNames.get(gateway.contextScopeId) ?? shortId(gateway.contextScopeId)}`;
}
if (gateway.contextScopeType === "agent") {
return `Agent · ${agentNames.get(gateway.contextScopeId) ?? shortId(gateway.contextScopeId)}`;
}
return `${gateway.contextScopeType} · ${shortId(gateway.contextScopeId)}`;
}
if (gateway.projectId) return `Project · ${projectNames.get(gateway.projectId) ?? shortId(gateway.projectId)}`;
if (gateway.agentId) return `Agent · ${agentNames.get(gateway.agentId) ?? shortId(gateway.agentId)}`;
return "Company";
}
export function formatOwner(gateway: ToolMcpGatewayWithTokens, agentNames: Map<string, string>): string {
if (gateway.createdByAgentId) {
return agentNames.get(gateway.createdByAgentId) ?? `Agent ${shortId(gateway.createdByAgentId)}`;
}
return "Board";
}
/** Whether the gateway is exposing tools to clients right now. */
export function isGatewayOn(gateway: ToolMcpGatewayWithTokens): boolean {
return gateway.status === "active";
}
/** Human summary of how many tools a profile allows. */
export function allowedToolsLabel(profile: ToolProfileWithDetails | undefined): string {
if (!profile) return "Profile unavailable";
const { accessMode, allowedToolCount, totalToolCount, excludedToolCount } = profile.summary;
const count =
accessMode === "all_except"
? Math.max(totalToolCount - excludedToolCount, 0)
: allowedToolCount;
if (count === 0) return "No tools allowed";
return `${count} ${count === 1 ? "tool" : "tools"}`;
}
export type GatewayAppRow = {
application: ToolApplication;
connection: ToolConnection | null;
toolCount: number;
needsAttention: boolean;
attentionReason: string | null;
};
/**
* Derive the "Apps in this gateway" list from the bound profile's include
* entries, joined to the company's applications and connections. Missing
* credentials / unreachable servers surface through the connection health
* status, mirroring the connection-level "Needs attention" contract — the
* gateway does not own a separate credential store.
*/
export function deriveGatewayApps(
profile: ToolProfileWithDetails | undefined,
applications: ToolApplication[],
connections: ToolConnection[],
): GatewayAppRow[] {
if (!profile) return [];
const applicationsById = new Map(applications.map((app) => [app.id, app]));
const connectionsById = new Map(connections.map((conn) => [conn.id, conn]));
const connectionsByApplication = new Map<string, ToolConnection[]>();
for (const conn of connections) {
connectionsByApplication.set(conn.applicationId, [
...(connectionsByApplication.get(conn.applicationId) ?? []),
conn,
]);
}
const toolCountByApp = new Map<string, number>();
const includedApplicationIds = new Set<string>();
for (const entry of profile.entries ?? []) {
if (entry.effect !== "include") continue;
let applicationId = entry.applicationId ?? null;
if (!applicationId && entry.connectionId) {
applicationId = connectionsById.get(entry.connectionId)?.applicationId ?? null;
}
if (!applicationId) continue;
includedApplicationIds.add(applicationId);
if (entry.catalogEntryId || entry.toolName) {
toolCountByApp.set(applicationId, (toolCountByApp.get(applicationId) ?? 0) + 1);
}
}
const rows: GatewayAppRow[] = [];
for (const applicationId of includedApplicationIds) {
const application = applicationsById.get(applicationId);
if (!application || application.status === "archived") continue;
const appConnections = connectionsByApplication.get(applicationId) ?? [];
const connection =
appConnections.find((conn) => conn.status !== "archived") ?? appConnections[0] ?? null;
const attentionConnection = appConnections.find((conn) =>
isToolConnectionAttentionHealth(conn.healthStatus),
);
rows.push({
application,
connection,
toolCount: toolCountByApp.get(applicationId) ?? 0,
needsAttention: Boolean(attentionConnection),
attentionReason: attentionConnection
? "Sign-in expired — reconnect to restore access."
: null,
});
}
return rows.sort((a, b) => a.application.name.localeCompare(b.application.name));
}
export function gatewayAppDisplayName(row: GatewayAppRow): string {
return row.connection
? humanizeConnectionDisplayName(row.connection)
: humanizeConnectionDisplayName(row.application.name);
}
const SNIPPET_CLIENT_ORDER: ToolMcpGatewayClientSnippet["client"][] = [
"cursor",
"claude_desktop",
"vscode",
"claude_code",
"opencode",
];
export function orderedSnippets(
snippets: ToolMcpGatewayClientSnippet[],
): ToolMcpGatewayClientSnippet[] {
return [...snippets].sort(
(a, b) => SNIPPET_CLIENT_ORDER.indexOf(a.client) - SNIPPET_CLIENT_ORDER.indexOf(b.client),
);
}
export function formatSnippetConfig(config: Record<string, unknown>): string {
return JSON.stringify(config, null, 2);
}
@@ -0,0 +1,28 @@
import { Activity, LayoutGrid, KeyRound, Wrench, Boxes } from "lucide-react";
/**
* Gateway detail tabs (PAP-11200). Terminology is locked by the approved
* PAP-11178 design of record: Overview · Apps & tools · Tokens · Activity ·
* Advanced. Raw protocol / JSON / transport details live under Advanced.
*/
export const GATEWAY_TABS = [
{ key: "overview", label: "Overview", icon: LayoutGrid },
{ key: "apps", label: "Apps & tools", icon: Boxes },
{ key: "tokens", label: "Tokens", icon: KeyRound },
{ key: "activity", label: "Activity", icon: Activity },
{ key: "advanced", label: "Advanced", icon: Wrench },
] as const;
export type GatewayTabKey = (typeof GATEWAY_TABS)[number]["key"];
export function gatewayTabHref(gatewayId: string, tab: GatewayTabKey): string {
return `/apps/gateways/${gatewayId}/${tab}`;
}
export function isGatewayTabKey(value: string | undefined): value is GatewayTabKey {
return GATEWAY_TABS.some((tab) => tab.key === value);
}
export function gatewayTabLabel(tabKey: GatewayTabKey): string {
return GATEWAY_TABS.find((tab) => tab.key === tabKey)?.label ?? "Overview";
}
@@ -0,0 +1,85 @@
import type { ToolProfileWithDetails } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { allowedToolsLabel, type GatewayAppRow, gatewayAppDisplayName } from "../gateway-helpers";
/**
* Apps & tools tab — which apps this gateway exposes and how many tools each
* contributes, derived from the bound access profile. Missing credentials
* surface as "Needs attention", carried from the connection health status.
*/
export function AppsToolsPanel({
apps,
profile,
}: {
apps: GatewayAppRow[];
profile: ToolProfileWithDetails | undefined;
}) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
These apps go through this gateway. The bound profile
{profile ? ` (${profile.name})` : ""} decides which tools are allowed
{profile ? ` — ${allowedToolsLabel(profile)}.` : "."} Change the profile under Advanced.
</p>
{apps.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No apps are assigned to this gateway’s profile yet.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full min-w-(--sz-32rem) text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">App</th>
<th className="px-4 py-2.5">Tools</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5 text-right" />
</tr>
</thead>
<tbody>
{apps.map((app) => {
const href = app.connection
? `/apps/${app.connection.id}/setup`
: `/apps/app/${app.application.id}/setup`;
return (
<tr key={app.application.id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<Link to={href} className="font-medium text-foreground hover:underline">
{gatewayAppDisplayName(app)}
</Link>
{app.needsAttention && app.attentionReason ? (
<div className="text-xs text-muted-foreground">{app.attentionReason}</div>
) : null}
</td>
<td className="px-4 py-3 text-muted-foreground">
{app.toolCount} {app.toolCount === 1 ? "tool" : "tools"}
</td>
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
app.needsAttention
? "border-foreground bg-foreground text-background"
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{app.needsAttention ? "Needs attention" : "Healthy"}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link to={href} className="text-xs font-medium text-primary hover:underline">
Open →
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,105 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { toolsApi, type ToolAuditOutcome, type ToolGatewayActivityEvent } from "@/api/tools";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
import { cn } from "@/lib/utils";
const OUTCOME_LABEL: Record<ToolAuditOutcome, string> = {
allowed: "Allowed",
blocked: "Blocked",
asked_first: "Ask first",
waiting: "Waiting",
failed: "Failed",
unknown: "—",
};
const OUTCOME_CLASS: Record<ToolAuditOutcome, string> = {
allowed: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
blocked: "border-foreground bg-foreground text-background",
asked_first: "border-foreground bg-foreground text-background",
waiting: "border-border bg-muted text-muted-foreground",
failed: "border-destructive/40 bg-destructive/10 text-destructive",
unknown: "border-border bg-muted text-muted-foreground",
};
function belongsToGateway(event: ToolGatewayActivityEvent, gateway: ToolMcpGatewayWithTokens): boolean {
const details = event.details ?? {};
return (
details.gatewayId === gateway.id ||
details.gatewayPublicId === gateway.gatewayPublicId ||
details.gatewaySlug === gateway.displaySlug
);
}
export function GatewayActivityPanel({
companyId,
gateway,
}: {
companyId: string;
gateway: ToolMcpGatewayWithTokens;
}) {
const activityQuery = useQuery({
queryKey: ["tools", "gateway-activity", companyId, gateway.id],
queryFn: () => toolsApi.listActivity(companyId, { window: "7d", limit: 100 }),
});
const events = useMemo(
() => (activityQuery.data?.events ?? []).filter((event) => belongsToGateway(event, gateway)),
[activityQuery.data, gateway],
);
if (activityQuery.isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
if (activityQuery.isError) {
return <ErrorState error={activityQuery.error} onRetry={() => activityQuery.refetch()} />;
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Every call through this gateway in the last 7 days, with why it was allowed, blocked, or paused.
</p>
{events.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No calls have gone through this gateway yet.
</div>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{events.map((event) => {
const outcome = event.normalizedOutcome;
const tool = event.toolDisplayName ?? "tool";
const app = event.appDisplayName ?? event.applicationDisplayName ?? "app";
const actor = event.agentDisplayName ?? "Client";
return (
<li key={event.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{actor}</div>
<div className="truncate text-xs text-muted-foreground">
{app} · {tool} · <RelativeTime value={event.createdAt} />
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium",
OUTCOME_CLASS[outcome],
)}
>
{OUTCOME_LABEL[outcome]}
</span>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,153 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { gatewaysQueryKey } from "../NewGatewayDialog";
/**
* Advanced tab — raw protocol/transport details, config JSON and the archive
* (destructive) action live here, out of the default prosumer view per the
* PAP-11174 contract's default-vs-Advanced split.
*/
export function GatewayAdvancedPanel({
companyId,
gateway,
}: {
companyId: string;
gateway: ToolMcpGatewayWithTokens;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToast();
const [confirming, setConfirming] = useState(false);
const [confirmName, setConfirmName] = useState("");
const endpoint = `${typeof window !== "undefined" ? window.location.origin : ""}${gateway.endpointPath}`;
const rawConfig = JSON.stringify(
{
gatewayPublicId: gateway.gatewayPublicId,
displaySlug: gateway.displaySlug,
status: gateway.status,
profileId: gateway.profileId,
defaultProfileMode: gateway.defaultProfileMode,
contextScopeType: gateway.contextScopeType,
contextScopeId: gateway.contextScopeId,
endpointPath: gateway.endpointPath,
authConfig: gateway.authConfig,
headerPolicy: gateway.headerPolicy,
metadataPolicy: gateway.metadataPolicy,
onDemandToolsConfig: gateway.onDemandToolsConfig,
},
null,
2,
);
const archiveMutation = useMutation({
mutationFn: () => toolsApi.updateGateway(companyId, gateway.id, { status: "archived" }),
onSuccess: async () => {
pushToast({ title: "Gateway archived", body: `${gateway.name} is no longer reachable.`, tone: "success" });
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
navigate("/apps/gateways");
},
onError: (error) =>
pushToast({
title: "Couldn't archive the gateway",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
});
async function copy(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
}
}
return (
<div className="space-y-5">
<section className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">Transport</h3>
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
<Row label="Transport" value="streamable_http" />
<Row label="Authentication" value="bearer" />
<Row label="Protocol version" value="2025-03-26" />
<Row label="Public ID" value={gateway.gatewayPublicId} mono />
</dl>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
{endpoint}
</code>
<Button variant="outline" size="sm" onClick={() => void copy(endpoint, "Endpoint URL")}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
</section>
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Raw configuration</h3>
<Button variant="outline" size="sm" onClick={() => void copy(rawConfig, "Gateway config JSON")}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy JSON
</Button>
</div>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
{rawConfig}
</pre>
</section>
<section className="space-y-2 rounded-lg border border-destructive/40 p-4">
<h3 className="text-sm font-semibold text-destructive">Danger zone</h3>
<p className="text-sm text-muted-foreground">
Archiving takes the gateway offline for every client. Existing tokens stop working. Type the
gateway name to confirm.
</p>
{confirming ? (
<div className="space-y-2">
<Input
value={confirmName}
onChange={(e) => setConfirmName(e.target.value)}
placeholder={gateway.name}
aria-label="Type the gateway name to confirm archive"
/>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
disabled={confirmName.trim() !== gateway.name || archiveMutation.isPending}
onClick={() => archiveMutation.mutate()}
>
{archiveMutation.isPending ? "Archiving…" : "Archive gateway"}
</Button>
<Button variant="ghost" size="sm" onClick={() => { setConfirming(false); setConfirmName(""); }}>
Cancel
</Button>
</div>
</div>
) : (
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setConfirming(true)}>
Archive gateway
</Button>
)}
</section>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className={mono ? "mt-0.5 font-mono text-foreground" : "mt-0.5 text-foreground"}>{value}</dd>
</div>
);
}
@@ -0,0 +1,173 @@
import { Copy } from "lucide-react";
import type { ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import {
activeTokenCount,
allowedToolsLabel,
expiringTokenCount,
formatScope,
type GatewayAppRow,
gatewayAppDisplayName,
isGatewayOn,
} from "../gateway-helpers";
export function OverviewPanel({
gateway,
profile,
apps,
agentNames,
projectNames,
toggleDisabled,
onToggle,
}: {
gateway: ToolMcpGatewayWithTokens;
profile: ToolProfileWithDetails | undefined;
apps: GatewayAppRow[];
agentNames: Map<string, string>;
projectNames: Map<string, string>;
toggleDisabled: boolean;
onToggle: () => void;
}) {
const { pushToast } = useToast();
const endpoint = `${typeof window !== "undefined" ? window.location.origin : ""}${gateway.endpointPath}`;
const active = activeTokenCount(gateway);
const expiring = expiringTokenCount(gateway);
const needsAttention = apps.filter((app) => app.needsAttention);
const on = isGatewayOn(gateway);
const snippet = [
"{",
' "mcpServers": {',
` "paperclip-${gateway.displaySlug}": {`,
` "url": "${endpoint}",`,
' "headers": { "Authorization": "Bearer pcgw_•••_TOKEN" }',
" }",
" }",
"}",
].join("\n");
async function copy(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
}
}
return (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-border p-4">
<div className="text-xs font-medium text-muted-foreground">{on ? "On" : "Off"}</div>
<div className="mt-2">
<ToggleSwitch checked={on} disabled={toggleDisabled} onCheckedChange={onToggle} aria-label="Toggle gateway" />
</div>
<p className="mt-2 text-xs text-muted-foreground">Toggle the whole gateway off here.</p>
</div>
<StatCard label="Apps">
{apps.length} {apps.length === 1 ? "app" : "apps"}
{profile ? ` · ${allowedToolsLabel(profile)}` : ""}
</StatCard>
<StatCard label="Tokens">
{active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
</StatCard>
<StatCard label="Health">
{needsAttention.length === 0 ? "All green" : `${needsAttention.length} needs attention`}
</StatCard>
</div>
<section className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-foreground">Who can use it</h3>
<p className="mt-1 text-sm text-muted-foreground">
Anyone holding an active token below, restricted by the rules in the bound profile.
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Chip>Scope · {formatScope(gateway, projectNames, agentNames)}</Chip>
<Chip>Profile · {profile?.name ?? "Unavailable"}</Chip>
<Chip>{active} active {active === 1 ? "token" : "tokens"}</Chip>
</div>
</section>
<section className="rounded-lg border border-border p-4">
<h3 className="text-sm font-semibold text-foreground">Apps in this gateway</h3>
{apps.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">
This gateway’s profile doesn’t include any apps yet.
</p>
) : (
<ul className="mt-3 divide-y divide-border">
{apps.map((app) => (
<AppRow key={app.application.id} app={app} />
))}
</ul>
)}
</section>
<section className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">How clients connect</h3>
<Button variant="outline" size="sm" onClick={() => void copy(snippet, "Client config")}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
</div>
<pre className="mt-3 overflow-auto whitespace-pre-wrap break-words rounded bg-background p-3 font-mono text-xs text-muted-foreground">
{snippet}
</pre>
</section>
</div>
);
}
function StatCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border p-4">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className="mt-2 text-sm font-semibold text-foreground">{children}</div>
</div>
);
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full border border-border px-3 py-1 text-xs font-medium text-foreground">
{children}
</span>
);
}
function AppRow({ app }: { app: GatewayAppRow }) {
const href = app.connection ? `/apps/${app.connection.id}/setup` : `/apps/app/${app.application.id}/setup`;
return (
<li className="flex items-center justify-between gap-3 py-2.5">
<div className="min-w-0">
<Link to={href} className="font-medium text-foreground hover:underline">
{gatewayAppDisplayName(app)}
</Link>
<div className="text-xs text-muted-foreground">
{app.toolCount} {app.toolCount === 1 ? "tool" : "tools"}
{app.needsAttention && app.attentionReason ? ` · ${app.attentionReason}` : ""}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium",
app.needsAttention
? "border-foreground bg-foreground text-background"
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
)}
>
{app.needsAttention ? "Needs attention" : "Healthy"}
</span>
</li>
);
}
@@ -0,0 +1,202 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { ToolMcpGatewayToken, ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TokensPanel } from "./TokensPanel";
const createGatewayTokenMock = vi.hoisted(() => vi.fn());
const revokeGatewayTokenMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
createGatewayToken: (...args: unknown[]) => createGatewayTokenMock(...args),
revokeGatewayToken: (...args: unknown[]) => revokeGatewayTokenMock(...args),
},
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = 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 token(overrides: Partial<ToolMcpGatewayToken> = {}): ToolMcpGatewayToken {
return {
id: "token-1",
companyId: "company-1",
gatewayId: "gateway-1",
name: "cto-cursor",
tokenPrefix: "pcgw_live_8x4Pa",
subjectType: "gateway_client",
subjectId: null,
clientLabel: "Cursor",
ownerNote: "Local IDE",
allowedActions: ["tools/list", "tools/call"],
expiresAt: "2026-09-01T00:00:00.000Z",
expiryOverrideReason: null,
expiryOverrideByUserId: null,
expiryOverrideByAgentId: null,
expiryOverrideAt: null,
lastUsedAt: null,
revokedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
...overrides,
};
}
function gateway(overrides: Partial<ToolMcpGatewayWithTokens> = {}): ToolMcpGatewayWithTokens {
return {
id: "gateway-1",
companyId: "company-1",
gatewayPublicId: "gw",
name: "CTO agents",
displaySlug: "cto-agents",
slug: "cto-agents",
description: null,
status: "active",
profileId: "profile-1",
defaultProfileMode: "gateway_only",
contextScopeType: "none",
contextScopeId: null,
agentId: null,
projectId: null,
issueId: null,
approvalIssueId: null,
endpointPath: "/g/cto-agents/mcp",
authConfig: {} as ToolMcpGatewayWithTokens["authConfig"],
headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"],
metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"],
onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"],
metadata: null,
createdByAgentId: null,
createdByUserId: "user-1",
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
tokens: [],
clientSnippets: [],
...overrides,
};
}
describe("TokensPanel", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-16T12:00:00.000Z").getTime());
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.restoreAllMocks();
});
async function render(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
});
await flushReact();
}
function clickButton(label: string) {
const button = [...container.querySelectorAll("button")].find(
(el) => el.textContent?.trim() === label,
);
if (!button) throw new Error(`Button "${label}" not found`);
return act(async () => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function setInput(selectorText: string, value: string) {
const input = container.querySelector<HTMLInputElement>(selectorText);
if (!input) throw new Error(`Input ${selectorText} not found`);
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
return act(async () => {
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
it("masks existing tokens and never renders the full secret at rest", async () => {
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
expect(container.textContent).toContain("pcgw_live_8x4Pa•••");
expect(container.textContent).toContain("Active");
});
it("mints a token and reveals it once, then copies the full value", async () => {
const created = { ...token({ id: "new-token" }), token: "pcgw_live_FULLSECRETVALUE" };
createGatewayTokenMock.mockResolvedValue(created);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
await render(<TokensPanel companyId="company-1" gateway={gateway()} />);
await clickButton("Mint token"); // open the mint form
await setInput('input[placeholder="cto-cursor"]', "cto-cursor");
const form = container.querySelector("form");
if (!form) throw new Error("mint form not found");
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
expect(createGatewayTokenMock).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("New token — copy now");
// Reveal-once banner shows the full value immediately after creation.
expect(container.textContent).toContain("pcgw_live_FULLSECRETVALUE");
await clickButton("Copy");
expect(writeText).toHaveBeenCalledWith("pcgw_live_FULLSECRETVALUE");
});
it("gates revoke behind a typed confirmation matching the token name", async () => {
revokeGatewayTokenMock.mockResolvedValue(token({ revokedAt: "2026-06-16T12:00:00.000Z" }));
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
await clickButton("Revoke");
const confirmButton = [...container.querySelectorAll("button")].find(
(el) => el.textContent?.trim() === "Revoke token",
) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(true);
await setInput('input[aria-label="Type the token name to confirm"]', "cto-cursor");
expect(confirmButton.disabled).toBe(false);
await clickButton("Revoke token");
await flushReact();
expect(revokeGatewayTokenMock).toHaveBeenCalledWith("company-1", "token-1");
});
});
@@ -0,0 +1,395 @@
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, KeyRound, Plus } from "lucide-react";
import type {
ToolMcpGatewayTokenAction,
ToolMcpGatewayTokenCreated,
ToolMcpGatewayWithTokens,
} from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { RelativeTime } from "@/pages/tools/shared";
import { cn } from "@/lib/utils";
import { gatewaysQueryKey } from "../NewGatewayDialog";
import { maskedTokenLabel, TOKEN_STATUS_LABEL, tokenStatus, type TokenStatus } from "../gateway-helpers";
const DEFAULT_ACTIONS: ToolMcpGatewayTokenAction[] = ["tools/list", "tools/call"];
function defaultExpiry(): string {
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
const STATUS_CLASS: Record<TokenStatus, string> = {
active: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
expiring: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
expired: "border-border bg-muted text-muted-foreground",
revoked: "border-foreground bg-foreground text-background",
};
/** Token status pill, shared by the desktop table and mobile cards. */
function StatusBadge({ status }: { status: TokenStatus }) {
return (
<span
className={cn(
"inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
STATUS_CLASS[status],
)}
>
{TOKEN_STATUS_LABEL[status]}
</span>
);
}
/** One label:value pair inside a mobile stacked card. */
function TokenField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="mt-0.5 truncate text-foreground">{value}</dd>
</div>
);
}
export function TokensPanel({
companyId,
gateway,
onTokenCreated,
}: {
companyId: string;
gateway: ToolMcpGatewayWithTokens;
onTokenCreated?: (token: ToolMcpGatewayTokenCreated) => void;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [minting, setMinting] = useState(false);
const [name, setName] = useState("");
const [clientLabel, setClientLabel] = useState("");
const [ownerNote, setOwnerNote] = useState("");
const [expiresAt, setExpiresAt] = useState(defaultExpiry());
const [created, setCreated] = useState<ToolMcpGatewayTokenCreated | null>(null);
const [revealed, setRevealed] = useState(false);
const [revokeName, setRevokeName] = useState("");
const [confirmToken, setConfirmToken] = useState<{ id: string; name: string } | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
const createMutation = useMutation({
mutationFn: () =>
toolsApi.createGatewayToken(companyId, gateway.id, {
name: name.trim(),
clientLabel: clientLabel.trim() || name.trim(),
ownerNote: ownerNote.trim() || name.trim(),
allowedActions: DEFAULT_ACTIONS,
expiresAt: expiresAt ? `${expiresAt}T23:59:59.000Z` : null,
}),
onSuccess: async (token) => {
setCreated(token);
setRevealed(true);
setMinting(false);
setName("");
setClientLabel("");
setOwnerNote("");
setExpiresAt(defaultExpiry());
pushToast({
title: "Token minted",
body: "Copy it now — you won’t see the full value again.",
tone: "success",
});
onTokenCreated?.(token);
await invalidate();
},
onError: (error) =>
pushToast({
title: "Token was not minted",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
});
const revokeMutation = useMutation({
mutationFn: (tokenId: string) => toolsApi.revokeGatewayToken(companyId, tokenId),
onSuccess: async (token) => {
setConfirmToken(null);
setRevokeName("");
pushToast({ title: "Token revoked", body: `${token.name} can no longer connect.`, tone: "success" });
await invalidate();
},
onError: (error) =>
pushToast({
title: "Token was not revoked",
body: error instanceof Error ? error.message : String(error),
tone: "error",
}),
});
async function copyToken(value: string) {
try {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
}
await navigator.clipboard.writeText(value);
pushToast({ title: "Copied", body: "Access token", tone: "success" });
} catch (error) {
pushToast({
title: "Copy failed",
body: error instanceof Error ? error.message : "Clipboard access is unavailable.",
tone: "error",
});
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim()) return;
createMutation.mutate();
}
const tokens = useMemo(
() =>
[...gateway.tokens].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
[gateway.tokens],
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Each token is a separate way in. Revoke any one without breaking the others.
</p>
<Button size="sm" onClick={() => setMinting((value) => !value)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Mint token
</Button>
</div>
{minting ? (
<form className="space-y-3 rounded-md border border-border p-4" onSubmit={submit}>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="cto-cursor" required autoFocus />
</label>
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Owner / client</span>
<Input
value={clientLabel}
onChange={(e) => setClientLabel(e.target.value)}
placeholder="Cursor on work laptop"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Note (why it exists)</span>
<Input value={ownerNote} onChange={(e) => setOwnerNote(e.target.value)} placeholder="Dotta’s MacBook" />
</label>
<label className="space-y-1.5 text-sm">
<span className="text-xs font-medium text-muted-foreground">Expires</span>
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} required />
</label>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setMinting(false)}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={createMutation.isPending || !name.trim()}>
{createMutation.isPending ? "Minting…" : "Mint token"}
</Button>
</div>
</form>
) : null}
{created ? (
<div className="space-y-2 rounded-md border-2 border-foreground/80 bg-muted/40 p-4">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-foreground">New token — copy now</div>
<div className="text-xs text-muted-foreground">
You won’t see the full value again. Store it in your client’s config or your secret manager.
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setCreated(null)} aria-label="Dismiss new token">
Dismiss
</Button>
</div>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-background px-3 py-2 font-mono text-xs text-foreground">
{revealed ? created.token : maskedTokenLabel(created)}
</code>
{revealed ? (
<Button variant="outline" size="sm" onClick={() => void copyToken(created.token)}>
<Copy className="mr-1 h-3.5 w-3.5" />
Copy
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
Show
</Button>
)}
</div>
</div>
) : null}
{tokens.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
No tokens yet. Mint one for the client that will connect to this gateway.
</div>
) : (
<>
{/* Desktop / tablet: full table. */}
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
<table className="w-full min-w-(--sz-44rem) text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">Token</th>
<th className="px-4 py-2.5">Owner</th>
<th className="px-4 py-2.5">Created</th>
<th className="px-4 py-2.5">Last used</th>
<th className="px-4 py-2.5">Expires</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5 text-right" />
</tr>
</thead>
<tbody>
{tokens.map((token) => {
const status = tokenStatus(token);
const canRevoke = status !== "revoked";
return (
<tr key={token.id} className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-foreground">{token.name}</div>
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{token.clientLabel || token.ownerNote || "—"}</td>
<td className="px-4 py-3 text-muted-foreground"><RelativeTime value={token.createdAt} /></td>
<td className="px-4 py-3 text-muted-foreground">
{token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">
{token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"}
</td>
<td className="px-4 py-3">
<StatusBadge status={status} />
</td>
<td className="px-4 py-3 text-right">
{canRevoke ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => {
setConfirmToken({ id: token.id, name: token.name });
setRevokeName("");
}}
>
Revoke
</Button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile: stacked cards so status + Revoke stay reachable. */}
<div className="space-y-3 sm:hidden">
{tokens.map((token) => {
const status = tokenStatus(token);
const canRevoke = status !== "revoked";
return (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-foreground">{token.name}</div>
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
</div>
<StatusBadge status={status} />
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<TokenField label="Owner" value={token.clientLabel || token.ownerNote || "—"} />
<TokenField label="Created" value={<RelativeTime value={token.createdAt} />} />
<TokenField
label="Last used"
value={token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
/>
<TokenField
label="Expires"
value={
token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"
}
/>
</dl>
{canRevoke ? (
<Button
variant="ghost"
size="sm"
className="mt-3 w-full text-xs text-destructive hover:text-destructive"
onClick={() => {
setConfirmToken({ id: token.id, name: token.name });
setRevokeName("");
}}
>
Revoke
</Button>
) : null}
</div>
);
})}
</div>
</>
)}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KeyRound className="h-3.5 w-3.5" />
Every mint, reveal, and revoke is recorded in Activity.
</p>
{confirmToken ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md space-y-3 rounded-lg border border-border bg-card p-5 shadow-lg">
<div>
<h3 className="text-sm font-semibold text-foreground">Revoke this token?</h3>
<p className="mt-1 text-sm text-muted-foreground">
Any client using <span className="font-medium text-foreground">{confirmToken.name}</span> goes
silent immediately. This can’t be undone. Type the token name to confirm.
</p>
</div>
<Input
value={revokeName}
onChange={(e) => setRevokeName(e.target.value)}
placeholder={confirmToken.name}
aria-label="Type the token name to confirm"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setConfirmToken(null);
setRevokeName("");
}}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
disabled={revokeName.trim() !== confirmToken.name || revokeMutation.isPending}
onClick={() => revokeMutation.mutate(confirmToken.id)}
>
{revokeMutation.isPending ? "Revoking…" : "Revoke token"}
</Button>
</div>
</div>
</div>
) : null}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
export function parseGoogleSheetIds(value: string): { ids: string[]; invalidCount: number } {
const ids: string[] = [];
let invalidCount = 0;
for (const rawToken of value.split(/[\s,]+/g)) {
const token = rawToken.trim().replace(/[),.;\]]+$/g, "");
if (!token) continue;
try {
const parsed = new URL(token);
const match = parsed.pathname.match(/\/spreadsheets\/d\/([^/]+)/i);
if (match?.[1]) {
ids.push(decodeURIComponent(match[1]));
continue;
}
invalidCount += 1;
} catch {
invalidCount += 1;
}
}
return { ids: Array.from(new Set(ids)), invalidCount };
}
export function googleSheetsConfigWithAllowlist(
currentConfig: Record<string, unknown> | null | undefined,
allowedSpreadsheetIds: string[],
): Record<string, unknown> {
const env = currentConfig && typeof currentConfig.env === "object" && !Array.isArray(currentConfig.env)
? currentConfig.env as Record<string, unknown>
: {};
return {
...(currentConfig ?? {}),
allowedSpreadsheetIds,
env: {
...env,
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: allowedSpreadsheetIds.join(","),
},
};
}
+50
View File
@@ -0,0 +1,50 @@
import { ServerCog, Wrench } from "lucide-react";
import { Link } from "@/lib/router";
import { advancedTabHref } from "@/pages/tools/tool-tabs";
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "linear"];
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
/** Zapier connects with the complete MCP URL issued by Zapier. */
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
/**
* First-class "Connect your own tool" card (PAP-12371, Finding C; PAP-13254).
* Lives in Browse as a persistent row and launches the guided URL flow.
*/
export function ByoConnectCard({ onConnect }: { onConnect: () => void }) {
return (
<button
type="button"
onClick={onConnect}
className="flex w-full items-center gap-4 rounded-xl border border-dashed border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"
>
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
<ServerCog className="h-5 w-5 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">Connect your own tool</div>
<div className="text-xs text-muted-foreground">
Paste the URL from a custom or self-hosted MCP server and review its actions before enabling it.
</div>
</div>
<span className="shrink-0 text-xs font-semibold text-primary">Connect →</span>
</button>
);
}
/** Labeled door to the developer control-plane (PAP-12371, Finding A cross-link). */
export function AdvancedToolsLink() {
return (
<Link
to={advancedTabHref("run-your-own")}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Wrench className="h-3.5 w-3.5" />
Developer tools (advanced)
</Link>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "@/context/CompanyContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
/**
* Live count of Ask-first requests waiting for the user's OK (PAP-12371,
* Finding B). This is the "decisions waiting on you" signal — deliberately
* separate from the health "Needs attention" count so an approval is never
* hidden behind an error-triage label. Backs the sidebar "Review" badge and
* the persistent review affordance on /apps.
*/
export function useReviewCount(): number {
const { selectedCompanyId } = useCompany();
const query = useQuery({
queryKey: queryKeys.tools.actionRequests(selectedCompanyId ?? "__none__", "pending"),
queryFn: () => toolsApi.listActionRequests(selectedCompanyId!, "pending"),
enabled: !!selectedCompanyId,
refetchInterval: 20_000,
});
return query.data?.actionRequests.length ?? 0;
}
@@ -0,0 +1,685 @@
import { useMemo, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ToolCatalogEntry,
ToolConnection,
ToolPolicy,
ToolProfileEffectiveSummary,
ToolProfileNewToolsReview,
ToolProfileSummary,
ToolProfileWithDetails,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { AgentToolsTab } from "@/pages/AgentToolsTab";
import { ProfileDetail } from "@/pages/tools/profiles/ProfileDetail";
import { ProfilesIndex } from "@/pages/tools/profiles/ProfilesIndex";
import { StepAssign, StepName } from "@/pages/tools/profiles/ProfileWizard";
import { WizardToolsStep } from "@/pages/tools/profiles/WizardToolsStep";
import {
groupCatalogByApp,
TEMPLATES,
type AdvancedRule,
type WizardSelections,
} from "@/pages/tools/profiles/profile-model";
const COMPANY = "company-storybook";
// --- Catalog fixtures ------------------------------------------------------
function makeTool(
id: string,
toolName: string,
cap: "read" | "write" | "destructive",
title: string,
applicationId: string,
connectionId: string,
): ToolCatalogEntry {
return {
id,
companyId: COMPANY,
applicationId,
connectionId,
entryKind: "tool",
toolName,
title,
description: title,
inputSchema: null,
outputSchema: null,
annotations: null,
riskLevel: cap,
isReadOnly: cap === "read",
isWrite: cap === "write",
isDestructive: cap === "destructive",
status: "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolCatalogEntry;
}
const GMAIL_TOOLS: ToolCatalogEntry[] = [
makeTool("g-list", "gmail.list_messages", "read", "List messages in a mailbox", "app-gmail", "conn-gmail"),
makeTool("g-read", "gmail.get_message", "read", "Read a single message", "app-gmail", "conn-gmail"),
makeTool("g-search", "gmail.search", "read", "Search the mailbox", "app-gmail", "conn-gmail"),
makeTool("g-labels", "gmail.list_labels", "read", "List labels", "app-gmail", "conn-gmail"),
makeTool("g-draft", "gmail.create_draft", "write", "Create a draft", "app-gmail", "conn-gmail"),
makeTool("g-send", "gmail.send_message", "write", "Send a message", "app-gmail", "conn-gmail"),
makeTool("g-label", "gmail.modify_labels", "write", "Add or remove labels", "app-gmail", "conn-gmail"),
makeTool("g-archive", "gmail.archive", "write", "Archive a thread", "app-gmail", "conn-gmail"),
makeTool("g-trash", "gmail.trash_message", "destructive", "Move a message to trash", "app-gmail", "conn-gmail"),
makeTool("g-delete", "gmail.delete_message", "destructive", "Permanently delete a message", "app-gmail", "conn-gmail"),
makeTool("g-purge", "gmail.empty_trash", "destructive", "Empty the trash", "app-gmail", "conn-gmail"),
makeTool("g-filter", "gmail.delete_filter", "destructive", "Delete a filter", "app-gmail", "conn-gmail"),
];
const SLACK_TOOLS: ToolCatalogEntry[] = [
makeTool("s-list", "slack.list_channels", "read", "List channels", "app-slack", "conn-slack"),
makeTool("s-history", "slack.channel_history", "read", "Read channel history", "app-slack", "conn-slack"),
makeTool("s-post", "slack.post_message", "write", "Post a message", "app-slack", "conn-slack"),
makeTool("s-archive", "slack.archive_channel", "destructive", "Archive a channel", "app-slack", "conn-slack"),
];
const CATALOG = [...GMAIL_TOOLS, ...SLACK_TOOLS];
const APP_GROUPS = groupCatalogByApp(
CATALOG,
new Map([
["app-gmail", "Gmail"],
["app-slack", "Slack"],
]),
new Map([
["conn-gmail", "Gmail"],
["conn-slack", "Slack"],
]),
);
// --- Profile fixtures ------------------------------------------------------
function summary(partial: Partial<ToolProfileSummary>): ToolProfileSummary {
return {
accessMode: "selected",
allowedToolCount: 0,
allowedApplicationCount: 0,
excludedToolCount: 0,
totalToolCount: 16,
assignmentCount: 0,
appliesToAgentCount: 0,
isCompanyDefault: false,
...partial,
};
}
function profile(
id: string,
name: string,
status: ToolProfileWithDetails["status"],
s: Partial<ToolProfileSummary>,
updatedAt: string,
): ToolProfileWithDetails {
return {
id,
companyId: COMPANY,
profileKey: id,
name,
description: null,
status,
defaultAction: "deny",
newToolsReviewedAt: null,
metadata: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date(updatedAt),
entries: [],
bindings: [],
summary: summary(s),
};
}
const PROFILES: ToolProfileWithDetails[] = [
profile("read-only", "Read-only starter", "active", { allowedToolCount: 6, allowedApplicationCount: 0, appliesToAgentCount: 3 }, "2026-06-11T10:00:00Z"),
profile("everyday", "Everyday work", "active", { allowedToolCount: 12, allowedApplicationCount: 1, appliesToAgentCount: 2 }, "2026-06-12T09:00:00Z"),
profile("company-baseline", "Company baseline", "active", { accessMode: "all_except", excludedToolCount: 4, isCompanyDefault: true }, "2026-06-09T14:00:00Z"),
profile("marketing", "Marketing reach", "active", { allowedToolCount: 3, allowedApplicationCount: 0 }, "2026-06-10T08:00:00Z"),
profile("half-built", "Onboarding bot access", "draft", { allowedToolCount: 2 }, "2026-06-12T16:30:00Z"),
];
const DETAIL_PROFILE: ToolProfileWithDetails = {
...profile("detail", "Everyday work", "active", { allowedToolCount: 12, allowedApplicationCount: 1, appliesToAgentCount: 2, assignmentCount: 2 }, "2026-06-12T09:00:00Z"),
description: "Read and make routine changes across connected work apps.",
entries: [
{
id: "entry-gmail",
companyId: COMPANY,
profileId: "detail",
selectorType: "application",
effect: "include",
applicationId: "app-gmail",
connectionId: null,
catalogEntryId: null,
toolName: null,
riskLevel: null,
conditions: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
},
],
bindings: [
{
id: "bind-sage",
companyId: COMPANY,
profileId: "detail",
targetType: "agent",
targetId: "a-sage",
priority: 0,
metadata: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
},
{
id: "bind-atlas",
companyId: COMPANY,
profileId: "detail",
targetType: "agent",
targetId: "a-atlas",
priority: 0,
metadata: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
},
],
};
const NEW_TOOLS_PROFILE: ToolProfileWithDetails = {
...DETAIL_PROFILE,
id: "gmail-new-tools",
name: "Gmail everyday",
newToolsPendingCount: 3,
};
const AUTO_ALLOW_PROFILE: ToolProfileWithDetails = {
...DETAIL_PROFILE,
id: "gmail-auto-allow",
name: "Gmail auto-allow",
defaultAction: "allow",
};
const NEW_TOOLS_REVIEW: ToolProfileNewToolsReview = {
profileId: NEW_TOOLS_PROFILE.id,
reviewedAt: new Date("2026-05-01T00:00:00Z"),
pendingCount: 3,
tools: [
{
catalogEntryId: "g-send",
applicationId: "app-gmail",
applicationName: "Gmail",
connectionId: "conn-gmail",
connectionName: "Gmail",
toolName: "gmail.send_message",
title: "Send a message",
description: "Send a message from Gmail.",
capability: "write",
riskLevel: "write",
addedAt: new Date("2026-05-28T00:00:00Z"),
firstSeenAt: new Date("2026-05-28T00:00:00Z"),
},
{
catalogEntryId: "g-label",
applicationId: "app-gmail",
applicationName: "Gmail",
connectionId: "conn-gmail",
connectionName: "Gmail",
toolName: "gmail.modify_labels",
title: "Add or remove labels",
description: "Apply labels to existing messages.",
capability: "write",
riskLevel: "write",
addedAt: new Date("2026-05-28T00:00:00Z"),
firstSeenAt: new Date("2026-05-28T00:00:00Z"),
},
{
catalogEntryId: "g-trash",
applicationId: "app-gmail",
applicationName: "Gmail",
connectionId: "conn-gmail",
connectionName: "Gmail",
toolName: "gmail.trash_message",
title: "Move a message to trash",
description: "Move a message out of the inbox and into trash.",
capability: "destructive",
riskLevel: "destructive",
addedAt: new Date("2026-05-28T00:00:00Z"),
firstSeenAt: new Date("2026-05-28T00:00:00Z"),
},
],
};
const CONNECTIONS: ToolConnection[] = [
{
id: "conn-gmail",
companyId: COMPANY,
applicationId: "app-gmail",
name: "Gmail",
connectionKind: "managed",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
healthStatus: "ok",
healthCheckedAt: null,
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
installs: [
{
id: "install-gmail-sage",
companyId: COMPANY,
connectionId: "conn-gmail",
targetType: "agent",
targetId: "a-sage",
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
},
],
} as ToolConnection,
{
id: "conn-slack",
companyId: COMPANY,
applicationId: "app-slack",
name: "Slack",
connectionKind: "managed",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
healthStatus: "ok",
healthCheckedAt: null,
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolConnection,
];
const AGENTS = [
{ id: "a-sage", name: "Sage" },
{ id: "a-atlas", name: "Atlas" },
{ id: "a-nova", name: "Nova" },
];
// --- Seeded index host -----------------------------------------------------
function SeededIndex({
profiles,
initialStatusFilter,
initialResolverOpen,
}: {
profiles: ToolProfileWithDetails[];
initialStatusFilter?: "active" | "archived";
initialResolverOpen?: boolean;
}) {
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
c.setQueryData(queryKeys.tools.profiles(COMPANY), { profiles });
c.setQueryData(queryKeys.tools.applications(COMPANY), { applications: [] });
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: [] });
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
c.setQueryData(queryKeys.tools.effectiveProfilesForAgent(COMPANY, "a-sage"), {
agentId: "a-sage",
profiles: [DETAIL_PROFILE],
entries: DETAIL_PROFILE.entries,
bindings: DETAIL_PROFILE.bindings,
allowedTools: GMAIL_TOOLS.slice(0, 4),
allowedToolNames: GMAIL_TOOLS.slice(0, 4).map((tool) => tool.toolName),
installedConnections: [],
} satisfies ToolProfileEffectiveSummary);
c.setQueryData(queryKeys.projects.list(COMPANY), []);
c.setQueryData(queryKeys.routines.list(COMPANY), []);
return c;
}, [profiles]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-6xl p-6">
<ProfilesIndex
companyId={COMPANY}
initialStatusFilter={initialStatusFilter}
initialResolverOpen={initialResolverOpen}
/>
</div>
</QueryClientProvider>
);
}
function SeededDetail({
profile,
catalog = CATALOG,
connections = CONNECTIONS,
initialCreated,
initialReviewOpen,
}: {
profile: ToolProfileWithDetails;
catalog?: ToolCatalogEntry[];
connections?: ToolConnection[];
initialCreated?: boolean;
initialReviewOpen?: boolean;
}) {
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
c.setQueryData(queryKeys.tools.profiles(COMPANY), { profiles: [profile, ...PROFILES] });
c.setQueryData(queryKeys.tools.applications(COMPANY), {
applications: [
{ id: "app-gmail", name: "Gmail" },
{ id: "app-slack", name: "Slack" },
],
});
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: catalog.filter((tool) => tool.connectionId === "conn-gmail") });
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: catalog.filter((tool) => tool.connectionId === "conn-slack") });
c.setQueryData(queryKeys.tools.profileNewTools(profile.id), profile.newToolsPendingCount ? NEW_TOOLS_REVIEW : { profileId: profile.id, reviewedAt: null, pendingCount: 0, tools: [] });
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
c.setQueryData(queryKeys.projects.list(COMPANY), []);
c.setQueryData(queryKeys.routines.list(COMPANY), []);
return c;
}, [profile, catalog, connections]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-6xl p-6">
<ProfileDetail
companyId={COMPANY}
profileId={profile.id}
initialCreated={initialCreated}
initialReviewOpen={initialReviewOpen}
/>
</div>
</QueryClientProvider>
);
}
function SeededAgentTools() {
const allowed = [...GMAIL_TOOLS.slice(0, 6), SLACK_TOOLS[0]];
const policy: ToolPolicy = {
id: "policy-write",
companyId: COMPANY,
name: "Ask first for sending mail",
description: "Write tools need a quick human OK.",
policyType: "require_approval",
priority: 10,
enabled: true,
selectors: {},
conditions: null,
config: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
};
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
c.setQueryData(queryKeys.tools.effectiveProfilesForAgent(COMPANY, "a-sage"), {
agentId: "a-sage",
profiles: [{ ...DETAIL_PROFILE, summary: summary({ allowedToolCount: allowed.length, allowedApplicationCount: 1, assignmentCount: 1, appliesToAgentCount: 1, isCompanyDefault: true }) }],
entries: DETAIL_PROFILE.entries,
bindings: DETAIL_PROFILE.bindings,
allowedTools: allowed,
allowedToolNames: allowed.map((tool) => tool.toolName),
installedConnections: CONNECTIONS.filter((connection) => connection.id === "conn-gmail"),
} satisfies ToolProfileEffectiveSummary);
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: CONNECTIONS });
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL_TOOLS });
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK_TOOLS });
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies: [policy] });
return c;
}, [allowed, policy]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-6xl p-6">
<AgentToolsTab agent={{ id: "a-sage", name: "Sage" } as never} companyId={COMPANY} />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Tools/Access profiles",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const IndexPopulated: Story = {
name: "Index — populated",
render: () => <SeededIndex profiles={PROFILES} />,
};
export const IndexNewToolsChip: Story = {
name: "Index — new tools chip (AP1 v2)",
render: () => <SeededIndex profiles={[NEW_TOOLS_PROFILE, ...PROFILES]} />,
};
export const IndexLongProfileName: Story = {
name: "Index — long profile name",
render: () => (
<SeededIndex
profiles={[
profile(
"long-profile-name",
"This profile has an exceptionally long name that should truncate before the Allows column",
"active",
{ allowedToolCount: 14, allowedApplicationCount: 2, appliesToAgentCount: 1 },
"2026-06-12T18:00:00Z",
),
...PROFILES,
]}
/>
),
};
export const AccessCheckerSheet: Story = {
name: "Access checker sheet (AP8)",
render: () => <SeededIndex profiles={PROFILES} initialResolverOpen />,
};
export const IndexEmpty: Story = {
name: "Index — empty (template cards)",
render: () => <SeededIndex profiles={[]} />,
};
export const IndexArchived: Story = {
name: "Index — archived filter",
render: () => <SeededIndex initialStatusFilter="archived" profiles={[...PROFILES, profile("old-profile", "Archived profile", "archived", { allowedToolCount: 5, assignmentCount: 1 }, "2026-06-08T10:00:00Z")]} />,
};
export const DetailAssigned: Story = {
name: "Detail — assigned",
render: () => <SeededDetail profile={DETAIL_PROFILE} />,
};
export const DetailNewToolsBanner: Story = {
name: "Detail — new tools banner (AP16)",
render: () => <SeededDetail profile={NEW_TOOLS_PROFILE} />,
};
export const DetailNewToolsModal: Story = {
name: "Detail — new tools review modal (AP16)",
render: () => <SeededDetail profile={NEW_TOOLS_PROFILE} initialReviewOpen />,
};
export const DetailAutoAllowedRows: Story = {
name: "Detail — auto-added row notes (AP16b)",
render: () => <SeededDetail profile={AUTO_ALLOW_PROFILE} />,
};
export const AgentAccessProfilesCard: Story = {
name: "Agent page — access profiles card (AP19)",
render: () => <SeededAgentTools />,
};
export const DetailUnassignedPostCreate: Story = {
name: "Detail — unassigned post-create",
render: () => (
<SeededDetail
initialCreated
profile={{
...DETAIL_PROFILE,
id: "detail-unassigned",
bindings: [],
summary: summary({ allowedToolCount: 12, allowedApplicationCount: 1, assignmentCount: 0, appliesToAgentCount: 0 }),
}}
/>
),
};
export const DetailArchived: Story = {
name: "Detail — archived",
render: () => (
<SeededDetail
profile={{
...DETAIL_PROFILE,
id: "detail-archived",
status: "archived",
}}
/>
),
};
export const DetailDegraded: Story = {
name: "Detail — degraded",
render: () => (
<SeededDetail
profile={{
...DETAIL_PROFILE,
id: "detail-degraded",
summary: summary({ allowedToolCount: 12, allowedApplicationCount: 1, appliesToAgentCount: 2, assignmentCount: 2 }),
}}
connections={[{ ...CONNECTIONS[0], status: "disabled", healthStatus: "error" } as ToolConnection, CONNECTIONS[1]]}
/>
),
};
export const WizardStep1: Story = {
name: "Wizard — step 1 (Name)",
render: function Step1() {
const [template, setTemplate] = useState<(typeof TEMPLATES)[number]["key"] | null>("everyday");
const [name, setName] = useState("Everyday work");
const [description, setDescription] = useState("Read and make routine changes — no destructive tools.");
const [profileKey, setProfileKey] = useState("everyday");
return (
<div className="mx-auto max-w-3xl p-6">
<StepName
template={template}
onTemplate={setTemplate}
copyFromId={null}
onCopyFrom={() => {}}
copyOptions={PROFILES.filter((p) => p.status !== "draft")}
name={name}
onName={setName}
description={description}
onDescription={setDescription}
profileKey={profileKey}
onProfileKey={setProfileKey}
/>
</div>
);
},
};
export const WizardStep2: Story = {
name: "Wizard — step 2 (Choose tools)",
render: function Step2() {
const [selections, setSelections] = useState<WizardSelections>({
"app-gmail": { kind: "all" },
"app-slack": { kind: "some", included: ["s-list", "s-history"] },
});
const [rules, setRules] = useState<AdvancedRule[]>([]);
const [action, setAction] = useState<"deny" | "allow">("deny");
return (
<div className="mx-auto max-w-3xl p-6">
<WizardToolsStep
appGroups={APP_GROUPS}
catalogLoading={false}
selections={selections}
onSelectionsChange={setSelections}
advancedRules={rules}
onAdvancedRulesChange={setRules}
newToolsAction={action}
onNewToolsActionChange={setAction}
/>
</div>
);
},
};
export const WizardStep2Partial: Story = {
name: "Wizard — step 2 partial selection (AP4b)",
render: function Step2Partial() {
const [selections, setSelections] = useState<WizardSelections>({
"app-gmail": { kind: "all_except", excluded: ["g-delete", "g-purge"] },
"app-slack": { kind: "all" },
});
const [rules, setRules] = useState<AdvancedRule[]>([
{ id: "r1", kind: "risk_level", value: "destructive", riskLevel: "destructive", effect: "exclude" },
]);
const [action, setAction] = useState<"deny" | "allow">("deny");
return (
<div className="mx-auto max-w-3xl p-6">
<WizardToolsStep
appGroups={APP_GROUPS}
catalogLoading={false}
selections={selections}
onSelectionsChange={setSelections}
advancedRules={rules}
onAdvancedRulesChange={setRules}
newToolsAction={action}
onNewToolsActionChange={setAction}
/>
</div>
);
},
};
export const WizardStep3: Story = {
name: "Wizard — step 3 (Assign)",
render: function Step3() {
const [selected, setSelected] = useState<Set<string>>(new Set(["a-sage"]));
const [companyDefault, setCompanyDefault] = useState(false);
return (
<div className="mx-auto max-w-3xl p-6">
<StepAssign
agents={AGENTS}
profiles={PROFILES}
selectedAgentIds={selected}
onToggleAgent={(id) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
})
}
companyDefault={companyDefault}
onCompanyDefault={setCompanyDefault}
/>
</div>
);
},
};
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
const binding = {
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
};
const baseProps = {
toolName: "slack.post_message",
risk: "medium" as const,
isWrite: true,
binding,
input: { channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false },
reason: "This tool can write to your workspace, so a human signs off before the agent posts.",
policyNumber: 7,
expiresInLabel: "expires in 23h 51m",
};
const meta: Meta<typeof ActionCard> = {
title: "Tools & Access/ActionCard",
component: ActionCard,
};
export default meta;
type Story = StoryObj<typeof ActionCard>;
export const Pending: Story = {
render: () => (
<div className="max-w-xl p-6">
<ActionCard {...baseProps} />
</div>
),
};
export const Stale: Story = {
render: () => (
<div className="max-w-xl p-6">
<ActionCard
{...baseProps}
variant="stale"
binding={{
...binding,
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
previousCatalogSha256: binding.catalogSha256,
}}
expiresInLabel="expires in 18h 02m"
/>
</div>
),
};
export const DesktopSideBySide: Story = {
render: () => (
<div className="grid max-w-5xl gap-4 p-6 lg:grid-cols-2">
<ActionCard {...baseProps} />
<ActionCard
{...baseProps}
variant="stale"
binding={{
...binding,
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
previousCatalogSha256: binding.catalogSha256,
}}
expiresInLabel="expires in 18h 02m"
/>
</div>
),
};
export const Mobile: Story = {
render: () => (
<div className="w-[390px] bg-background p-3">
<ActionCardMobile {...baseProps} />
</div>
),
};
export const Bindings: Story = {
render: () => (
<div className="max-w-md p-6">
<BindingsTable
rows={[
{ label: "Application", value: "Slack · manifest v2.4.1" },
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
{ label: "Catalog", value: "sha256:9f86d08188…f00a08", mono: true },
{ label: "Payload", value: "sha256:2c26b46b68…66e7ae", mono: true },
]}
/>
</div>
),
};
@@ -0,0 +1,181 @@
import { useMemo } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import type { ToolGatewayActivityEvent } from "@/api/tools";
import { AuditTab } from "@/pages/tools/AuditTab";
const COMPANY = "company-storybook";
const MIN = 60 * 1000;
function event(overrides: Partial<ToolGatewayActivityEvent> = {}): ToolGatewayActivityEvent {
return {
id: "evt-1",
companyId: COMPANY,
action: "tool_gateway.call_completed",
actorType: "agent",
actorId: "agent-fable",
entityType: "issue",
entityId: "run-1",
agentId: "agent-fable",
runId: "11111111-1111-4111-8111-111111111111",
applicationId: "app-gmail",
connectionId: "conn-gmail",
agentDisplayName: "Fable",
appDisplayName: "Gmail",
applicationDisplayName: "Gmail",
connectionDisplayName: "Gmail",
toolDisplayName: "Send Email",
normalizedOutcome: "allowed",
details: {
reasonCode: "profile_allows_tool",
tool: "mail:send_email",
issueId: "issue-1",
runId: "11111111-1111-4111-8111-111111111111",
matchedPolicyIds: ["pol-allow-mail"],
},
createdAt: new Date(Date.now() - 5 * MIN).toISOString(),
...overrides,
};
}
const EVENTS: ToolGatewayActivityEvent[] = [
event(),
event({
id: "evt-2",
action: "tool_gateway.call_denied",
agentDisplayName: "Scout",
actorId: "agent-scout",
agentId: "agent-scout",
toolDisplayName: "Delete Spreadsheet",
appDisplayName: "Google Sheets",
applicationDisplayName: "Google Sheets",
connectionDisplayName: "Google Sheets",
normalizedOutcome: "blocked",
details: {
reasonCode: "deny_policy_block",
tool: "sheets:delete_spreadsheet",
issueId: "issue-2",
runId: "22222222-2222-4222-8222-222222222222",
matchedPolicyIds: ["pol-block-destructive"],
},
createdAt: new Date(Date.now() - 12 * MIN).toISOString(),
}),
event({
id: "evt-3",
action: "tool_gateway.approval_requested",
agentDisplayName: "Fable",
toolDisplayName: "Send Money",
appDisplayName: "Mercury",
applicationDisplayName: "Mercury",
connectionDisplayName: "Mercury",
normalizedOutcome: "asked_first",
details: { reasonCode: "requires_approval_policy", tool: "mercury:send_money", issueId: "issue-3" },
createdAt: new Date(Date.now() - 26 * MIN).toISOString(),
}),
event({
id: "evt-4",
action: "tool_gateway.call_failed",
agentDisplayName: "Scout",
toolDisplayName: "Create Issue",
appDisplayName: "GitHub",
applicationDisplayName: "GitHub",
connectionDisplayName: "GitHub",
normalizedOutcome: "failed",
details: { reasonCode: "tool_failed", tool: "github:create_issue", issueId: "issue-4" },
createdAt: new Date(Date.now() - 48 * MIN).toISOString(),
}),
event({
id: "evt-5",
action: "tool_gateway.call_deferred",
agentDisplayName: "Fable",
toolDisplayName: "Read Channel",
appDisplayName: "Slack",
applicationDisplayName: "Slack",
connectionDisplayName: "Slack",
normalizedOutcome: "waiting",
details: { reasonCode: "defer_runtime", tool: "slack:read_channel" },
createdAt: new Date(Date.now() - 70 * MIN).toISOString(),
}),
];
const APPLICATIONS = [
{ id: "app-gmail", name: "Gmail" },
{ id: "app-sheets", name: "Google Sheets" },
{ id: "app-mercury", name: "Mercury" },
{ id: "app-github", name: "GitHub" },
{ id: "app-slack", name: "Slack" },
];
const AGENTS = [
{ id: "agent-fable", name: "Fable" },
{ id: "agent-scout", name: "Scout" },
];
const POLICIES = [
{ id: "pol-allow-mail", name: "Fable can email customers" },
{ id: "pol-block-destructive", name: "destructive actions → Block" },
];
function defaultActivityKey() {
return queryKeys.tools.activity(COMPANY, { window: "24h" });
}
function Seeded({ events }: { events: ToolGatewayActivityEvent[] }) {
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
},
});
c.setQueryData(defaultActivityKey(), {
pages: [{ events, nextCursor: null }],
pageParams: [null],
});
// Filtered-empty: typing "refund" resolves to an empty page.
c.setQueryData(queryKeys.tools.activity(COMPANY, { window: "24h", search: "refund" }), {
pages: [{ events: [], nextCursor: null }],
pageParams: [null],
});
c.setQueryData(queryKeys.tools.applications(COMPANY), { applications: APPLICATIONS });
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies: POLICIES });
return c;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-5xl p-6">
<AuditTab companyId={COMPANY} />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Tools/Activity (audit feed)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const Populated: Story = {
name: "Populated",
render: () => <Seeded events={EVENTS} />,
};
export const RowExpanded: Story = {
name: "Row expanded (use the chevron)",
render: () => <Seeded events={EVENTS} />,
};
export const TrueEmpty: Story = {
name: "Empty",
render: () => <Seeded events={[]} />,
};
export const FilteredEmpty: Story = {
name: "Filtered empty (type 'refund' in search)",
render: () => <Seeded events={EVENTS} />,
};
@@ -0,0 +1,150 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type {
Agent,
ToolCallEvent,
ToolConnectionLifecycleEvent,
} from "@paperclipai/shared";
import { ActivityPanel } from "@/pages/apps/app-detail/ActivityPanel";
import type { ActivityPanelProps } from "@/pages/apps/app-detail/types";
const CONNECTION_ID = "conn-sheets";
const MIN = 60 * 1000;
const AGENTS = [
{ id: "agent-coder", name: "Coder" },
{ id: "agent-scout", name: "Scout" },
] as unknown as Agent[];
function callEvent(overrides: Partial<ToolCallEvent>): ToolCallEvent {
return {
id: "evt",
companyId: "company-storybook",
eventType: "call_completed",
actorType: "agent",
actorId: "agent-coder",
agentId: "agent-coder",
runId: null,
issueId: null,
applicationId: "app-sheets",
connectionId: CONNECTION_ID,
catalogEntryId: null,
invocationId: null,
actionRequestId: null,
runtimeSlotId: null,
toolName: "Get value",
decision: "allow",
matchedPolicyIds: [],
reasonCode: null,
outcome: "success",
latencyMs: 120,
requestHash: null,
requestSummary: null,
resultHash: null,
resultSummary: null,
resultSizeBytes: null,
redactionPlan: null,
rateLimitState: null,
metadata: null,
errorCode: null,
errorMessage: null,
createdAt: new Date(Date.now() - 40 * MIN),
...overrides,
} as ToolCallEvent;
}
function lifecycle(overrides: Partial<ToolConnectionLifecycleEvent>): ToolConnectionLifecycleEvent {
return {
id: "life",
connectionId: CONNECTION_ID,
type: "app_connected",
actorType: "user",
actorId: "user-board",
agentId: null,
actorDisplayName: "Dotta",
details: null,
createdAt: new Date(Date.now() - 90 * MIN),
...overrides,
};
}
const LIFECYCLE_EVENTS: ToolConnectionLifecycleEvent[] = [
lifecycle({ id: "life-connected", type: "app_connected", createdAt: new Date(Date.now() - 180 * MIN) }),
lifecycle({
id: "life-allowlist",
type: "allowlist_changed",
details: { added: 1, removed: 0, total: 3 },
createdAt: new Date(Date.now() - 120 * MIN),
}),
lifecycle({
id: "life-quarantine",
type: "actions_quarantined",
actorType: "system",
actorId: null,
actorDisplayName: null,
details: { count: 2 },
createdAt: new Date(Date.now() - 60 * MIN),
}),
lifecycle({ id: "life-paused", type: "app_paused", details: { enabled: false }, createdAt: new Date(Date.now() - 25 * MIN) }),
lifecycle({ id: "life-resumed", type: "app_resumed", details: { enabled: true }, createdAt: new Date(Date.now() - 10 * MIN) }),
];
const CALL_EVENTS: ToolCallEvent[] = [
callEvent({ id: "evt-1", toolName: "Get value", createdAt: new Date(Date.now() - 40 * MIN) }),
callEvent({
id: "evt-2",
eventType: "call_denied",
toolName: "Delete spreadsheet",
outcome: "denied",
createdAt: new Date(Date.now() - 15 * MIN),
}),
];
function panelProps(overrides: Partial<ActivityPanelProps> = {}): ActivityPanelProps {
return {
events: CALL_EVENTS,
lifecycleEvents: LIFECYCLE_EVENTS,
issues: {},
actionRequests: {},
loading: false,
agents: AGENTS,
connectionId: CONNECTION_ID,
appName: "Google Sheets (stdio smoke)",
...overrides,
};
}
function Panel(props: ActivityPanelProps) {
return (
<div className="mx-auto max-w-2xl p-6">
<ActivityPanel {...props} />
</div>
);
}
const meta: Meta = {
title: "Apps/App detail · Activity (lifecycle)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const MixedTimeline: Story = {
name: "Tool calls + lifecycle",
render: () => <Panel {...panelProps()} />,
};
export const LifecycleOnly: Story = {
name: "Lifecycle only",
render: () => <Panel {...panelProps({ events: [] })} />,
};
export const Loading: Story = {
name: "Loading",
render: () => <Panel {...panelProps({ loading: true })} />,
};
export const Empty: Story = {
name: "Empty",
render: () => <Panel {...panelProps({ events: [], lifecycleEvents: [] })} />,
};
@@ -0,0 +1,232 @@
import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
TOOL_APP_GALLERY,
type AppGalleryEntry,
type McpJsonImportPreview,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { AppsConnect } from "@/pages/apps/AppsConnect";
import { PasteConfigTab } from "@/pages/tools/PasteConfigTab";
/**
* PAP-11091 — discoverability copy for remote MCP URLs.
*
* Two surfaces, captured for the UXDesigner re-review:
* 1. Apps Connect gallery — the "Connect with a link" field now advertises that
* any remote tool URL (incl. a local MCP server) works, with a localhost
* example in the placeholder.
* 2. The Advanced "Paste a config" tab — a hint that routes a bare URL back to
* the gallery's link flow.
*/
const COMPANY = "company-storybook";
const GALLERY: AppGalleryEntry[] = TOOL_APP_GALLERY.slice(0, 6) as AppGalleryEntry[];
function seededClient() {
const c = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
},
});
c.setQueryData(queryKeys.apps.gallery(COMPANY), { apps: GALLERY });
return c;
}
function GalleryHost() {
const client = useMemo(() => seededClient(), []);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-4xl p-6">
<AppsConnect />
</div>
</QueryClientProvider>
);
}
function PasteConfigHost() {
const client = useMemo(() => seededClient(), []);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-3xl p-6">
<PasteConfigTab companyId={COMPANY} />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Apps/Connect discoverability (PAP-11091)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const GalleryLinkAffordance: Story = {
name: "Gallery — Connect with a link",
render: () => <GalleryHost />,
};
export const PasteConfigRedirectHint: Story = {
name: "Paste a config — redirect hint",
render: () => <PasteConfigHost />,
};
/**
* PAP-11092 / PAP-11094 — preview states after "Check config" runs.
*
* Stubs the import-json POST so the preview renders without a backend, then
* auto-fills the textarea and clicks "Check config" so the screenshot lands on
* the activation hand-off (Continue buttons + footer copy).
*/
function PreviewHost({ preview, snippet }: { preview: McpJsonImportPreview; snippet: string }) {
const client = useMemo(() => seededClient(), []);
const ranRef = useRef(false);
useEffect(() => {
const realFetch = window.fetch;
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/tools/mcp/import-json")) {
return new Response(JSON.stringify(preview), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return realFetch(input as RequestInfo, init);
}) as typeof window.fetch;
return () => {
window.fetch = realFetch;
};
}, [preview]);
useEffect(() => {
if (ranRef.current) return;
let cancelled = false;
const tick = window.setInterval(() => {
if (cancelled) return;
const textarea = document.querySelector<HTMLTextAreaElement>("textarea");
const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((b) =>
b.textContent?.trim().startsWith("Check config"),
);
if (!textarea || !button) return;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(textarea, snippet);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
window.setTimeout(() => {
button.click();
ranRef.current = true;
window.clearInterval(tick);
}, 30);
}, 50);
return () => {
cancelled = true;
window.clearInterval(tick);
};
}, [snippet]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-3xl p-6">
<PasteConfigTab companyId={COMPANY} />
</div>
</QueryClientProvider>
);
}
const REMOTE_PREVIEW: McpJsonImportPreview = {
drafts: [
{
name: "kv-demo",
transport: "remote_http",
status: "draft",
config: { url: "http://127.0.0.1:8848/mcp" },
credentialRefs: [],
credentialFields: [],
warnings: [],
},
],
};
const MIXED_PREVIEW: McpJsonImportPreview = {
drafts: [
{
name: "linear",
transport: "remote_http",
status: "draft",
config: { url: "https://mcp.linear.app/sse" },
credentialRefs: [
{ name: "LINEAR_API_KEY", secretId: "draft-1", placement: "header", key: "LINEAR_API_KEY" },
],
credentialFields: [{
configPath: "headers.LINEAR_API_KEY",
label: "LINEAR_API_KEY",
placement: "header",
key: "LINEAR_API_KEY",
prefix: null,
required: true,
}],
warnings: [],
},
{
name: "github",
transport: "local_stdio",
status: "draft",
config: { importedCommand: "npx -y @modelcontextprotocol/server-github", importedArgs: [] },
credentialRefs: [
{ name: "GITHUB_TOKEN", secretId: "draft-2", placement: "env", key: "GITHUB_TOKEN" },
],
credentialFields: [],
warnings: ["Imported stdio commands stay draft-only unless mapped to an approved Paperclip template."],
},
],
};
const STDIO_PREVIEW: McpJsonImportPreview = {
drafts: [
{
name: "github",
transport: "local_stdio",
status: "draft",
config: { importedCommand: "npx -y @modelcontextprotocol/server-github", importedArgs: [] },
credentialRefs: [
{ name: "GITHUB_TOKEN", secretId: "draft-3", placement: "env", key: "GITHUB_TOKEN" },
],
credentialFields: [],
warnings: ["Imported stdio commands stay draft-only unless mapped to an approved Paperclip template."],
},
],
};
export const PasteConfigPreviewRemote: Story = {
name: "Paste a config — remote draft (check actions)",
render: () => (
<PreviewHost
preview={REMOTE_PREVIEW}
snippet='{ "mcpServers": { "kv-demo": { "url": "http://127.0.0.1:8848/mcp" } } }'
/>
),
};
export const PasteConfigPreviewMixed: Story = {
name: "Paste a config — mixed remote + stdio",
render: () => (
<PreviewHost
preview={MIXED_PREVIEW}
snippet='{ "mcpServers": { "linear": { "url": "https://mcp.linear.app/sse" }, "github": { "command": "npx -y @modelcontextprotocol/server-github" } } }'
/>
),
};
export const PasteConfigPreviewStdioOnly: Story = {
name: "Paste a config — stdio only (draft, no activation)",
render: () => (
<PreviewHost
preview={STDIO_PREVIEW}
snippet='{ "mcpServers": { "github": { "command": "npx -y @modelcontextprotocol/server-github" } } }'
/>
),
};
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TOOL_APP_GALLERY, type AppGalleryEntry } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { AppsConnect } from "@/pages/apps/AppsConnect";
/**
* PAP-11283 — the gallery Connect wizard names the connection at create time.
*
* The key step now leads with a Name field (default = the app name) so two
* connections to the same app (e.g. a stdio and an HTTP Google Sheets) can be
* told apart in the Apps list without a detail-page rename hop.
*/
const COMPANY = "company-storybook";
// Zapier is an api_key gallery app, so the key step renders both the new Name
// field and a credential input — a representative shape for this screenshot.
const ZAPIER = TOOL_APP_GALLERY.find((e) => e.key === "zapier") as AppGalleryEntry;
const GALLERY: AppGalleryEntry[] = [ZAPIER];
function seededClient() {
const c = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
},
});
c.setQueryData(queryKeys.apps.gallery(COMPANY), { apps: GALLERY });
return c;
}
/**
* Auto-advances into the key step by clicking the first gallery card, then —
* for the custom-name variant — types a distinct name so the screenshot lands
* on the edited field.
*/
function KeyStepHost({ customName }: { customName?: string }) {
const client = useMemo(() => seededClient(), []);
const ranRef = useRef(false);
useEffect(() => {
if (ranRef.current) return;
let cancelled = false;
const tick = window.setInterval(() => {
if (cancelled) return;
const card = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((b) =>
b.textContent?.includes("Zapier"),
);
if (!card) return;
card.click();
ranRef.current = true;
window.clearInterval(tick);
if (customName) {
window.setTimeout(() => {
const nameInput = Array.from(document.querySelectorAll<HTMLInputElement>("input")).find(
(i) => i.getAttribute("placeholder") === "My app",
);
if (!nameInput) return;
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(nameInput, customName);
nameInput.dispatchEvent(new Event("input", { bubbles: true }));
}, 30);
}
}, 50);
return () => {
cancelled = true;
window.clearInterval(tick);
};
}, [customName]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-4xl p-6">
<AppsConnect />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Apps/Connect name at create time (PAP-11283)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const DefaultName: Story = {
name: "Gallery key step — default name",
render: () => <KeyStepHost />,
};
export const CustomName: Story = {
name: "Gallery key step — custom name",
render: () => <KeyStepHost customName="Zapier (stdio smoke)" />,
};
@@ -0,0 +1,265 @@
import { useMemo } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
import { ToastProvider } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { GatewaysTab } from "@/pages/tools/GatewaysTab";
const COMPANY = "company-storybook";
const POPULATED: ToolMcpGatewayWithTokens[] = [
{
id: "gw-1",
companyId: COMPANY,
name: "Engineering inbox",
slug: "engineering-inbox",
description: "Shared MCP endpoint for the engineering org's editors and IDEs.",
status: "active",
profileId: "profile-eng",
agentId: null,
projectId: null,
issueId: null,
endpointPath: "/api/mcp/gateways/engineering-inbox",
metadata: null,
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-06-10T12:00:00Z"),
updatedAt: new Date("2026-06-15T18:00:00Z"),
tokens: [
{
id: "tok-1",
companyId: COMPANY,
gatewayId: "gw-1",
name: "Dotta's MacBook",
expiresAt: new Date("2026-09-01T00:00:00Z"),
lastUsedAt: new Date("2026-06-16T08:00:00Z"),
revokedAt: null,
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-06-10T12:00:00Z"),
updatedAt: new Date("2026-06-16T08:00:00Z"),
},
{
id: "tok-2",
companyId: COMPANY,
gatewayId: "gw-1",
name: "CI runner",
expiresAt: null,
lastUsedAt: null,
revokedAt: null,
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-06-11T09:00:00Z"),
updatedAt: new Date("2026-06-11T09:00:00Z"),
},
{
id: "tok-3",
companyId: COMPANY,
gatewayId: "gw-1",
name: "Old laptop (rotated)",
expiresAt: null,
lastUsedAt: new Date("2026-04-01T10:00:00Z"),
revokedAt: new Date("2026-05-15T16:00:00Z"),
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-04-01T09:00:00Z"),
updatedAt: new Date("2026-05-15T16:00:00Z"),
},
],
clientSnippets: [
{
client: "cursor",
label: "Cursor",
config: {
mcpServers: {
"paperclip-engineering-inbox": {
url: "https://paperclip.app/api/mcp/gateways/engineering-inbox",
headers: { Authorization: "Bearer YOUR_TOKEN" },
},
},
},
notes: ["Paste into ~/.cursor/mcp.json"],
},
{
client: "claude_desktop",
label: "Claude Desktop",
config: {
mcpServers: {
"paperclip-engineering-inbox": {
url: "https://paperclip.app/api/mcp/gateways/engineering-inbox",
headers: { Authorization: "Bearer YOUR_TOKEN" },
},
},
},
notes: ["Edit ~/Library/Application Support/Claude/claude_desktop_config.json"],
},
{
client: "vscode",
label: "VS Code",
config: {
servers: {
"paperclip-engineering-inbox": {
type: "http",
url: "https://paperclip.app/api/mcp/gateways/engineering-inbox",
headers: { Authorization: "Bearer YOUR_TOKEN" },
},
},
},
notes: [],
},
],
} as unknown as ToolMcpGatewayWithTokens,
{
id: "gw-2",
companyId: COMPANY,
name: "Support ops (read-only)",
slug: "support-ops",
description: null,
status: "active",
profileId: "profile-support",
agentId: null,
projectId: null,
issueId: null,
endpointPath: "/api/mcp/gateways/support-ops",
metadata: null,
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-06-12T10:00:00Z"),
updatedAt: new Date("2026-06-12T10:00:00Z"),
tokens: [],
clientSnippets: [
{
client: "claude_code",
label: "Claude Code",
config: {
mcpServers: {
"paperclip-support-ops": {
url: "https://paperclip.app/api/mcp/gateways/support-ops",
headers: { Authorization: "Bearer YOUR_TOKEN" },
},
},
},
notes: [],
},
],
} as unknown as ToolMcpGatewayWithTokens,
{
id: "gw-3",
companyId: COMPANY,
name: "Disabled legacy gateway with an unusually long human-readable name for truncation testing",
slug: "legacy-long",
description:
"Earlier shared endpoint kept around for one Cursor user who hasn't migrated yet. Will be removed in July.",
status: "disabled",
profileId: "profile-legacy",
agentId: null,
projectId: null,
issueId: null,
endpointPath: "/api/mcp/gateways/legacy-very-long-slug-for-overflow-testing-of-the-mono-chip-on-mobile-and-desktop",
metadata: null,
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-03-01T10:00:00Z"),
updatedAt: new Date("2026-06-01T10:00:00Z"),
tokens: [
{
id: "tok-4",
companyId: COMPANY,
gatewayId: "gw-3",
name: "Legacy IDE",
expiresAt: new Date("2026-06-30T00:00:00Z"),
lastUsedAt: null,
revokedAt: null,
createdByAgentId: null,
createdByUserId: "user-dotta",
createdAt: new Date("2026-03-01T10:00:00Z"),
updatedAt: new Date("2026-03-01T10:00:00Z"),
},
],
clientSnippets: [],
} as unknown as ToolMcpGatewayWithTokens,
];
function seededClient(gateways: ToolMcpGatewayWithTokens[]) {
const c = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
},
});
c.setQueryData(["tools", "gateways", COMPANY], { gateways });
c.setQueryData(queryKeys.tools.profiles(COMPANY), {
profiles: [
profile("profile-eng", "Engineering", 18),
profile("profile-support", "Support ops", 7),
profile("profile-legacy", "Legacy read-only", 3),
],
});
c.setQueryData(queryKeys.agents.list(COMPANY), []);
c.setQueryData(queryKeys.projects.list(COMPANY), []);
return c;
}
function profile(id: string, name: string, allowedToolCount: number) {
return {
id,
companyId: COMPANY,
profileKey: id,
name,
description: null,
status: "active",
defaultAction: "deny",
newToolsReviewedAt: null,
metadata: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
entries: [],
bindings: [],
summary: {
accessMode: "selected",
allowedToolCount,
allowedApplicationCount: 2,
excludedToolCount: 0,
totalToolCount: 24,
assignmentCount: 1,
appliesToAgentCount: 0,
isCompanyDefault: false,
},
};
}
function GatewaysHost({ gateways }: { gateways: ToolMcpGatewayWithTokens[] }) {
const client = useMemo(() => seededClient(gateways), [gateways]);
return (
<QueryClientProvider client={client}>
<ToastProvider>
<div className="mx-auto max-w-4xl p-4 sm:p-6">
<GatewaysTab companyId={COMPANY} />
</div>
</ToastProvider>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Tools/Gateways (PAP-11182)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const Empty: Story = {
name: "Gateways — empty state",
render: () => <GatewaysHost gateways={[]} />,
};
export const Populated: Story = {
name: "Gateways — populated",
render: () => <GatewaysHost gateways={POPULATED} />,
};
export const SingleGateway: Story = {
name: "Gateways — single gateway with no tokens",
render: () => <GatewaysHost gateways={[POPULATED[1]]} />,
};
@@ -11,8 +11,15 @@ import {
acceptedRequestConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
commentExpiredRequestConfirmationInteraction,
declinedToolActionInteraction,
executedToolActionInteraction,
expiredToolActionInteraction,
failedRequestConfirmationInteraction,
failedToolActionInteraction,
genericPendingRequestConfirmationInteraction,
pendingToolActionDestructiveInteraction,
pendingToolActionWriteInteraction,
runningToolActionInteraction,
issueThreadInteractionComments,
issueThreadInteractionEvents,
issueThreadInteractionFixtureMeta,
@@ -647,6 +654,189 @@ export const RequestConfirmationFailed: Story = {
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
// ---------------------------------------------------------------------------
// MCP tool-approval card (PAP-13745). A `request_confirmation` carrying a
// `payload.toolAction` renders as the dedicated tool-approval card: identity
// header, humanized preview, technical-details drawer, and lifecycle states.
// The governing rule — approve = run — means no terminal state reads "Accepted".
// ---------------------------------------------------------------------------
function ToolActionCard({
interaction,
interactive = false,
}: {
interaction: RequestConfirmationInteraction;
interactive?: boolean;
}) {
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={interactive ? () => undefined : undefined}
onRejectInteraction={interactive ? () => undefined : undefined}
/>
);
}
export const ToolActionPendingWrite: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending · write"
description="A write tool call awaits approval: identity header, WRITE risk badge, humanized preview, collapsible technical details, expiry countdown, and an Approve & run CTA."
>
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionPendingDestructive: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending · destructive"
description="A destructive call takes the red risk badge and a destructive primary button; the countdown sits inside the sub-5-minute urgent window."
>
<ToolActionCard interaction={pendingToolActionDestructiveInteraction} interactive />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionRunning: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Approved — running…"
description="The transient post-approve state: an amber spinner strip that self-resolves to Executed or Failed when the gateway writes back. Buttons are removed to prevent double-submit."
>
<ToolActionCard interaction={runningToolActionInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionExecuted: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Executed"
description="The green terminal state reports the real outcome — a result summary plus a deep-link — never a bare checkmark or “Accepted”."
>
<ToolActionCard interaction={executedToolActionInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionFailed: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Failed"
description="You approved it and it ran, but the connector errored: amber card, red icon, and the verbatim connector error. Distinct from Declined."
>
<ToolActionCard interaction={failedToolActionInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionDeclined: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Declined"
description="You rejected it and nothing ran: red card with a dimmed/greyscale identity header and the decline reason attached."
>
<ToolActionCard interaction={declinedToolActionInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionExpired: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Expired"
description="Nobody responded within 60 minutes: a neutral grey (not error-red) card that always states the rule and the recovery path — the agent can request approval again."
>
<ToolActionCard interaction={expiredToolActionInteraction} />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionLegacyGeneric: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Legacy · no toolAction"
description="A confirmation without a toolAction payload keeps the existing generic rendering unchanged — the tool-approval surface is strictly additive."
>
<ToolActionCard interaction={genericPendingRequestConfirmationInteraction} interactive />
</ScenarioCard>
</StoryFrame>
),
};
export const ToolActionAllStates: Story = {
render: () => (
<StoryFrame>
<Section eyebrow="MCP Tool Approval" title="All six lifecycle states (PAP-13745)">
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard title="1 · Pending (write)" description="Awaiting approval — Approve & run.">
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
</ScenarioCard>
<ScenarioCard title="1b · Pending (destructive)" description="Red risk badge, urgent countdown.">
<ToolActionCard interaction={pendingToolActionDestructiveInteraction} interactive />
</ScenarioCard>
<ScenarioCard title="2 · Approved — running…" description="Transient, self-resolving spinner.">
<ToolActionCard interaction={runningToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="3 · Executed" description="Green, with a result summary.">
<ToolActionCard interaction={executedToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="4 · Failed" description="Ran, but the connector errored.">
<ToolActionCard interaction={failedToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="5 · Declined" description="Rejected — nothing ran.">
<ToolActionCard interaction={declinedToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="6 · Expired" description="No response in 60 min.">
<ToolActionCard interaction={expiredToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="Legacy · no toolAction" description="Unchanged generic rendering.">
<ToolActionCard interaction={genericPendingRequestConfirmationInteraction} interactive />
</ScenarioCard>
</div>
</Section>
</StoryFrame>
),
};
export const ToolActionMobile: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Mobile · 390"
description="Single column: risk badge wraps under the tool name, actions stack full-width, the technical drawer stays collapsed."
>
<div className="mx-auto max-w-[358px]">
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
</div>
</ScenarioCard>
</StoryFrame>
),
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
export const CheckboxConfirmationPending: Story = {
render: () => (
<StoryFrame>
@@ -0,0 +1,263 @@
import { useMemo, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
Agent,
ToolCatalogEntry,
ToolConnection,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { AgentToolsTab } from "@/pages/AgentToolsTab";
import { PermissionsPanel } from "@/pages/apps/app-detail/PermissionsPanel";
import { InstallStep } from "@/pages/apps/AppsConnect";
import type { AccessDraft } from "@/pages/apps/app-detail/types";
import type { InstallState } from "@/lib/tool-installs";
// ---------------------------------------------------------------------------
// Phase 3b — Permitted vs Installed UX review harness (PAP-13634).
// Renders the three changed surfaces at a real viewport so visual craft can be
// signed off. Fixtures are self-contained; casts keep the stories terse.
// ---------------------------------------------------------------------------
const COMPANY = "company-review";
const AGENTS: Agent[] = [
{ id: "a-sage", name: "Sage", status: "active" },
{ id: "a-atlas", name: "Atlas", status: "active" },
{ id: "a-nova", name: "Nova", status: "active" },
{ id: "a-orion", name: "Orion", status: "active" },
] as unknown as Agent[];
function tool(
id: string,
toolName: string,
cap: "read" | "write" | "destructive",
title: string,
connectionId: string,
): ToolCatalogEntry {
return {
id,
companyId: COMPANY,
applicationId: "app-gmail",
connectionId,
entryKind: "tool",
toolName,
title,
description: title,
inputSchema: null,
outputSchema: null,
annotations: null,
riskLevel: cap,
isReadOnly: cap === "read",
isWrite: cap === "write",
isDestructive: cap === "destructive",
status: "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
} as unknown as ToolCatalogEntry;
}
const GMAIL_TOOLS = [
tool("g-list", "gmail.list", "read", "List messages", "conn-gmail"),
tool("g-read", "gmail.read", "read", "Read message", "conn-gmail"),
tool("g-send", "gmail.send", "write", "Send message", "conn-gmail"),
];
const SLACK_TOOLS = [tool("s-list", "slack.list", "read", "List channels", "conn-slack")];
function connection(id: string, name: string, installs: InstallState): ToolConnection {
const rows = [
...(installs.onAll ? [{ targetType: "company", targetId: COMPANY }] : []),
...[...installs.agentIds].map((targetId) => ({ targetType: "agent", targetId })),
].map((r, i) => ({
id: `${id}-install-${i}`,
companyId: COMPANY,
connectionId: id,
...r,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
}));
return {
id,
companyId: COMPANY,
name,
status: "active",
installs: rows,
} as unknown as ToolConnection;
}
const meta: Meta = {
title: "Tools/Permitted vs Installed",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
// --- Surface 1: App detail Permissions tab (PermissionsPanel) --------------
function PanelHarness({ access, install }: { access: AccessDraft; install: InstallState }) {
const [state, setState] = useState(install);
return (
<div className="mx-auto max-w-3xl bg-background p-6">
<PermissionsPanel
appName="Gmail"
access={access}
agents={AGENTS}
install={state}
readOnly={GMAIL_TOOLS.filter((t) => t.isReadOnly)}
canChange={GMAIL_TOOLS.filter((t) => !t.isReadOnly)}
quarantined={[]}
enabledIds={new Set(["g-list", "g-read"])}
askFirstIds={new Set(["g-send"])}
pending={false}
installPending={false}
refreshPending={false}
onSaveAccess={() => {}}
onSaveInstall={setState}
onSetActionPermission={() => {}}
onTurnOnQuarantined={() => {}}
onRefreshActions={() => {}}
/>
</div>
);
}
export const AppDetailInstalledMixed: Story = {
name: "1 · App detail — mixed install + auto-extend warning",
render: () => (
<PanelHarness
access={{ mode: "specific", agentIds: new Set(["a-sage", "a-atlas"]) }}
install={{ onAll: false, agentIds: new Set(["a-sage", "a-orion"]) }}
/>
),
};
export const AppDetailInstalledOnAll: Story = {
name: "1 · App detail — installed on all agents",
render: () => (
<PanelHarness
access={{ mode: "all", agentIds: new Set() }}
install={{ onAll: true, agentIds: new Set() }}
/>
),
};
export const AppDetailPermittedOnly: Story = {
name: "1 · App detail — permitted only (not installed)",
render: () => (
<PanelHarness
access={{ mode: "all", agentIds: new Set() }}
install={{ onAll: false, agentIds: new Set() }}
/>
),
};
// --- Surface 2: Agent detail Tools tab (AgentToolsTab) ---------------------
function SeededAgentTools() {
const allowed = [...GMAIL_TOOLS, SLACK_TOOLS[0]];
const connections = [
connection("conn-gmail", "Gmail", { onAll: false, agentIds: new Set(["a-sage"]) }),
connection("conn-slack", "Slack", { onAll: false, agentIds: new Set() }),
];
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
c.setQueryData(queryKeys.tools.effectiveProfilesForAgent(COMPANY, "a-sage"), {
agentId: "a-sage",
profiles: [],
bindings: [],
allowedTools: allowed,
allowedToolNames: allowed.map((t) => t.toolName),
installedConnections: connections.filter((conn) => conn.id === "conn-gmail"),
});
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL_TOOLS });
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK_TOOLS });
return c;
}, []);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-6xl bg-background p-6">
<AgentToolsTab agent={{ id: "a-sage", name: "Sage" } as never} companyId={COMPANY} />
</div>
</QueryClientProvider>
);
}
export const AgentToolsInstalledApps: Story = {
name: "2 · Agent Tools tab — Installed apps + badges",
render: () => <SeededAgentTools />,
};
// --- Surface 3: Connect flow Install step (InstallStep) --------------------
function SeededInstallStep({
access,
accessAgentIds,
initialMode,
initialInstall,
}: {
access: "all" | "specific";
accessAgentIds: Set<string>;
initialMode: "none" | "specific" | "all";
initialInstall: Set<string>;
}) {
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
return c;
}, []);
const [mode, setMode] = useState(initialMode);
const [ids, setIds] = useState(initialInstall);
return (
<QueryClientProvider client={client}>
<div className="bg-background p-6">
<InstallStep
appName="Gmail"
companyId={COMPANY}
access={access}
accessAgentIds={accessAgentIds}
installMode={mode}
setInstallMode={setMode}
installAgentIds={ids}
setInstallAgentIds={setIds}
submitting={false}
onBack={() => {}}
onFinish={() => {}}
/>
</div>
</QueryClientProvider>
);
}
export const ConnectInstallSpecific: Story = {
name: "3 · Connect — Install step (specific + auto-extend)",
render: () => (
<SeededInstallStep
access="specific"
accessAgentIds={new Set(["a-sage", "a-atlas"])}
initialMode="specific"
initialInstall={new Set(["a-sage", "a-orion"])}
/>
),
};
export const ConnectInstallAll: Story = {
name: "3 · Connect — Install step (all agents)",
render: () => (
<SeededInstallStep
access="all"
accessAgentIds={new Set()}
initialMode="all"
initialInstall={new Set()}
/>
),
};
@@ -0,0 +1,311 @@
import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ToolCatalogEntry, ToolConnection, ToolPolicy } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { PoliciesTab } from "@/pages/tools/PoliciesTab";
const COMPANY = "company-storybook";
function makeTool(
id: string,
toolName: string,
cap: "read" | "write" | "destructive",
title: string,
applicationId: string,
connectionId: string,
): ToolCatalogEntry {
return {
id,
companyId: COMPANY,
applicationId,
connectionId,
entryKind: "tool",
name: toolName,
toolName,
title,
description: title,
inputSchema: null,
outputSchema: null,
annotations: null,
riskLevel: cap,
isReadOnly: cap === "read",
isWrite: cap !== "read",
isDestructive: cap === "destructive",
status: "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
versionHash: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolCatalogEntry;
}
const GMAIL: ToolCatalogEntry[] = [
makeTool("g-list", "gmail.list_messages", "read", "List messages", "app-gmail", "conn-gmail"),
makeTool("g-read", "gmail.get_message", "read", "Read a message", "app-gmail", "conn-gmail"),
makeTool("g-send", "gmail.send_message", "write", "Send a message", "app-gmail", "conn-gmail"),
makeTool("g-draft", "gmail.create_draft", "write", "Create a draft", "app-gmail", "conn-gmail"),
makeTool("g-trash", "gmail.trash_message", "destructive", "Move to trash", "app-gmail", "conn-gmail"),
makeTool("g-delete", "gmail.delete_message", "destructive", "Permanently delete", "app-gmail", "conn-gmail"),
];
const SLACK: ToolCatalogEntry[] = [
makeTool("s-list", "slack.list_channels", "read", "List channels", "app-slack", "conn-slack"),
makeTool("s-post", "slack.post_message", "write", "Post a message", "app-slack", "conn-slack"),
makeTool("s-archive", "slack.archive_channel", "destructive", "Archive a channel", "app-slack", "conn-slack"),
];
const CATALOG: ToolCatalogEntry[] = [...GMAIL, ...SLACK];
const CONNECTIONS: ToolConnection[] = [
{
id: "conn-gmail",
companyId: COMPANY,
applicationId: "app-gmail",
name: "Gmail",
connectionKind: "managed",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
healthStatus: "ok",
healthCheckedAt: null,
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolConnection,
{
id: "conn-slack",
companyId: COMPANY,
applicationId: "app-slack",
name: "Slack",
connectionKind: "managed",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
healthStatus: "ok",
healthCheckedAt: null,
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolConnection,
];
const AGENTS = [
{ id: "agent-fable", name: "Fable" },
{ id: "agent-sage", name: "Sage" },
{ id: "agent-atlas", name: "Atlas" },
];
const PROJECTS = [
{ id: "project-launch", name: "Launch" },
{ id: "project-support", name: "Support" },
];
function rule(partial: Partial<ToolPolicy> & { id: string; policyType: ToolPolicy["policyType"]; priority: number }): ToolPolicy {
return {
id: partial.id,
companyId: COMPANY,
name: partial.name ?? partial.id,
description: partial.description ?? null,
policyType: partial.policyType,
priority: partial.priority,
enabled: partial.enabled ?? true,
selectors: partial.selectors ?? {},
conditions: partial.conditions ?? null,
config: partial.config ?? null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-11T00:00:00Z"),
};
}
const POLICIES: ToolPolicy[] = [
rule({
id: "p1",
name: "Block destructive actions everywhere",
policyType: "block",
priority: 50,
selectors: { riskLevel: "destructive" },
}),
rule({
id: "p2",
name: "Ask first when Fable sends mail",
policyType: "require_approval",
priority: 100,
selectors: { agentId: "agent-fable", toolName: "gmail.send_message" },
}),
rule({
id: "p3",
name: "Limit Slack posts",
policyType: "rate_limit",
priority: 150,
selectors: { toolName: "slack.post_message" },
config: { rateLimit: { limit: 25, windowSeconds: 3600, keyBy: ["agent", "tool"] } },
}),
rule({
id: "p4",
name: "Allow Sage to use Gmail",
policyType: "allow",
priority: 200,
selectors: { agentId: "agent-sage", applicationId: "app-gmail" },
}),
rule({
id: "p5",
name: "Block critical actions",
policyType: "block",
priority: 300,
selectors: { riskLevel: "critical" },
}),
];
const TRUST_RULES: ToolPolicy[] = [
rule({
id: "trust-1",
policyType: "trust_rule",
priority: 1000,
selectors: { agentId: "agent-fable", toolName: "gmail.send_message" },
}),
rule({
id: "trust-2",
policyType: "trust_rule",
priority: 1000,
selectors: { agentId: "agent-sage", toolName: "slack.post_message" },
}),
];
const AUDIT_HITS = [
{ policyId: "p1", count: 14 },
{ policyId: "p2", count: 6 },
{ policyId: "p3", count: 22 },
{ policyId: "p4", count: 3 },
{ policyId: "p5", count: 1 },
];
function buildAudit() {
const now = Date.now();
const rows: Array<{ createdAt: string; details: { matchedPolicyIds: string[] } }> = [];
for (const hit of AUDIT_HITS) {
for (let i = 0; i < hit.count; i += 1) {
rows.push({
createdAt: new Date(now - i * 60_000).toISOString(),
details: { matchedPolicyIds: [hit.policyId] },
});
}
}
return rows;
}
function seededClient({
policies,
trustRules,
}: {
policies: ToolPolicy[];
trustRules: ToolPolicy[];
}) {
const c = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
},
});
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies });
c.setQueryData(queryKeys.tools.trustRules(COMPANY), { trustRules });
c.setQueryData(queryKeys.tools.audit(COMPANY, 250), buildAudit());
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
c.setQueryData(queryKeys.projects.list(COMPANY), PROJECTS);
c.setQueryData(queryKeys.tools.applications(COMPANY), {
applications: [
{ id: "app-gmail", name: "Gmail" },
{ id: "app-slack", name: "Slack" },
],
});
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: CONNECTIONS });
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL });
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK });
return c;
}
function findButtonByText(label: string): HTMLButtonElement | null {
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("button"));
return buttons.find((b) => (b.textContent ?? "").trim() === label) ?? null;
}
function PoliciesHost({
policies = POLICIES,
trustRules = TRUST_RULES,
autoClick,
}: {
policies?: ToolPolicy[];
trustRules?: ToolPolicy[];
autoClick?: "new-rule" | "test-rule";
}) {
const client = useMemo(() => seededClient({ policies, trustRules }), [policies, trustRules]);
const triggered = useRef(false);
useEffect(() => {
if (!autoClick || triggered.current) return;
let cancelled = false;
const tryClick = (attempt: number) => {
if (cancelled || triggered.current) return;
const label = autoClick === "new-rule" ? "New rule" : "Test a rule";
const button = findButtonByText(label);
if (button) {
triggered.current = true;
button.click();
return;
}
if (attempt < 30) window.setTimeout(() => tryClick(attempt + 1), 50);
};
tryClick(0);
return () => {
cancelled = true;
};
}, [autoClick]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-6xl p-6">
<PoliciesTab companyId={COMPANY} />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Tools/Rules (PAP-11049)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const IndexPopulated: Story = {
name: "Rules index — populated",
render: () => <PoliciesHost />,
};
export const Empty: Story = {
name: "Rules — empty state",
render: () => <PoliciesHost policies={[]} trustRules={[]} />,
};
export const BuilderNewRule: Story = {
name: "Rule builder — after New rule",
render: () => <PoliciesHost autoClick="new-rule" />,
};
export const TestRuleSlideover: Story = {
name: "Test a rule — slide-over",
render: () => <PoliciesHost autoClick="test-rule" />,
};
@@ -0,0 +1,246 @@
import { useMemo } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ToolConnection,
ToolRuntimeAlertRecommendation,
ToolRuntimeHealthSummary,
ToolRuntimeSlot,
} from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "@/lib/queryKeys";
import { RuntimeTab } from "@/pages/tools/RuntimeTab";
const COMPANY = "company-storybook";
function slot(overrides: Partial<ToolRuntimeSlot> = {}): ToolRuntimeSlot {
return {
id: "slot-gmail",
companyId: COMPANY,
applicationId: "app-gmail",
connectionId: "conn-gmail",
projectWorkspaceId: null,
executionWorkspaceId: null,
issueId: null,
ownerScopeType: "company",
ownerScopeId: null,
runtimeKind: "local_stdio",
slotKey: "gmail-stdio-local",
status: "running",
reuseKey: null,
workspaceScope: null,
credentialScopeHash: null,
provider: null,
providerRef: null,
processId: 41832,
commandTemplateKey: "gmail",
healthStatus: "healthy",
lastHealthCheckAt: null,
idleExpiresAt: null,
startedAt: new Date("2026-06-13T10:46:00Z"),
stoppedAt: null,
lastUsedAt: new Date("2026-06-13T12:55:00Z"),
lastError: null,
metadata: null,
createdAt: new Date("2026-06-13T10:46:00Z"),
updatedAt: new Date("2026-06-13T13:00:00Z"),
...overrides,
} as ToolRuntimeSlot;
}
function connection(overrides: Partial<ToolConnection> = {}): ToolConnection {
return {
id: "conn-gmail",
companyId: COMPANY,
applicationId: "app-gmail",
name: "Gmail",
connectionKind: "managed",
transport: "local_stdio",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
healthStatus: "healthy",
healthCheckedAt: null,
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-06-13T10:46:00Z"),
updatedAt: new Date("2026-06-13T13:00:00Z"),
...overrides,
} as ToolConnection;
}
const SLOTS: ToolRuntimeSlot[] = [
slot(),
slot({
id: "slot-sheets",
applicationId: "app-sheets",
connectionId: "conn-sheets",
slotKey: "sheets-stdio-local",
commandTemplateKey: "google_sheets",
processId: 41877,
lastUsedAt: new Date("2026-06-13T12:51:00Z"),
}),
slot({
id: "slot-slack",
applicationId: "app-slack",
connectionId: "conn-slack",
runtimeKind: "remote_session",
slotKey: "slack-remote",
commandTemplateKey: null,
providerRef: "slack",
processId: null,
lastUsedAt: new Date("2026-06-13T12:42:00Z"),
}),
slot({
id: "slot-github",
applicationId: "app-github",
connectionId: "conn-github",
slotKey: "github-stdio-local",
commandTemplateKey: "github",
processId: 41901,
lastUsedAt: new Date("2026-06-13T12:59:00Z"),
}),
];
const CONNECTIONS: ToolConnection[] = [
connection(),
connection({ id: "conn-sheets", applicationId: "app-sheets", name: "Google Sheets" }),
connection({ id: "conn-slack", applicationId: "app-slack", name: "Slack", transport: "remote_http" }),
connection({ id: "conn-github", applicationId: "app-github", name: "GitHub" }),
];
function alert(overrides: Partial<ToolRuntimeAlertRecommendation> = {}): ToolRuntimeAlertRecommendation {
return {
name: "mcp_runtime_stuck_running_slot",
severity: "critical",
status: "firing",
threshold: "Any running slot with no progress for 5 minutes.",
observed: "1 stuck running slot(s).",
description: "A runtime slot is running but has not recorded progress inside the supervisor stuck-slot window.",
firstResponderAction: "Inspect recent audit events and active tool calls; restart the slot only after confirming no healthy call is still in progress.",
runbookSection: "runbook.md#stuck-running-slot",
...overrides,
};
}
function health(overrides: Partial<ToolRuntimeHealthSummary> = {}): ToolRuntimeHealthSummary {
return {
status: "ok",
generatedAt: new Date("2026-06-13T13:00:00Z"),
runbookPath: "docs/runbooks/mcp-runtime.md",
metrics: {
windowStartedAt: new Date("2026-06-13T12:00:00Z"),
windowEndedAt: new Date("2026-06-13T13:00:00Z"),
activeSlots: 4,
startingSlots: 0,
runningSlots: 4,
idleSlots: 0,
failedSlots: 0,
stoppedSlots: 0,
stuckStartingSlots: 0,
stuckRunningSlots: 0,
capacityDeferralsLastHour: 0,
restartAttemptsLastHour: 0,
restartSuppressionsLastHour: 0,
idleEvictionsLastHour: 0,
toolCallsLastHour: 128,
toolTimeoutsLastHour: 0,
toolFailuresLastHour: 0,
timeoutRateLastHour: 0,
failureRateLastHour: 0,
averageToolLatencyMsLastHour: 1200,
p95ToolLatencyMsLastHour: 2100,
missingSecretFailuresLastHour: 0,
auditWriteFailuresLastHour: 0,
activeConnections: 4,
disabledConnections: 0,
degradedConnections: 0,
remoteHttpConnections: 1,
localStdioConnections: 3,
},
supportMatrix: {
remoteHttp: { supported: true, note: "" },
localStdio: { supported: true, note: "" },
},
alerts: [],
recommendations: [],
...overrides,
};
}
function Seeded({
slots,
connections,
summary,
}: {
slots: ToolRuntimeSlot[];
connections: ToolConnection[];
summary: ToolRuntimeHealthSummary;
}) {
const client = useMemo(() => {
const c = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
c.setQueryData(queryKeys.tools.runtimeSlots(COMPANY), { runtimeSlots: slots });
c.setQueryData(queryKeys.tools.runtimeHealth(COMPANY), summary);
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
return c;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<QueryClientProvider client={client}>
<TooltipProvider>
<div className="mx-auto max-w-5xl p-6">
<RuntimeTab companyId={COMPANY} />
</div>
</TooltipProvider>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Tools/Health (runtime)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const AllGood: Story = {
name: "All good",
render: () => <Seeded slots={SLOTS} connections={CONNECTIONS} summary={health()} />,
};
export const NeedsAttention: Story = {
name: "Needs attention",
render: () => (
<Seeded
slots={[
slot({ healthStatus: "error", lastError: "MCP request timed out after 30000ms" }),
...SLOTS.slice(1),
]}
connections={[connection({ healthStatus: "degraded" }), ...CONNECTIONS.slice(1)]}
summary={health({
status: "degraded",
metrics: {
...health().metrics,
runningSlots: 3,
degradedConnections: 1,
toolFailuresLastHour: 11,
toolTimeoutsLastHour: 3,
timeoutRateLastHour: 12,
averageToolLatencyMsLastHour: 2400,
p95ToolLatencyMsLastHour: 5200,
},
alerts: [alert(), alert({ name: "mcp_runtime_connection_health_degraded", observed: "1 degraded connection(s), 0 disabled connection(s)." })],
})}
/>
),
};
export const RowExpanded: Story = {
name: "Row expanded (use the chevron)",
render: () => <Seeded slots={SLOTS} connections={CONNECTIONS} summary={health()} />,
};
+395
View File
@@ -0,0 +1,395 @@
import { useEffect, useMemo, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ToolCatalogEntry,
ToolConnectionTestAgent,
ToolConnectionTestCallResult,
ToolConnectionTestDecision,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
const CONNECTION = "conn-sheets";
// ---------------------------------------------------------------------------
// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
// ---------------------------------------------------------------------------
function tool(
id: string,
toolName: string,
title: string,
description: string,
read: boolean,
inputSchema: Record<string, unknown> | null = null,
): ToolCatalogEntry {
return {
id,
companyId: "company-storybook",
applicationId: "app-sheets",
connectionId: CONNECTION,
entryKind: "tool",
name: toolName,
toolName,
title,
description,
inputSchema,
outputSchema: null,
annotations: null,
riskLevel: read ? "read" : "write",
isReadOnly: read,
isWrite: !read,
isDestructive: false,
status: "active",
addedAt: new Date("2026-06-01T00:00:00Z"),
version: null,
versionHash: null,
schemaHash: null,
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
} as ToolCatalogEntry;
}
const READ_SHEET_SCHEMA = {
type: "object",
properties: {
spreadsheetId: { type: "string", title: "Spreadsheet" },
sheet: { type: "string", title: "Sheet" },
range: { type: "string", title: "Range", description: "e.g. A1:F50" },
},
required: ["spreadsheetId", "sheet"],
};
const APPEND_ROW_SCHEMA = {
type: "object",
properties: {
spreadsheetId: { type: "string", title: "Spreadsheet" },
sheet: { type: "string", title: "Sheet" },
values: { type: "string", title: "Values", description: "Comma-separated cell values" },
},
required: ["spreadsheetId", "sheet", "values"],
};
const DELETE_ROW_SCHEMA = {
type: "object",
properties: {
spreadsheetId: { type: "string", title: "Spreadsheet" },
rowNumber: { type: "number", title: "Row number" },
},
required: ["spreadsheetId", "rowNumber"],
};
const CATALOG: ToolCatalogEntry[] = [
tool("r1", "list_spreadsheets", "List spreadsheets", "See the spreadsheets in this account.", true),
tool("r2", "read_sheet", "Read a sheet", "Get rows and cell values from a sheet.", true, READ_SHEET_SCHEMA),
tool("r3", "search_rows", "Search rows", "Find rows that match a query.", true),
tool("r4", "get_cell", "Get a cell", "Read a single cell value.", true),
tool("r5", "list_sheets", "List sheets", "See the tabs in a spreadsheet.", true),
tool("r6", "get_metadata", "Get spreadsheet info", "Read a spreadsheet's title and tabs.", true),
tool("r7", "describe_columns", "Describe columns", "List the column headers in a sheet.", true),
tool("w1", "append_row", "Append a row", "Add a new row to the bottom of a sheet.", false, APPEND_ROW_SCHEMA),
tool("w2", "update_cell", "Update a cell", "Change a single cell value.", false),
tool("w3", "create_sheet", "Add a sheet", "Create a new tab in a spreadsheet.", false),
tool("w4", "clear_range", "Clear a range", "Empty the cells in a range.", false),
tool("w5", "delete_row", "Delete a row", "Remove a row from a sheet permanently.", false, DELETE_ROW_SCHEMA),
];
function decisionTool(entry: ToolCatalogEntry, decision: ToolConnectionTestDecision) {
return {
toolName: entry.toolName,
gatewayToolName: `sheets__${entry.toolName}`,
displayName: entry.title,
risk: (entry.isReadOnly ? "read" : "write") as "read" | "write" | "destructive",
decision,
reasonCode: null,
matchedPolicyIds: [],
};
}
// Decisions: 9 allowed (7 read + 2 write), 2 ask first, 1 off.
const DECISIONS: Record<string, ToolConnectionTestDecision> = {
list_spreadsheets: "allowed",
read_sheet: "allowed",
search_rows: "allowed",
get_cell: "allowed",
list_sheets: "allowed",
get_metadata: "allowed",
describe_columns: "allowed",
append_row: "ask_first",
update_cell: "allowed",
create_sheet: "allowed",
clear_range: "ask_first",
delete_row: "off",
};
function buildAgent(id: string, name: string, decisions: Record<string, ToolConnectionTestDecision>): ToolConnectionTestAgent {
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
return {
id,
name,
role: "engineer",
title: "Engineer",
status: "active",
effectiveAccess: {
connectionId: CONNECTION,
toolCount: tools.length,
allowedCount: tools.filter((t) => t.decision === "allowed").length,
askFirstCount: tools.filter((t) => t.decision === "ask_first").length,
offCount: tools.filter((t) => t.decision === "off").length,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools,
},
};
}
const AGENTS: ToolConnectionTestAgent[] = [
buildAgent("agent-claude", "ClaudeCoder", DECISIONS),
buildAgent("agent-codex", "CodexCoder", {
...DECISIONS,
create_sheet: "ask_first",
clear_range: "off",
}),
];
// ---------------------------------------------------------------------------
// DOM driver — expand a row, fill its fields, press Run.
// ---------------------------------------------------------------------------
type Step =
| { kind: "expand"; title: string }
| { kind: "fill"; values: string[] }
| { kind: "click"; label: string };
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function runScript(steps: Step[]) {
let index = 0;
const tick = (attempt: number) => {
if (index >= steps.length) return;
const step = steps[index];
if (step.kind === "expand") {
const btn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes(step.title),
);
if (btn) {
btn.click();
index += 1;
window.setTimeout(() => tick(0), 60);
return;
}
} else if (step.kind === "fill") {
const inputs = Array.from(document.querySelectorAll("input")).filter(
(i) => i.getAttribute("aria-label") !== "Find an action" && i.getAttribute("aria-label") !== "Search agents",
);
if (inputs.length >= step.values.length) {
step.values.forEach((value, i) => setInputValue(inputs[i] as HTMLInputElement, value));
index += 1;
window.setTimeout(() => tick(0), 60);
return;
}
} else if (step.kind === "click") {
const btn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === step.label,
);
if (btn) {
btn.click();
index += 1;
window.setTimeout(() => tick(0), 60);
return;
}
}
if (attempt < 40) window.setTimeout(() => tick(attempt + 1), 50);
};
tick(0);
}
function seededClient(agents: ToolConnectionTestAgent[]): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), { agents });
return client;
}
function TestHost({
script,
runResult,
runDelayMs = 120,
agents = AGENTS,
quarantined = [],
}: {
script?: Step[];
runResult?: ToolConnectionTestCallResult;
runDelayMs?: number;
agents?: ToolConnectionTestAgent[];
quarantined?: ToolCatalogEntry[];
}) {
const client = useMemo(() => seededClient(agents), [agents]);
const fetchPatched = useRef(false);
// Stub the test-call POST so the run states render without a backend.
useEffect(() => {
if (fetchPatched.current) return;
fetchPatched.current = true;
const original = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.includes("/test-calls")) {
await new Promise((resolve) => window.setTimeout(resolve, runDelayMs));
return Response.json(runResult ?? { decision: "allowed", invocationId: "inv-storybook", result: {} });
}
return original(input, init);
};
return () => {
window.fetch = original;
};
}, [runResult, runDelayMs]);
useEffect(() => {
if (!script) return;
const timer = window.setTimeout(() => runScript(script), 150);
return () => window.clearTimeout(timer);
}, [script]);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-3xl p-6">
<TestPanel connectionId={CONNECTION} appName="Google Sheets" active={CATALOG} quarantined={quarantined} />
</div>
</QueryClientProvider>
);
}
const meta: Meta = {
title: "Apps/Test tab (PAP-11350)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
name: "Default — grouped actions + access badges",
render: () => <TestHost />,
};
export const AllowedResult: Story = {
name: "Allowed — result panel",
render: () => (
<TestHost
runResult={{
decision: "allowed",
invocationId: "inv-allowed",
result: [
{ Name: "Acme Co.", Stage: "Demo", Owner: "Dotta", Updated: "Tue" },
{ Name: "Globex", Stage: "Trial", Owner: "Dotta", Updated: "Mon" },
{ Name: "Initech", Stage: "Closed", Owner: "CodexCoder", Updated: "Sun" },
{ Name: "Soylent", Stage: "Discovery", Owner: "QA", Updated: "Fri" },
{ Name: "Umbrella", Stage: "Trial", Owner: "Dotta", Updated: "Thu" },
],
}}
script={[
{ kind: "expand", title: "Read a sheet" },
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const AskFirstResult: Story = {
name: "Ask first — sent for approval",
render: () => (
<TestHost
runResult={{ decision: "ask_first", invocationId: "inv-ask", actionRequestId: "req-1" }}
script={[
{ kind: "expand", title: "Append a row" },
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads", "Wayne Industries, Demo, Dotta"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const ErrorResult: Story = {
name: "Error — what the app said + what to try",
render: () => (
<TestHost
runResult={{
decision: "allowed",
invocationId: "inv-error",
error: { message: "Requested entity was not found.", reasonCode: "NOT_FOUND" },
}}
script={[
{ kind: "expand", title: "Read a sheet" },
{ kind: "fill", values: ["1AbCxyz…NotFound", "Leads"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const RunningState: Story = {
name: "Running — in-flight card",
render: () => (
<TestHost
runDelayMs={20000}
script={[
{ kind: "expand", title: "Read a sheet" },
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
{ kind: "click", label: "Run" },
]}
/>
),
};
export const OffAction: Story = {
name: "Off — explanation + open Permissions",
render: () => <TestHost script={[{ kind: "expand", title: "Delete a row" }]} />,
};
// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
const AGENTS_WITH_AUDIT: ToolConnectionTestAgent[] = AGENTS.map((agent, i) =>
i === 0
? {
...agent,
effectiveAccess: {
...agent.effectiveAccess,
lastChangedAt: new Date("2026-06-17T09:00:00Z").toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
}
: agent,
);
export const OffAuditHint: Story = {
name: "Off — last-changed audit hint (PAP-11404)",
render: () => (
<TestHost agents={AGENTS_WITH_AUDIT} script={[{ kind: "expand", title: "Delete a row" }]} />
),
};
const QUARANTINED: ToolCatalogEntry[] = [
{ ...tool("q1", "rename_sheet", "Rename a sheet", "Change a sheet's name.", false), status: "quarantined" } as ToolCatalogEntry,
];
export const QuarantinedAction: Story = {
name: "New — quarantined action not yet on (PAP-11404)",
render: () => (
<TestHost quarantined={QUARANTINED} script={[{ kind: "expand", title: "Rename a sheet" }]} />
),
};
+67
View File
@@ -0,0 +1,67 @@
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BACKEND = "http://paperclip-dev:3100";
// Mock the /tools/gallery endpoint (which only exists on PAP-10341 branch)
// so we can visually test the "recognized domain" shortcut against a populated gallery.
const MOCK_GALLERY = {
apps: [
{
key: "zapier",
name: "Zapier",
tagline: "Automate things",
authKind: "api_key",
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
logoUrl: null,
credentialFields: [
{ configPath: "credentials.authorization", label: "API key", required: true },
],
},
],
};
const galleryMockPlugin = {
name: "qa-mock-gallery",
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url && /\/api\/companies\/[^/]+\/tools\/gallery(\?|$)/.test(req.url)) {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(MOCK_GALLERY));
return;
}
next();
});
},
};
export default defineConfig({
plugins: [galleryMockPlugin, react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
},
},
server: {
host: "0.0.0.0",
port: 5179,
strictPort: true,
proxy: {
"/api": {
target: BACKEND,
ws: true,
changeOrigin: true,
configure: (proxy) => {
proxy.on("proxyReq", (proxyReq) => {
proxyReq.setHeader("origin", BACKEND);
});
},
},
},
},
});