Reusable env-var editor (agents, projects, environments, routines). One shared grid, an
diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx
index 45f4da85c13..300f2e2454c 100644
--- a/ui/src/pages/InstanceExperimentalSettings.test.tsx
+++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx
@@ -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(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();
diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx
index b77f205a3dd..80bcfac88fd 100644
--- a/ui/src/pages/InstanceExperimentalSettings.tsx
+++ b/ui/src/pages/InstanceExperimentalSettings.tsx
@@ -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() {
) : null}
+
+
+
+
+
Apps
+ Experimental
+
+
+ Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.
+
+
+
toggleMutation.mutate({ enableApps: !enableApps })}
+ disabled={toggleMutation.isPending}
+ aria-label="Toggle apps experimental setting"
+ />
+
+
+
@@ -686,6 +708,25 @@ export function InstanceExperimentalSettings() {
+
+
+
+
Smoke Lab
+
+ 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.
+
+
+
toggleMutation.mutate({ enableSmokeLab: !enableSmokeLab })}
+ disabled={toggleMutation.isPending}
+ aria-label="Toggle smoke lab experimental setting"
+ />
+
+
+
diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx
new file mode 100644
index 00000000000..240822b5249
--- /dev/null
+++ b/ui/src/pages/apps/AppDetail.test.tsx
@@ -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
;
+ },
+ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
+
+ {children}
+
+ ),
+}));
+
+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
) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ for (let i = 0; i < 3; i += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ }
+}
+
+function connection(overrides: Record = {}) {
+ return {
+ id: "conn-1",
+ 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 = {}) {
+ 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;
+
+ 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(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ }
+
+ it("pauses the app by flipping the connection enabled flag", async () => {
+ await renderAppDetail();
+
+ const toggle = container.querySelector(
+ '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®ion=us",
+ },
+ }),
+ );
+
+ await renderAppDetail();
+
+ expect(container.textContent).toContain(
+ "https://mcp.zapier.com/api/v1/connect?token=REDACTED®ion=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(
+ '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('select[aria-label="Read repo permission"]');
+ const writeSelect = container.querySelector('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('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('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('[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");
+ });
+});
diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx
new file mode 100644
index 00000000000..1f568cc450a
--- /dev/null
+++ b/ui/src/pages/apps/AppDetail.tsx
@@ -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 (" tested as ") 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; askFirst: Set; 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) => 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; askFirst?: Set; access?: AccessDraft }) =>
+ persist.mutate({
+ enabled: mutate.enabled ?? new Set(enabledIds),
+ askFirst: mutate.askFirst ?? new Set(askFirstIds),
+ access: mutate.access ?? access,
+ });
+
+ if (!connectionId || !activeTab) {
+ return ;
+ }
+
+ if (!selectedCompanyId) {
+ return Select a company to manage apps.
;
+ }
+ if (connectionQuery.isLoading || catalogQuery.isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+ if (!connection) {
+ return (
+
+
We couldn't find that app.
+
navigate("/apps")}>
+ Back to apps
+
+
+ );
+ }
+
+ 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 (
+
+
{
+ setNameDraft(appName);
+ setRenaming(true);
+ }}
+ onRenameCancel={() => setRenaming(false)}
+ onRenameSubmit={(next) => {
+ if (next && next !== appName) rename.mutate(next);
+ else setRenaming(false);
+ }}
+ />
+
+ {needsReconnect && (
+ {
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
+ }}
+ />
+ )}
+
+ {activeTab === "setup" && (
+ toggleEnabled.mutate()}
+ configUpdateDisabled={updateConfig.isPending}
+ onUpdateConfig={(config) => updateConfig.mutate(config)}
+ oauthStartDisabled={startOAuth.isPending}
+ onStartOAuth={() => startOAuth.mutate()}
+ />
+ )}
+ {activeTab === "review" && (
+ apply({ enabled: addAll(new Set(enabledIds), ids) })}
+ />
+ )}
+ {activeTab === "permissions" && (
+ 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" && (
+
+ )}
+ {activeTab === "activity" && (
+
+ )}
+ {activeTab === "advanced" && (
+ removeApp.mutate()}
+ onReplaced={() => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
+ }}
+ />
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
+ {renaming ? (
+
+ ) : (
+
+ )}
+ {connectionDisplaySecondaryHint(connection) && (
+
{connectionDisplaySecondaryHint(connection)}
+ )}
+
+
+
+ {actionCount} {actionCount === 1 ? "action" : "actions"} available
+
+
+
+
+
+ );
+}
+
+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 = {
+ 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 (
+
+ {status.tone === "connected" && }
+ {status.label}
+
+ );
+}
+
+function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set {
+ const ids = new Set();
+ 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 {
+ const ids = new Set();
+ 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, ids: string[]): Set {
+ 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,
+ askFirstIds: Set,
+) {
+ 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 };
+}
diff --git a/ui/src/pages/apps/AppLogo.tsx b/ui/src/pages/apps/AppLogo.tsx
new file mode 100644
index 00000000000..3a570175736
--- /dev/null
+++ b/ui/src/pages/apps/AppLogo.tsx
@@ -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 (
+
+ setFailed(true)}
+ />
+
+ );
+ }
+
+ return (
+
+ {letter}
+
+ );
+}
diff --git a/ui/src/pages/apps/AppNotConnected.test.tsx b/ui/src/pages/apps/AppNotConnected.test.tsx
new file mode 100644
index 00000000000..e2037ad12c9
--- /dev/null
+++ b/ui/src/pages/apps/AppNotConnected.test.tsx
@@ -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
;
+ },
+ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+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) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ for (let i = 0; i < 3; i += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ }
+}
+
+function application(overrides: Record = {}) {
+ 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 = {}) {
+ 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;
+
+ 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(
+
+
+ ,
+ );
+ });
+ 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");
+ });
+});
diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx
new file mode 100644
index 00000000000..b3ed0820229
--- /dev/null
+++ b/ui/src/pages/apps/AppNotConnected.tsx
@@ -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 Select a company to manage apps.
;
+ }
+ if (!applicationId || !activeTab) {
+ return ;
+ }
+ if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
+ return (
+
+
+
+
+ );
+ }
+ if (!application) {
+ return (
+
+
This app doesn’t exist anymore.
+
navigate("/apps")}>Back to apps
+
+ );
+ }
+ if (activeConnection) {
+ return ;
+ }
+
+ 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 (
+
+
+
+ {activeTab === "setup" && (
+
navigate(connectHref)}
+ />
+ )}
+ {activeTab === "review" && (
+ previousConnection ? (
+
+ ) : (
+
+ )
+ )}
+ {activeTab === "permissions" && (
+
+ )}
+ {activeTab === "test" && (
+
+ )}
+ {activeTab === "activity" && (
+ previousConnection ? (
+
+ ) : (
+
+ )
+ )}
+ {activeTab === "advanced" && (
+ remove.mutate()}
+ />
+ )}
+
+ );
+}
+
+function ApplicationHeader({
+ applicationName,
+ description,
+ logoUrl,
+}: {
+ applicationName: string;
+ description: string | null;
+ logoUrl: string | undefined;
+}) {
+ return (
+
+ );
+}
+
+function SetupTab({
+ previousConnection,
+ previousAddress,
+ onConnect,
+}: {
+ previousConnection: ToolConnection | null;
+ previousAddress: string | null;
+ onConnect: () => void;
+}) {
+ return (
+
+
+
+
+
+ {previousConnection ? "Reconnect this app" : "Connect this app"}
+
+
+ {previousConnection
+ ? "We kept the previous setup. Add a working key to bring it back online."
+ : "Agents can't use it until it's connected."}
+
+
+
+ {previousConnection ? "Reconnect" : "Connect"}
+
+
+
+
+ {previousConnection && (
+
+ )}
+
+ );
+}
+
+function PreviousSetup({
+ connection,
+ previousAddress,
+}: {
+ connection: ToolConnection;
+ previousAddress: string | null;
+}) {
+ return (
+
+ Previous setup
+ {connection.healthMessage && (
+
+ Last error: {connection.healthMessage}
+
+ )}
+
+ Address
+ {previousAddress}
+ Connection type
+ {connectionTransportLabel(connection.transport)}
+ Last used
+
+ {connection.lastUsedAt ? timeAgo(connection.lastUsedAt) : "Never"}
+
+
+
+ );
+}
+
+function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) {
+ return (
+
+ Permissions paused
+
+ Reconnect this app to edit who can use it and which actions need a human first.
+
+ {previousConnection && (
+
+ Previous setup is retained for reconnect, but access controls stay read-only until the app is online.
+
+ )}
+
+ );
+}
+
+function AdvancedTab({
+ appName,
+ previousConnection,
+ previousAddress,
+ removing,
+ onRemove,
+}: {
+ appName: string;
+ previousConnection: ToolConnection | null;
+ previousAddress: string | null;
+ removing: boolean;
+ onRemove: () => void;
+}) {
+ return (
+
+ {previousConnection ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function EmptyTab({ title, body }: { title: string; body: string }) {
+ return (
+
+ );
+}
+
+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()}`;
+}
diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx
new file mode 100644
index 00000000000..d9ff3228605
--- /dev/null
+++ b/ui/src/pages/apps/AppsConnect.test.tsx
@@ -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) {
+ let result: void | Promise = 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("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(
+
+
+ ,
+ );
+ });
+ 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("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("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("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("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(
+ '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('[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("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("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("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("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("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("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("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("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"] },
+ });
+ });
+});
diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx
new file mode 100644
index 00000000000..227f68ff227
--- /dev/null
+++ b/ui/src/pages/apps/AppsConnect.tsx
@@ -0,0 +1,1645 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ ArrowUpRight,
+ Check,
+ ChevronRight,
+ Copy,
+ ClipboardPaste,
+ Link2,
+ Loader2,
+ Lock,
+ Search,
+ TerminalSquare,
+} from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+import type {
+ Agent,
+ AppGalleryEntry,
+ ConnectToolAppResult,
+ ToolAppConnectionActionSummary,
+} from "@paperclipai/shared";
+import { getToolAppGalleryEntryForUrl } from "@paperclipai/shared";
+import { useNavigate, useParams, useSearchParams } from "@/lib/router";
+import { useCompany } from "@/context/CompanyContext";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useToast } from "@/context/ToastContext";
+import { queryKeys } from "@/lib/queryKeys";
+import { ApiError } from "@/api/client";
+import { toolsApi } from "@/api/tools";
+import { agentsApi } from "@/api/agents";
+import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
+import { advancedTabHref } from "@/pages/tools/tool-tabs";
+import { AgentIcon } from "@/components/AgentIconPicker";
+import { AgentMultiSelect } from "@/components/AgentMultiSelect";
+import { InlineBanner } from "@/components/InlineBanner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { ToggleSwitch } from "@/components/ui/toggle-switch";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+import { AppLogo } from "./AppLogo";
+import { parseGoogleSheetIds } from "./google-sheets";
+import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
+
+type Step = "gallery" | "key" | "actions" | "who" | "install" | "success";
+
+const ROUTE_STAGE_BY_STEP: Partial> = {
+ key: "setup",
+ actions: "actions",
+ who: "access",
+ install: "install",
+ success: "complete",
+};
+
+function appConnectHref(appKey: string, step: Step): string {
+ const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup";
+ const params = new URLSearchParams({ byo: "1", appKey, stage });
+ return `/apps/connect?${params.toString()}`;
+}
+type AppAccessSelection = "all_agents" | { agentIds: string[] };
+type InstallMode = "none" | "specific" | "all";
+const LINK_CREDENTIAL_CONFIG_PATH = "credentials.authorization";
+
+const STEP_LABELS = ["Pick app", "Add your key", "Choose actions", "Choose access", "Install tools"];
+const STEP_INDEX: Record, number> = {
+ gallery: 0,
+ key: 1,
+ actions: 2,
+ who: 3,
+ install: 4,
+};
+const ZAPIER_STEP_INDEX: Record, number> = {
+ key: 0,
+ actions: 1,
+ who: 2,
+ install: 3,
+};
+const ZAPIER_STEP_LABELS = ["Add MCP URL", "Choose actions", "Choose access", "Install tools"];
+
+function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
+ const raw = (result.suggestedDefaults as { askFirstRiskLevels?: unknown })?.askFirstRiskLevels;
+ return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
+}
+
+function isGoogleSheetsEntry(entry: AppGalleryEntry | null): boolean {
+ return entry?.key === "google-sheets";
+}
+
+export function AppsConnect() {
+ const navigate = useNavigate();
+ const routeParams = useParams<{ appKey?: string }>();
+ const { selectedCompany, selectedCompanyId } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const { pushToast } = useToast();
+ const [searchParams] = useSearchParams();
+ const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
+ const zapierSource = searchParams.get("source") === "zapier";
+
+ // Prefill arrives from the app page for reconnects; read once so later
+ // wizard navigation doesn't fight the URL.
+ const [prefill] = useState(() => {
+ const rawLink = searchParams.get("link")?.trim() ?? "";
+ return {
+ link: /^https?:\/\//i.test(rawLink) ? rawLink : "",
+ name: searchParams.get("name")?.trim() ?? "",
+ applicationId: searchParams.get("applicationId")?.trim() || undefined,
+ };
+ });
+
+ const [step, setStep] = useState(appKey || prefill.link || zapierSource ? "key" : "gallery");
+ const [entry, setEntry] = useState(null);
+ const [galleryName, setGalleryName] = useState("");
+ const [linkUrl, setLinkUrl] = useState(prefill.link);
+ const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : ""));
+ const [linkNeedsKey, setLinkNeedsKey] = useState(false);
+ const [linkKey, setLinkKey] = useState("");
+ const [credentials, setCredentials] = useState>({});
+ const [googleSheetsLinks, setGoogleSheetsLinks] = useState("");
+ const [googleSheetsError, setGoogleSheetsError] = useState(null);
+ const [connectResult, setConnectResult] = useState(null);
+ const [enabled, setEnabled] = useState>({});
+ const [access, setAccess] = useState<"all" | "specific">("all");
+ const [agentIds, setAgentIds] = useState>(new Set());
+ const [installMode, setInstallMode] = useState("none");
+ const [installAgentIds, setInstallAgentIds] = useState>(new Set());
+
+ const openGallery = () => {
+ setEntry(null);
+ setGalleryName("");
+ setLinkUrl("");
+ setLinkName("");
+ setLinkNeedsKey(false);
+ setLinkKey("");
+ setCredentials({});
+ setGoogleSheetsLinks("");
+ setGoogleSheetsError(null);
+ setConnectResult(null);
+ setInstallMode("none");
+ setInstallAgentIds(new Set());
+ setStep("gallery");
+ navigate("/apps/connect?byo=1");
+ };
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: selectedCompany?.name ?? "Company", href: "/dashboard" },
+ { label: "Apps", href: "/apps" },
+ { label: "Connect an app" },
+ ]);
+ return () => setBreadcrumbs([]);
+ }, [setBreadcrumbs, selectedCompany?.name]);
+
+ const galleryQuery = useQuery({
+ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
+ queryFn: () => toolsApi.listGallery(selectedCompanyId!),
+ enabled: !!selectedCompanyId,
+ });
+
+ useEffect(() => {
+ if (!appKey || galleryQuery.isLoading || !galleryQuery.data) return;
+
+ const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.key === appKey);
+ if (!requestedEntry || requestedEntry.authKind === "oauth" || requestedEntry.availability?.available === false) {
+ setEntry(null);
+ setStep("gallery");
+ navigate("/apps/connect", { replace: true });
+ return;
+ }
+
+ if (entry?.key !== requestedEntry.key) {
+ setEntry(requestedEntry);
+ setGalleryName(requestedEntry.name);
+ setLinkUrl("");
+ setLinkName("");
+ setLinkNeedsKey(false);
+ setLinkKey("");
+ setCredentials({});
+ setGoogleSheetsLinks("");
+ setGoogleSheetsError(null);
+ setConnectResult(null);
+ }
+ setInstallMode("none");
+ setInstallAgentIds(new Set());
+ setStep("key");
+ }, [appKey, entry?.key, galleryQuery.data, galleryQuery.isLoading, navigate]);
+
+ const setAppStep = (nextStep: Step) => {
+ setStep(nextStep);
+ if (entry) navigate(appConnectHref(entry.key, nextStep));
+ };
+
+ const connectMutation = useMutation({
+ mutationFn: () => {
+ if (entry) {
+ const sheetIds = isGoogleSheetsEntry(entry) ? parseGoogleSheetIds(googleSheetsLinks).ids : [];
+ const trimmedGalleryName = galleryName.trim();
+ return toolsApi.connectApp(selectedCompanyId!, {
+ galleryKey: entry.key,
+ name: trimmedGalleryName || undefined,
+ credentialValues: credentials,
+ configValues: isGoogleSheetsEntry(entry) ? { allowedSpreadsheetIds: sheetIds } : undefined,
+ applicationId: prefill.applicationId,
+ });
+ }
+ const trimmedKey = linkNeedsKey ? linkKey.trim() : "";
+ const trimmedName = linkName.trim();
+ return toolsApi.connectApp(selectedCompanyId!, {
+ link: linkUrl,
+ name: trimmedName || undefined,
+ credentialValues: trimmedKey ? { [LINK_CREDENTIAL_CONFIG_PATH]: trimmedKey } : undefined,
+ applicationId: prefill.applicationId,
+ });
+ },
+ onSuccess: (result) => {
+ setConnectResult(result);
+ const defaults: Record = {};
+ for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
+ for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = false;
+ setEnabled(defaults);
+ setInstallMode("none");
+ setInstallAgentIds(new Set());
+ setAppStep("actions");
+ },
+ onError: (error) => {
+ const details = error instanceof ApiError && error.body && typeof error.body === "object"
+ ? (error.body as { details?: { code?: unknown } }).details
+ : null;
+ const oauthRequired = details?.code === "oauth_challenge";
+ pushToast({
+ title: oauthRequired ? "Sign-in required" : "Couldn’t connect",
+ body: oauthRequired
+ ? "This app needs you to sign in - coming soon."
+ : error instanceof Error
+ ? error.message
+ : "Please check your key and try again.",
+ tone: "error",
+ });
+ },
+ });
+
+ const finishMutation = useMutation({
+ mutationFn: async () => {
+ const askFirstLevels = connectResult ? askFirstLevelsFrom(connectResult) : [];
+ const changeActions = connectResult?.actions.canMakeChanges ?? [];
+ const enabledIds = Object.entries(enabled)
+ .filter(([, on]) => on)
+ .map(([id]) => id);
+ const askFirstIds = changeActions
+ .filter((a) => enabled[a.catalogEntryId] && askFirstLevels.includes(a.riskLevel))
+ .map((a) => a.catalogEntryId);
+ const selection: AppAccessSelection =
+ access === "all" ? "all_agents" : { agentIds: Array.from(agentIds) };
+ const result = await toolsApi.finishApp(selectedCompanyId!, connectResult!.connectionId, {
+ enabledCatalogEntryIds: enabledIds,
+ askFirstCatalogEntryIds: askFirstIds,
+ access: selection,
+ });
+ const installState = installMode === "all"
+ ? { onAll: true, agentIds: new Set() }
+ : { onAll: false, agentIds: installMode === "specific" ? installAgentIds : new Set() };
+ await toolsApi.putConnectionInstalls(
+ connectResult!.connectionId,
+ installPayload(selectedCompanyId!, installState),
+ );
+ return result;
+ },
+ onSuccess: () => setAppStep("success"),
+ onError: (error) => {
+ pushToast({
+ title: "Couldn’t finish setup",
+ body: error instanceof Error ? error.message : "Please try again.",
+ tone: "error",
+ });
+ },
+ });
+
+ if (!selectedCompanyId) {
+ return Select a company to connect apps.
;
+ }
+
+ const appName =
+ connectResult?.application.name ??
+ entry?.name ??
+ (linkName.trim() || defaultLinkName(linkUrl) || "this app");
+ const zapierEntry = zapierSource
+ ? galleryQuery.data?.apps.find((app) => app.key === "zapier") ?? null
+ : null;
+ const stepLabels = zapierSource
+ ? ZAPIER_STEP_LABELS
+ : isGoogleSheetsEntry(entry)
+ ? ["Pick app", "Share sheet", "Choose actions", "Choose access", "Install tools"]
+ : STEP_LABELS;
+ const stepIndex = zapierSource && step !== "gallery" && step !== "success"
+ ? ZAPIER_STEP_INDEX[step]
+ : step === "success"
+ ? stepLabels.length
+ : STEP_INDEX[step];
+
+ return (
+
+ {step !== "success" && (
+
navigate(zapierSource ? "/apps/browse" : "/apps")}
+ />
+ )}
+
+ {step === "gallery" && (
+ {
+ setEntry(picked);
+ setGalleryName(picked.name);
+ setLinkUrl("");
+ setLinkName("");
+ setLinkNeedsKey(false);
+ setLinkKey("");
+ setCredentials({});
+ setGoogleSheetsLinks("");
+ setGoogleSheetsError(null);
+ setConnectResult(null);
+ setInstallMode("none");
+ setInstallAgentIds(new Set());
+ setStep("key");
+ navigate(appConnectHref(picked.key, "key"));
+ }}
+ onUseLink={(url) => {
+ const matchedEntry = getToolAppGalleryEntryForUrl(url, galleryQuery.data?.apps ?? []);
+ setEntry(null);
+ setGalleryName("");
+ setLinkUrl(url);
+ setLinkName(matchedEntry?.name ?? defaultLinkName(url) ?? "");
+ setLinkNeedsKey(false);
+ setLinkKey("");
+ setCredentials({});
+ setGoogleSheetsLinks("");
+ setGoogleSheetsError(null);
+ setInstallMode("none");
+ setInstallAgentIds(new Set());
+ setStep("key");
+ }}
+ onRunYourOwn={() => navigate(advancedTabHref("run-your-own"))}
+ onPasteConfig={() => navigate(advancedTabHref("paste-config"))}
+ />
+ )}
+
+ {step === "key" && entry && (
+ {
+ setGoogleSheetsLinks(next);
+ setGoogleSheetsError(null);
+ }}
+ submitting={connectMutation.isPending}
+ onBack={openGallery}
+ onConnect={() => {
+ if (isGoogleSheetsEntry(entry)) {
+ const parsed = parseGoogleSheetIds(googleSheetsLinks);
+ if (parsed.invalidCount > 0) {
+ setGoogleSheetsError("That doesn't look like a Google Sheets link.");
+ return;
+ }
+ if (parsed.ids.length === 0) {
+ setGoogleSheetsError("Paste at least one Google Sheets link.");
+ return;
+ }
+ }
+ connectMutation.mutate();
+ }}
+ />
+ )}
+
+ {step === "key" && !entry && linkUrl && !zapierSource && (
+ {
+ setLinkNeedsKey(next);
+ if (!next) setLinkKey("");
+ }}
+ keyValue={linkKey}
+ onKeyChange={setLinkKey}
+ submitting={connectMutation.isPending}
+ onBack={() => setStep("gallery")}
+ onConnect={() => connectMutation.mutate()}
+ />
+ )}
+
+ {step === "key" && !entry && zapierSource && (
+ navigate("/apps/browse")}
+ onConnect={() => connectMutation.mutate()}
+ />
+ )}
+
+ {step === "actions" && connectResult && (
+ setEnabled((prev) => ({ ...prev, [id]: on }))}
+ onBulk={(ids, on) =>
+ setEnabled((prev) => {
+ const next = { ...prev };
+ for (const id of ids) next[id] = on;
+ return next;
+ })
+ }
+ onBack={() => setAppStep("key")}
+ onContinue={() => setAppStep("who")}
+ />
+ )}
+
+ {step === "who" && connectResult && (
+ setAppStep("actions")}
+ onContinue={() => setAppStep("install")}
+ />
+ )}
+
+ {step === "install" && connectResult && (
+ setAppStep("who")}
+ onFinish={() => finishMutation.mutate()}
+ />
+ )}
+
+ {step === "success" && (
+ navigate("/apps")}
+ />
+ )}
+
+ );
+}
+
+function StepHeader({
+ subtitle,
+ step,
+ activeIndex,
+ labels,
+ appIdentity,
+ onCancel,
+}: {
+ subtitle: string;
+ step: Step;
+ activeIndex: number;
+ labels: string[];
+ appIdentity?: { name: string; logoUrl: string | null };
+ onCancel: () => void;
+}) {
+ return (
+
+
+
+ {appIdentity ? (
+
+ ) : null}
+
+
+ {appIdentity ? `Connect ${appIdentity.name}` : "Connect an app"}
+
+
{subtitle}
+
+
+
+ Cancel
+
+
+ {step !== "gallery" && (
+
+
+ {labels.map((label, i) => (
+
+ ))}
+
+
{labels.join(" · ")}
+
+ )}
+
+ );
+}
+
+function ZapierConnectStep({
+ link,
+ onLinkChange,
+ submitting,
+ onBack,
+ onConnect,
+}: {
+ link: string;
+ onLinkChange: (next: string) => void;
+ submitting: boolean;
+ onBack: () => void;
+ onConnect: () => void;
+}) {
+ const normalizedLink = normalizeAppLink(link);
+ const zapierHostname = normalizedLink ? new URL(normalizedLink).hostname : "";
+ const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com");
+
+ return (
+
+
+
+
+
+
+
Connect Zapier
+
+ Paste the complete MCP URL Zapier gives you, including its token.
+
+
+
+
+
+
Zapier MCP URL
+
onLinkChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && isZapierLink && !submitting) onConnect();
+ }}
+ placeholder="https://mcp.zapier.com/api/v1/connect?token=…"
+ className="mt-2 h-11"
+ autoFocus
+ />
+
+ The token is part of the URL. Paperclip stores it securely and checks the connection before enabling actions.
+
+ {link.trim() && !isZapierLink && (
+
Paste a valid Zapier URL to continue.
+ )}
+
+
+
+
+ Back
+
+
+ {submitting && }
+ {submitting ? "Checking…" : "Check link"}
+
+
+
+ );
+}
+
+function GalleryStep({
+ loading,
+ apps,
+ byo = false,
+ source = null,
+ onPick,
+ onUseLink,
+ onRunYourOwn,
+ onPasteConfig,
+}: {
+ loading: boolean;
+ apps: AppGalleryEntry[];
+ /** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
+ byo?: boolean;
+ source?: string | null;
+ onPick: (entry: AppGalleryEntry) => void;
+ onUseLink: (link: string) => void;
+ onRunYourOwn: () => void;
+ onPasteConfig: () => void;
+}) {
+ const [search, setSearch] = useState("");
+ const [linkInput, setLinkInput] = useState("");
+ const [linkError, setLinkError] = useState(null);
+ const linkSectionRef = useRef(null);
+ const linkInputRef = useRef(null);
+
+ // Arriving from the BYO card: scroll the "Connect with a link" section into
+ // view and focus its input so the paste-URL path is the obvious next step.
+ useEffect(() => {
+ if (!byo || loading) return;
+ linkSectionRef.current?.scrollIntoView({ block: "center" });
+ linkInputRef.current?.focus();
+ }, [byo, loading]);
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return apps;
+ return apps.filter((a) => a.name.toLowerCase().includes(q));
+ }, [apps, search]);
+ const normalizedLink = normalizeAppLink(linkInput);
+ const matchedEntry = normalizedLink ? getToolAppGalleryEntryForUrl(normalizedLink, apps) : null;
+ const zapierSource = source === "zapier";
+
+ const continueWithLink = () => {
+ const next = normalizeAppLink(linkInput);
+ if (!next) {
+ setLinkError("Paste a full http or https link.");
+ return;
+ }
+ setLinkError(null);
+ onUseLink(next);
+ };
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search apps…"
+ className="h-11 pl-9"
+ />
+
+
+
+ {filtered.map((app) => {
+ const copy = appCopyFor(app.key, app.tagline);
+ const oauth = app.authKind === "oauth";
+ const unavailable = app.availability?.available === false;
+ return (
+
onPick(app)}
+ className={cn(
+ "flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
+ oauth || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
+ )}
+ >
+
+ {app.name}
+ {copy.tagline}
+
+ {unavailable ? (
+ Not available on this instance - ask your admin.
+ ) : oauth ? (
+ Sign-in coming soon
+ ) : (
+ Connect →
+ )}
+
+
+ );
+ })}
+
+
+ {filtered.length === 0 && (
+
No apps match “{search}”.
+ )}
+
+
+
+
+
+ {zapierSource ? "Connect Zapier" : byo ? "Connect your own MCP server" : "Connect with a link"}
+
+
+ {zapierSource
+ ? "Paste the complete MCP URL Zapier gives you, including its token."
+ : byo
+ ? "Paste your MCP server’s URL and we’ll walk you through permissions and review."
+ : "Paste a setup link from an app that is not listed here."}
+
+ {!zapierSource && (
+
+ Any remote tool URL works here — including a local MCP server like{" "}
+ http://127.0.0.1:8848/mcp.
+
+ )}
+ {matchedEntry && (
+
+
+
+
This looks like {matchedEntry.name}.
+
+
{
+ setLinkError(null);
+ if (matchedEntry.key === "zapier") {
+ continueWithLink();
+ return;
+ }
+ onPick(matchedEntry);
+ }}
+ >
+ {matchedEntry.availability?.available === false
+ ? "Not available"
+ : matchedEntry.key === "zapier"
+ ? "Continue"
+ : `Use ${matchedEntry.name}`}
+
+
+ )}
+
+
+
+ {
+ setLinkInput(e.target.value);
+ setLinkError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") continueWithLink();
+ }}
+ placeholder={zapierSource ? "https://mcp.zapier.com/api/v1/connect?token=…" : "https://example.com/actions"}
+ className="h-10"
+ />
+
+ Continue
+
+
+ {linkError &&
{linkError}
}
+
+
+
+
+
More ways to connect
+
+ For tools that aren’t in the gallery. You’ll need details from the tool’s docs.
+
+
+
+
+
+
+
+ );
+}
+
+function ConnectMethodRow({
+ icon: Icon,
+ title,
+ description,
+ onClick,
+}: {
+ icon: LucideIcon;
+ title: string;
+ description: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+ );
+}
+
+function normalizeAppLink(value: string): string | null {
+ try {
+ const parsed = new URL(value.trim());
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
+ return parsed.toString();
+ } catch {
+ return null;
+ }
+}
+
+function defaultLinkName(link: string): string | null {
+ try {
+ return new URL(link).hostname.replace(/^www\./, "");
+ } catch {
+ return null;
+ }
+}
+
+function LinkConnectStep({
+ link,
+ name,
+ onNameChange,
+ needsKey,
+ onNeedsKeyChange,
+ keyValue,
+ onKeyChange,
+ submitting,
+ onBack,
+ onConnect,
+}: {
+ link: string;
+ name: string;
+ onNameChange: (next: string) => void;
+ needsKey: boolean;
+ onNeedsKeyChange: (next: boolean) => void;
+ keyValue: string;
+ onKeyChange: (next: string) => void;
+ submitting: boolean;
+ onBack: () => void;
+ onConnect: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
Connect with a link
+
{link}
+
+
+
+
+
+
Name
+
onNameChange(e.target.value)}
+ placeholder="My app"
+ className="mt-2 h-11"
+ />
+
+ We filled this in from the link. Change it if you’d like.
+
+
+
+
+
Does it need a key?
+
+ onNeedsKeyChange(false)}
+ />
+ onNeedsKeyChange(true)}
+ />
+
+
+ {needsKey
+ ? "Paste the key this app gave you."
+ : "Most apps just work from the link — pick Yes only if the app gave you a key."}
+
+
+
+ {needsKey && (
+
+
+ App key
+ onKeyChange(e.target.value)}
+ placeholder="••••••••••••••••"
+ className="mt-2 h-11 font-mono"
+ />
+
+
+
+
+
+
Your key is stored securely.
+
+ You can replace it anytime from this app’s page.
+
+
+
+
+ )}
+
+
+
+
+ Back
+
+
+
+ We’ll check the link before turning anything on.
+
+
+ {submitting && }
+ {submitting ? "Checking…" : "Check link"}
+
+
+
+
+ );
+}
+
+function SegmentedOption({
+ label,
+ selected,
+ onClick,
+}: {
+ label: string;
+ selected: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+function ConnectionNameField({
+ name,
+ onNameChange,
+}: {
+ name: string;
+ onNameChange: (next: string) => void;
+}) {
+ return (
+
+
Name
+
onNameChange(e.target.value)}
+ placeholder="My app"
+ className="mt-2 h-11"
+ />
+
+ We filled this in from the app. Change it to tell connections apart.
+
+
+ );
+}
+
+function KeyStep({
+ entry,
+ name,
+ onNameChange,
+ values,
+ onChange,
+ googleSheetsLinks,
+ googleSheetsError,
+ onGoogleSheetsLinksChange,
+ submitting,
+ onBack,
+ onConnect,
+}: {
+ entry: AppGalleryEntry;
+ name: string;
+ onNameChange: (next: string) => void;
+ values: Record;
+ onChange: (next: Record) => void;
+ googleSheetsLinks: string;
+ googleSheetsError: string | null;
+ onGoogleSheetsLinksChange: (next: string) => void;
+ submitting: boolean;
+ onBack: () => void;
+ onConnect: () => void;
+}) {
+ const copy = appCopyFor(entry.key, entry.tagline);
+ const fields = entry.credentialFields ?? [];
+ const allFilled = fields.every(
+ (f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0,
+ );
+ const robotEmail = entry.availability?.robotEmail ?? null;
+ const unavailable = entry.availability?.available === false;
+
+ if (isGoogleSheetsEntry(entry)) {
+ const parsed = parseGoogleSheetIds(googleSheetsLinks);
+ const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0;
+ return (
+
+
+
+
+
Connect Google Sheets
+
{copy.short}
+
+
+
+
+
+
+ {robotEmail ? (
+
+
Share each sheet with this email
+
+
+ {robotEmail}
+
+
void navigator.clipboard?.writeText(robotEmail)}
+ >
+
+ Copy
+
+
+
+ In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.
+
+
+ ) : (
+
+ Google Sheets is not available on this instance yet.
+
+ )}
+
+
+
Paste links to the sheets you shared
+
+
+
+
+
+ Back
+
+
+ {submitting && }
+ {submitting ? "Checking…" : "Connect"}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Connect {entry.name}
+
{copy.short}
+
+
+
+
+
+
+ {fields.length === 0 ? (
+
+ This app doesn’t need a key. Just connect to continue.
+
+ ) : (
+ fields.map((field) => (
+
+
+ {credentialFieldLabel(entry.name, field.label, fields.length)}
+
+
onChange({ ...values, [field.configPath]: e.target.value })}
+ placeholder="••••••••••••••••"
+ className="mt-2 h-11 font-mono"
+ />
+ {field.helpUrl && (
+
+ Where do I find this?
+
+
+ )}
+
+ ))
+ )}
+
+
+
+
+
Your key is stored securely.
+
+ You can replace it anytime from this app’s page.
+
+
+
+
+
+
+
+ Back
+
+
+
+ We’ll check the key before turning anything on.
+
+
+ {submitting && }
+ {submitting ? "Checking…" : "Connect"}
+
+
+
+
+ );
+}
+
+function ActionGroup({
+ title,
+ hint,
+ actions,
+ enabled,
+ onToggle,
+ bulkLabel,
+ onBulk,
+ askFirstLevels,
+}: {
+ title: string;
+ hint: string;
+ actions: ToolAppConnectionActionSummary[];
+ enabled: Record;
+ onToggle: (id: string, on: boolean) => void;
+ bulkLabel: string;
+ onBulk: () => void;
+ askFirstLevels: string[];
+}) {
+ if (actions.length === 0) return null;
+ return (
+
+
+
+ {title}
+ · {hint}
+
+
+ {bulkLabel}
+
+
+
+ {actions.map((action) => {
+ const on = enabled[action.catalogEntryId] ?? false;
+ const showAskFirst = on && askFirstLevels.includes(action.riskLevel);
+ return (
+
+
+
+ {action.title ?? action.toolName}
+
+ {action.description && (
+
{action.description}
+ )}
+
+ {showAskFirst && (
+
+ Ask first
+
+ )}
+
onToggle(action.catalogEntryId, next)} />
+
+ );
+ })}
+
+
+ );
+}
+
+function ActionsStep({
+ appName,
+ result,
+ enabled,
+ onToggle,
+ onBulk,
+ onBack,
+ onContinue,
+}: {
+ appName: string;
+ result: ConnectToolAppResult;
+ enabled: Record;
+ onToggle: (id: string, on: boolean) => void;
+ onBulk: (ids: string[], on: boolean) => void;
+ onBack: () => void;
+ onContinue: () => void;
+}) {
+ const askFirstLevels = askFirstLevelsFrom(result);
+ const { readOnly, canMakeChanges } = result.actions;
+ const total = readOnly.length + canMakeChanges.length;
+ const enabledCount = Object.values(enabled).filter(Boolean).length;
+
+ return (
+
+
+
+
+
+
+
+ Connected to {appName} — it offers {total} {total === 1 ? "action" : "actions"}.
+
+
+ Read-only actions are on. Anything that can change something starts off — turn on the ones you want.
+
+
+
+
+
onBulk(readOnly.map((a) => a.catalogEntryId), false)}
+ askFirstLevels={askFirstLevels}
+ />
+
+ onBulk(canMakeChanges.map((a) => a.catalogEntryId), true)}
+ askFirstLevels={askFirstLevels}
+ />
+
+
+
+ Back
+
+
+
+ If {appName} adds new actions later, they start off until you review them.
+
+
+ Continue with {enabledCount} {enabledCount === 1 ? "action" : "actions"} on
+
+
+
+
+ );
+}
+
+function WhoStep({
+ appName,
+ companyId,
+ access,
+ setAccess,
+ agentIds,
+ setAgentIds,
+ onBack,
+ onContinue,
+}: {
+ appName: string;
+ companyId: string;
+ access: "all" | "specific";
+ setAccess: (a: "all" | "specific") => void;
+ agentIds: Set;
+ setAgentIds: (s: Set) => void;
+ onBack: () => void;
+ onContinue: () => void;
+}) {
+ const agentsQuery = useQuery({
+ queryKey: queryKeys.agents.list(companyId),
+ queryFn: () => agentsApi.list(companyId),
+ enabled: access === "specific",
+ });
+ const agents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
+ const canFinish = access === "all" || agentIds.size > 0;
+
+ return (
+
+
+
Who can use {appName}?
+
You can change this later from the app’s page.
+
+
+
setAccess("all")}
+ className={cn(
+ "flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
+ access === "all" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
+ )}
+ >
+
+
+
+ All agents
+
+ Recommended
+
+
+
+ Anyone you’ve added to Paperclip can use {appName} in their tasks. This is what most teams want.
+
+
+
+
+
setAccess("specific")}
+ className={cn(
+ "flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
+ access === "specific" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
+ )}
+ >
+
+
+
Only specific agents
+
Tick the agents who can use {appName}.
+
+
+
+ {access === "specific" && (
+
+ )}
+
+
+
+
+
+ Back
+
+
+ Continue to install
+
+
+
+ );
+}
+
+export function InstallStep({
+ appName,
+ companyId,
+ access,
+ accessAgentIds,
+ installMode,
+ setInstallMode,
+ installAgentIds,
+ setInstallAgentIds,
+ submitting,
+ onBack,
+ onFinish,
+}: {
+ appName: string;
+ companyId: string;
+ access: "all" | "specific";
+ accessAgentIds: Set;
+ installMode: InstallMode;
+ setInstallMode: (mode: InstallMode) => void;
+ installAgentIds: Set;
+ setInstallAgentIds: (ids: Set) => void;
+ submitting: boolean;
+ onBack: () => void;
+ onFinish: () => void;
+}) {
+ const agentsQuery = useQuery({
+ queryKey: queryKeys.agents.list(companyId),
+ queryFn: () => agentsApi.list(companyId),
+ });
+ const agents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
+ const installSpecific = () => {
+ setInstallMode("specific");
+ if (installAgentIds.size === 0 && access === "specific") setInstallAgentIds(new Set(accessAgentIds));
+ };
+ const extendingAgentIds = access === "all"
+ ? []
+ : installMode === "all"
+ ? agents.filter((agent) => !accessAgentIds.has(agent.id)).map((agent) => agent.id)
+ : [...installAgentIds].filter((id) => !accessAgentIds.has(id));
+ const canFinish = installMode !== "specific" || installAgentIds.size > 0;
+ const extendingLabel = extendingAgentIds.length === 1
+ ? agents.find((agent) => agent.id === extendingAgentIds[0])?.name ?? "1 agent"
+ : `${extendingAgentIds.length} agents`;
+
+ return (
+
+
+
Install {appName} tools?
+
+ Access is permission. Install decides whose runs actually carry these tools.
+
+
+
+
+ {installInfoNotice(appName)}
+
+
+
+
+
setInstallMode("none")}
+ className={cn(
+ "flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
+ installMode === "none" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
+ )}
+ >
+
+
+
Not yet
+
+ Keep {appName} permitted only. You can install it later from the app or agent page.
+
+
+
+
+
+
+
+
Specific agents
+
Tick the agents that should load {appName} every run.
+
+
+
+ {installMode === "specific" ? (
+
+ ) : null}
+
+
setInstallMode("all")}
+ className={cn(
+ "flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
+ installMode === "all" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
+ )}
+ >
+
+
+
All agents
+
{INSTALL_ALL_WARNING}
+
+
+
+ {extendingAgentIds.length > 0 ? (
+
+ {autoExtendNotice(extendingLabel)}
+
+ ) : null}
+
+
+
+
+
+ Back
+
+
+ {submitting && }
+ {submitting ? "Finishing..." : "Finish setup"}
+
+
+
+ );
+}
+
+function Radio({ selected }: { selected: boolean }) {
+ return (
+
+ {selected && }
+
+ );
+}
+
+function SuccessStep({
+ appName,
+ logoUrl,
+ enabledCount,
+ access,
+ installMode,
+ installCount,
+ onDone,
+}: {
+ appName: string;
+ logoUrl?: string | null;
+ enabledCount: number;
+ access: "all" | "specific";
+ installMode: InstallMode;
+ installCount: number;
+ onDone: () => void;
+}) {
+ const installSummary = installMode === "all"
+ ? "Installed on all agents"
+ : installMode === "specific"
+ ? `${installCount} ${installCount === 1 ? "agent" : "agents"} installed`
+ : "Permitted only";
+ return (
+
+
+
+
+
+
+
{appName} is ready.
+
+
+ {installMode === "none"
+ ? "Agents can use it after you install it on their Tools tab."
+ : "Installed agents will load it on their next run."}
+
+
+ {enabledCount} {enabledCount === 1 ? "action" : "actions"} on ·{" "}
+ {access === "all" ? "All agents can use it" : "Specific agents can use it"} · {installSummary}
+
+
+
+ Done
+
+
+
+ );
+}
diff --git a/ui/src/pages/apps/AppsReview.tsx b/ui/src/pages/apps/AppsReview.tsx
new file mode 100644
index 00000000000..719a3a01505
--- /dev/null
+++ b/ui/src/pages/apps/AppsReview.tsx
@@ -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 Select a company to review approvals.
;
+ }
+
+ return (
+
+ );
+}
diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx
new file mode 100644
index 00000000000..8bb4562b65d
--- /dev/null
+++ b/ui/src/pages/apps/Browse.test.tsx
@@ -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 }) => {children} ,
+ 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) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ for (let i = 0; i < 5; i += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ }
+}
+
+function galleryEntry(overrides: Record) {
+ 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;
+
+ 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(
+
+
+ ,
+ );
+ });
+ 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('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('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");
+ });
+});
diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx
new file mode 100644
index 00000000000..5998e0abf3b
--- /dev/null
+++ b/ui/src/pages/apps/Browse.tsx
@@ -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 Select a company to browse apps.
;
+ }
+
+ const loading = galleryQuery.isLoading;
+
+ return (
+
+
+
+
+
+ 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"
+ />
+
+
+ {loading ? (
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+ ) : (
+ <>
+ {!trimmed && popular.length > 0 && (
+
+
+ Popular
+
+
+ {popular.map((entry) => (
+
navigate(ZAPIER_CONNECT_HREF) : undefined}
+ compact
+ />
+ ))}
+
+
+ )}
+
+
+
+ {trimmed ? `Results (${filtered.length})` : "All apps"}
+
+ {filtered.length === 0 ? (
+
+
+ No planned apps match “{query.trim()}”.
+
+ ) : (
+
+ {filtered.map((entry) => (
+
navigate(ZAPIER_CONNECT_HREF) : undefined}
+ />
+ ))}
+
+ )}
+
+
+
navigate(BYO_CONNECT_HREF)} />
+
+
+
+ Zapier connects with the MCP URL it gives you. Other listed integrations are previews.
+
+
+
+ >
+ )}
+
+ );
+}
+
+function AppTile({
+ entry,
+ onConnect,
+ compact = false,
+}: {
+ entry: AppGalleryEntry;
+ onConnect?: () => void;
+ compact?: boolean;
+}) {
+ const disabled = !onConnect;
+ if (compact) {
+ return (
+
+
+ {entry.name}
+
+ {disabled ? "Coming soon" : "Connect →"}
+
+
+ );
+ }
+ return (
+
+
+
+
{entry.name}
+
{entry.tagline}
+
+
+ {disabled ? "Coming soon" : "Connect →"}
+
+
+ );
+}
diff --git a/ui/src/pages/apps/Connections.test.tsx b/ui/src/pages/apps/Connections.test.tsx
new file mode 100644
index 00000000000..3f80ba4bf5f
--- /dev/null
+++ b/ui/src/pages/apps/Connections.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+}));
+
+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) {
+ let result: void | Promise = 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) {
+ 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) {
+ 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;
+
+ 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(
+
+
+ ,
+ );
+ });
+ 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");
+ });
+});
diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx
new file mode 100644
index 00000000000..d5bf011a64d
--- /dev/null
+++ b/ui/src/pages/apps/Connections.tsx
@@ -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 = {
+ 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("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();
+ for (const entry of gallery) map.set(entry.name.toLowerCase(), entry);
+ return map;
+ }, [gallery]);
+ const logoByKey = useMemo(() => {
+ const map = new Map();
+ 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();
+ 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();
+ for (const connection of connections) {
+ map.set(connection.applicationId, [...(map.get(connection.applicationId) ?? []), connection]);
+ }
+ return map;
+ }, [connections]);
+
+ const rows = useMemo(() => {
+ 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((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 Select a company to manage apps.
;
+ }
+
+ const loading = applicationsQuery.isLoading || connectionsQuery.isLoading || galleryQuery.isLoading;
+
+ return (
+
+ {loading ? (
+
+
+
+
+ ) : rows.length === 0 ? (
+
navigate(BROWSE_HREF)} />
+ ) : (
+
+
+
+
+ setFilter("all")}>
+ All ({rows.length})
+
+ setFilter("attention")}
+ >
+ Needs attention ({rowsNeedingAttention.length})
+
+
+
+ {reviewCount > 0 && (
+
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"
+ >
+
+
+
+ {reviewCount} {reviewCount === 1 ? "action is" : "actions are"} waiting for your OK
+
+
+ Your agents paused to check with you before making a change.
+
+
+ Review →
+
+ )}
+
+ {rowsNeedingAttention.length > 0 && (
+
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"
+ >
+
+
+
+ {rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "app needs" : "apps need"} attention
+
+
+ {floatSummary(rowsNeedingAttention)}
+
+
+ Fix →
+
+ )}
+
+
+
+
+
+ App
+ Status
+ Actions
+ Last used
+
+
+
+
+ {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 (
+ 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]",
+ )}
+ >
+
+
+
+
+
+ {application.name}
+
+ {hint && (
+
{hint}
+ )}
+
+
+
+
+
+ {status.label}
+
+
+
+ {row.actionCount} on
+
+
+
+ {row.lastUsedAt ? timeAgo(row.lastUsedAt) : "—"}
+
+
+
+ {
+ event.stopPropagation();
+ navigate(appHref);
+ }}
+ >
+ {actionLabel}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ Apps you connect become available to every agent unless you change “Who can use it”.
+
+
+
+
+ )}
+
+ );
+}
+
+function FilterChip({
+ active,
+ tone = "default",
+ disabled = false,
+ onClick,
+ children,
+}: {
+ active: boolean;
+ tone?: "default" | "danger";
+ disabled?: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
No connections yet.
+
+ Add one from Apps to give your agents
+ the tools they need.
+
+
+ Browse apps
+
+
+
+ );
+}
diff --git a/ui/src/pages/apps/ReviewQueueCard.test.tsx b/ui/src/pages/apps/ReviewQueueCard.test.tsx
new file mode 100644
index 00000000000..07df963db62
--- /dev/null
+++ b/ui/src/pages/apps/ReviewQueueCard.test.tsx
@@ -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) {
+ let result: void | Promise = 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;
+
+ 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(
+
+
+ ,
+ );
+ });
+ 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" }));
+ });
+});
diff --git a/ui/src/pages/apps/ReviewQueueCard.tsx b/ui/src/pages/apps/ReviewQueueCard.tsx
new file mode 100644
index 00000000000..412b14fff25
--- /dev/null
+++ b/ui/src/pages/apps/ReviewQueueCard.tsx
@@ -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 (
+
+ Nothing is waiting for your OK right now.
+
+ );
+ }
+
+ return (
+
+
+
+
{heading}
+
+ {items.length}
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionRequestListItem }) {
+ const queryClient = useQueryClient();
+ const { pushToast } = useToast();
+ const [resolving, setResolving] = useState(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 (
+
+
+ {actionLabel(item)}
+ {item.applicationName && (
+
+ in {humanizeConnectionDisplayName(item.applicationName)}
+
+ )}
+ · asked {timeAgo(item.request.createdAt)}
+
+
+ {preview ? (
+
+ {preview}
+
+ ) : (
+
+ An agent wants to run this action. It can change something, so we’re checking with you first.
+
+ )}
+
+
+ allowOnce.mutate()} disabled={busy}>
+ {resolving === "allow" ? : }
+ Allow once
+
+ alwaysAllow.mutate()} disabled={busy}>
+ {resolving === "always" ? : null}
+ Always allow
+
+ decline.mutate()} disabled={busy}>
+ {resolving === "decline" ? : }
+ Decline
+
+
+
+ );
+}
+
+function actionLabel(item: ToolActionRequestListItem): string {
+ if (!item.toolTitle && !item.toolName) return "This action";
+ return humanizeConnectionDisplayName(item.toolName ?? "", { title: item.toolTitle });
+}
+
+function failToast(
+ pushToast: ReturnType["pushToast"],
+ error: unknown,
+) {
+ pushToast({
+ title: "Couldn’t save that",
+ body: error instanceof Error ? error.message : "Please try again.",
+ tone: "error",
+ });
+}
diff --git a/ui/src/pages/apps/app-detail/ActivityPanel.render.test.tsx b/ui/src/pages/apps/app-detail/ActivityPanel.render.test.tsx
new file mode 100644
index 00000000000..2eed5c30a99
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/ActivityPanel.render.test.tsx
@@ -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 }) => {children} ,
+}));
+
+// 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 {
+ 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[0]) {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => root.render( ));
+ 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");
+ });
+});
diff --git a/ui/src/pages/apps/app-detail/ActivityPanel.test.tsx b/ui/src/pages/apps/app-detail/ActivityPanel.test.tsx
new file mode 100644
index 00000000000..4606813e8df
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/ActivityPanel.test.tsx
@@ -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 {
+ 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 ' tested as ' 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");
+ });
+});
diff --git a/ui/src/pages/apps/app-detail/ActivityPanel.tsx b/ui/src/pages/apps/app-detail/ActivityPanel.tsx
new file mode 100644
index 00000000000..ae28488ba57
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/ActivityPanel.tsx
@@ -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 ;
+}
+
+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(() => {
+ 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 (
+
+
+
Recent activity
+
+ {loading ? (
+
+
+
+
+ ) : rows.length === 0 ? (
+ No activity yet.
+ ) : (
+
+ {rows.map((row) => (
+
+
+
+ {row.primary}
+
+ {row.issue ? (
+ <>
+ while working on{" "}
+
+ {row.issue.identifier}
+
+ {" · "}
+ >
+ ) : null}
+ {timeAgo(row.createdAt)}
+ {row.link ? (
+ <>
+ {" · "}
+
+ {row.link.label}
+
+ >
+ ) : null}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+const HUMANIZED_EVENTS = new Set([
+ "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 | 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 " tested as " 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 | 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";
+}
diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx
new file mode 100644
index 00000000000..21407a9f243
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx
@@ -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 & {
+ removing: boolean;
+ onRemove: () => void;
+ onReplaced: () => void;
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+function KeySection({
+ connection,
+ galleryEntry,
+ onReplaced,
+}: {
+ connection: ToolConnection;
+ galleryEntry: AppGalleryEntry | null;
+ onReplaced: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+
+
+
Key
+
+ Your key is stored securely. Replace it if it stopped working or you rotated it.
+
+
+
+ {!open && (
+
setOpen(true)}>
+ Replace key
+
+ )}
+
+ {open && (
+
+ setOpen(false)}
+ onReconnected={() => {
+ setOpen(false);
+ onReplaced();
+ }}
+ />
+
+ )}
+
+ );
+}
+
+export function ReconnectCard({
+ connection,
+ galleryEntry,
+ onReconnected,
+}: {
+ connection: ToolConnection;
+ galleryEntry: AppGalleryEntry | null;
+ onReconnected: () => void;
+}) {
+ return (
+
+
This app needs reconnecting
+
+ {connection.healthMessage?.trim() || "The key stopped working. Paste a new one to get it back online."}
+
+
+
+
+
+ );
+}
+
+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>({});
+ 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 (
+
+ {usesGallery ? (
+ fields.map((field) => (
+
+
{field.label}
+
setValues({ ...values, [field.configPath]: e.target.value })}
+ placeholder="****************"
+ className="mt-1 h-10 font-mono"
+ />
+ {field.helpUrl && (
+
+ Where do I find this?
+
+ )}
+
+ ))
+ ) : (
+
setSingle(e.target.value)}
+ placeholder="Paste your new key"
+ className="h-10 font-mono"
+ />
+ )}
+
+ reconnect.mutate()}>
+ {reconnect.isPending && }
+ {reconnect.isPending ? "Checking..." : "Check & reconnect"}
+
+ {onCancel && (
+
+ Cancel
+
+ )}
+
+
+ );
+}
+
+function TechnicalDetails({ connection }: { connection: ToolConnection }) {
+ return (
+
+ Technical details
+
+ Address
+ {connectionAddress(connection)}
+ Connection type
+ {connectionTransportLabel(connection.transport)}
+
+
+ );
+}
+
+export function DangerZone({
+ appName,
+ removing,
+ onRemove,
+}: {
+ appName: string;
+ removing: boolean;
+ onRemove: () => void;
+}) {
+ const [confirming, setConfirming] = useState(false);
+ return (
+
+
+ Danger zone
+
+
+
+
Remove this app
+
+ Agents lose access to {appName} right away. You can connect it again later.
+
+
+ {confirming ? (
+
+ setConfirming(false)} disabled={removing}>
+ Cancel
+
+
+ {removing && }
+ Yes, remove it
+
+
+ ) : (
+
setConfirming(true)}>
+ Remove app
+
+ )}
+
+
+ );
+}
+
+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";
+}
diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
new file mode 100644
index 00000000000..c3c1b0c4a8c
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
@@ -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 (
+
+ );
+}
+
+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(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 (
+
+
+
+
Who can use it
+
{summary}
+
+ {!editing && (
+
setEditing(true)}>
+ Change
+
+ )}
+
+
+ {editing && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
Installed on agents
+
+ Whose harness carries {appName}'s tools on every run.
+
+
+
+ {disabled && Saving… }
+ {install.onAll ? (
+
+ ) : install.agentIds.size > 0 ? (
+
+ ) : (
+
+ Permitted only — not installed on any agent
+
+ )}
+
+
+
+
+
+ {installInfoNotice(appName)}
+
+
+ {!install.onAll && (
+
(hasAccess(agent.id) ? "has access" : "no access yet")}
+ renderNameSuffix={(agent) =>
+ !hasAccess(agent.id) && install.agentIds.has(agent.id) ? (
+
+ will grant access
+
+ ) : null
+ }
+ onChange={(agentIds) => onSave({ onAll: false, agentIds })}
+ />
+ )}
+
+
+
+ onSave(checked ? { onAll: true, agentIds: new Set() } : { onAll: false, agentIds: new Set() })
+ }
+ />
+
+ Install on all agents
+
+ {INSTALL_ALL_WARNING}
+
+
+
+
+ {extendingAgents.length > 0 ? (
+
+
+ {autoExtendNotice(
+ extendingAgents.length === 1
+ ? liveAgents.find((a) => a.id === extendingAgents[0])?.name ?? "1 agent"
+ : `${extendingAgents.length} agents`,
+ )}{" "}
+
+ Review the {extendingAgents.length} access change
+ {extendingAgents.length === 1 ? "" : "s"}
+
+
+
+ ) : null}
+
+
+ );
+}
+
+function InstalledBadge({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function ActionsSection({
+ readOnly,
+ canChange,
+ quarantined,
+ enabledIds,
+ askFirstIds,
+ disabled,
+ refreshPending,
+ focusId,
+ onSetPermission,
+ onTurnOnQuarantined,
+ onRefreshActions,
+}: {
+ readOnly: ToolCatalogEntry[];
+ canChange: ToolCatalogEntry[];
+ quarantined: ToolCatalogEntry[];
+ enabledIds: Set;
+ askFirstIds: Set;
+ disabled: boolean;
+ refreshPending: boolean;
+ focusId?: string | null;
+ onSetPermission: (id: string, next: ActionPermission) => void;
+ onTurnOnQuarantined: (ids: string[]) => void;
+ onRefreshActions: () => void;
+}) {
+ return (
+
+
+
+
Action permissions
+
+ Choose what agents can do and what needs a human first.
+
+
+
+ {disabled && Saving... }
+
+ {refreshPending ? (
+
+ ) : (
+
+ )}
+ Refresh actions
+
+
+
+
+ {quarantined.length > 0 && (
+
+ )}
+
+
+
+
+ );
+}
+
+function ActionGroup({
+ title,
+ hint,
+ actions,
+ enabledIds,
+ askFirstIds,
+ disabled,
+ focusId,
+ onSetPermission,
+}: {
+ title: string;
+ hint: string;
+ actions: ToolCatalogEntry[];
+ enabledIds: Set;
+ askFirstIds: Set;
+ disabled: boolean;
+ focusId?: string | null;
+ onSetPermission: (id: string, next: ActionPermission) => void;
+}) {
+ const focusRef = useRef(null);
+ useEffect(() => {
+ if (focusId && focusRef.current) {
+ focusRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ }, [focusId]);
+ if (actions.length === 0) return null;
+ return (
+
+
+ {title}
+ - {hint}
+
+
+ {actions.map((action) => {
+ const value = actionPermission(action.id, enabledIds, askFirstIds);
+ const focused = focusId === action.id;
+ return (
+
+
+
{action.title ?? action.toolName}
+ {action.description && (
+
{action.description}
+ )}
+
+
onSetPermission(action.id, event.currentTarget.value as ActionPermission)}
+ >
+ Off
+ Allowed
+ Ask a human first
+
+
+ );
+ })}
+
+
+ );
+}
+
+function actionPermission(
+ id: string,
+ enabledIds: Set,
+ askFirstIds: Set,
+): ActionPermission {
+ if (!enabledIds.has(id)) return "off";
+ return askFirstIds.has(id) ? "ask" : "allowed";
+}
diff --git a/ui/src/pages/apps/app-detail/ReviewPanel.tsx b/ui/src/pages/apps/app-detail/ReviewPanel.tsx
new file mode 100644
index 00000000000..221b2819970
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/ReviewPanel.tsx
@@ -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 &
+ Partial> & {
+ onTurnOnQuarantined?: (ids: string[]) => void;
+ }) {
+ const showsQuarantinedActions = quarantined.length > 0 && !!onTurnOnQuarantined;
+
+ return (
+
+ {showsQuarantinedActions ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/ui/src/pages/apps/app-detail/SetupPanel.tsx b/ui/src/pages/apps/app-detail/SetupPanel.tsx
new file mode 100644
index 00000000000..a8e66fb4e82
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/SetupPanel.tsx
@@ -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) => 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 (
+
+ {description && (
+
{description}
+ )}
+ {galleryEntry?.key === "google-sheets" && (
+
+ )}
+ {hasOAuthSignIn && (
+
).connectedAt)}
+ isSmokeLabFixture={isSmokeLabFixture}
+ disabled={oauthStartDisabled}
+ onStart={onStartOAuth}
+ />
+ )}
+
+
+ );
+}
+
+function OAuthConnectionSection({
+ connected,
+ isSmokeLabFixture,
+ disabled,
+ onStart,
+}: {
+ connected: boolean;
+ isSmokeLabFixture: boolean;
+ disabled: boolean;
+ onStart: () => void;
+}) {
+ const providerName = isSmokeLabFixture ? "Smoke OAuth" : "OAuth";
+ return (
+
+
+
+
+ {connected ? `Connected with ${providerName}` : `Connect with ${providerName}`}
+
+
+ {connected
+ ? "Sign in again to replace this connection's OAuth session."
+ : "Open the provider's consent page to finish connecting this app."}
+
+
+
+ {connected ? "Reconnect" : `Connect with ${providerName}`}
+
+
+
+ );
+}
+
+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) => void;
+}) {
+ const [draft, setDraft] = useState("");
+ const [error, setError] = useState(null);
+ const ids = currentSpreadsheetIds(connection);
+ const saveIds = (nextIds: string[]) =>
+ onUpdateConfig(googleSheetsConfigWithAllowlist(connection.config, nextIds));
+
+ return (
+
+
+
Sheets agents can use
+
+ Agents can only use the sheets listed here.
+
+
+
+
+ {ids.length === 0 ? (
+
No sheets are connected yet.
+ ) : (
+ ids.map((id) => {
+ const sheetUrl = googleSheetsUrlForId(id);
+ return (
+
+ );
+ })
+ )}
+
+
+
+ {
+ setDraft(event.target.value);
+ setError(null);
+ }}
+ placeholder="https://docs.google.com/spreadsheets/d/..."
+ className="h-10"
+ />
+ {
+ 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
+
+
+ {error && {error}
}
+
+ );
+}
+
+export function AppLifecycleSection({
+ connection,
+ disabled,
+ onToggle,
+}: {
+ connection: ToolConnection;
+ disabled: boolean;
+ onToggle: () => void;
+}) {
+ const enabled = connection.enabled !== false && connection.status !== "disabled";
+ return (
+
+
+
+
+ {enabled ? "Agents can use this app" : "This app is paused"}
+
+
+ {enabled
+ ? "Pause it to stop every agent from using its actions."
+ : "Resume it when agents should be able to use its actions again."}
+
+
+
+
+
+ );
+}
+
+export function QuarantinePill({
+ count,
+ entries,
+ disabled,
+ onTurnOn,
+}: {
+ count: number;
+ entries: ToolCatalogEntry[];
+ disabled: boolean;
+ onTurnOn: (ids: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+ {count} new {count === 1 ? "action" : "actions"} to review
+
+
+ setOpen((v) => !v)}>
+ {open ? "Hide" : "Review"}
+
+ onTurnOn(entries.map((e) => e.id))}>
+ Turn on all
+
+
+
+
+ This app added actions since you set it up. They stay off until you turn them on.
+
+ {open && (
+
+ {entries.map((entry) => (
+
+
+
{entry.title ?? entry.toolName}
+ {entry.description && (
+
{entry.description}
+ )}
+
+
onTurnOn([entry.id])}>
+ Turn on
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/ui/src/pages/apps/app-detail/TestPanel.test.tsx b/ui/src/pages/apps/app-detail/TestPanel.test.tsx
new file mode 100644
index 00000000000..0e06c06d624
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/TestPanel.test.tsx
@@ -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 }) => (
+
+ {children}
+
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ for (let i = 0; i < 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 {
+ 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 = {}) {
+ 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(
+
+
+ ,
+ );
+}
+
+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."]);
+ });
+});
diff --git a/ui/src/pages/apps/app-detail/TestPanel.tsx b/ui/src/pages/apps/app-detail/TestPanel.tsx
new file mode 100644
index 00000000000..3868c04e287
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/TestPanel.tsx
@@ -0,0 +1,1461 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ Ban,
+ Check,
+ ChevronDown,
+ ChevronsUpDown,
+ Clock,
+ Loader2,
+ Play,
+ Search,
+ ShieldQuestion,
+} from "lucide-react";
+import type {
+ ToolCatalogEntry,
+ ToolConnectionAccessSummary,
+ ToolConnectionTestAgent,
+ ToolConnectionTestCallResult,
+ ToolConnectionTestCallStatus,
+ ToolConnectionTestDecision,
+} from "@paperclipai/shared";
+import { Link } from "@/lib/router";
+import { toolsApi } from "@/api/tools";
+import { queryKeys } from "@/lib/queryKeys";
+import { useCompany } from "@/context/CompanyContext";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ JsonSchemaForm,
+ getDefaultValues,
+ validateJsonSchemaForm,
+ type JsonSchemaNode,
+} from "@/components/JsonSchemaForm";
+import { cn, relativeTime } from "@/lib/utils";
+import { appTabHref } from "../app-tabs";
+
+// ---------------------------------------------------------------------------
+// Small format helpers
+// ---------------------------------------------------------------------------
+
+/** "1.2s" / "0.4s" — the copy-spec always shows seconds with one decimal. */
+function seconds(ms: number): string {
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+/** relativeTime() returns "just now"; the spec capitalizes it ("Just now"). */
+function relTime(date: Date): string {
+ const t = relativeTime(date);
+ return t.charAt(0).toUpperCase() + t.slice(1);
+}
+
+/** Sub-line copy: first sentence of the catalog description, no trailing period. */
+function actionSubLine(entry: ToolCatalogEntry): string | null {
+ if (!entry.description) return null;
+ const firstSentence = entry.description.split(/(?<=\.)\s/)[0] ?? entry.description;
+ return firstSentence.replace(/\.+$/, "").trim() || null;
+}
+
+// ---------------------------------------------------------------------------
+// Decision badges
+// ---------------------------------------------------------------------------
+
+type DecisionMeta = { label: string; className: string };
+
+const DECISION_META: Record = {
+ allowed: {
+ label: "Allowed",
+ className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
+ },
+ ask_first: {
+ label: "Ask first",
+ className: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ },
+ off: {
+ label: "Off",
+ className: "border-border bg-muted text-muted-foreground",
+ },
+};
+
+function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) {
+ const meta = DECISION_META[decision];
+ return (
+
+ {meta.label}
+
+ );
+}
+
+/** "Allowed for 1 action · Ask first for 2 · Off for 1" — singular gets " action". */
+function summaryCount(label: string, n: number): string {
+ return `${label} ${n}${n === 1 ? " action" : ""}`;
+}
+
+function accessSummaryLine(summary: ToolConnectionAccessSummary): string {
+ return [
+ summaryCount("Allowed for", summary.allowedCount),
+ summaryCount("Ask first for", summary.askFirstCount),
+ summaryCount("Off for", summary.offCount),
+ ].join(" · ");
+}
+
+// ---------------------------------------------------------------------------
+// Panel
+// ---------------------------------------------------------------------------
+
+export function TestPanel({
+ connectionId,
+ appName,
+ active,
+ quarantined = [],
+}: {
+ connectionId: string;
+ appName: string;
+ /** Active (non-quarantined, non-removed) catalog entries. */
+ active: ToolCatalogEntry[];
+ /** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */
+ quarantined?: ToolCatalogEntry[];
+}) {
+ const testAgentsQuery = useQuery({
+ queryKey: queryKeys.tools.testAgents(connectionId),
+ queryFn: () => toolsApi.listTestAgents(connectionId),
+ enabled: !!connectionId,
+ });
+
+ const agents = useMemo(
+ () => [...(testAgentsQuery.data?.agents ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
+ [testAgentsQuery.data],
+ );
+
+ const [agentId, setAgentId] = useState(null);
+ // Default to the first agent (alphabetical) that can run at least one action;
+ // otherwise the first agent we can test as at all.
+ useEffect(() => {
+ if (agentId && agents.some((a) => a.id === agentId)) return;
+ if (agents.length === 0) return;
+ const withAccess = agents.find((a) => a.effectiveAccess.allowedCount > 0);
+ setAgentId((withAccess ?? agents[0]).id);
+ }, [agents, agentId]);
+
+ // Switches the header from "TEST AS" card to the compact "Testing as …" line.
+ const [hasInteracted, setHasInteracted] = useState(false);
+
+ const selectedAgent = agents.find((a) => a.id === agentId) ?? null;
+
+ // Per-action decision for the selected agent, keyed by both the upstream and
+ // gateway tool names so we can match whatever the catalog stores.
+ const decisionByTool = useMemo(() => {
+ const map = new Map();
+ for (const tool of selectedAgent?.effectiveAccess.tools ?? []) {
+ map.set(tool.toolName, tool.decision);
+ map.set(tool.gatewayToolName, tool.decision);
+ }
+ return map;
+ }, [selectedAgent]);
+
+ const decisionFor = (entry: ToolCatalogEntry): ToolConnectionTestDecision =>
+ decisionByTool.get(entry.toolName) ?? "off";
+
+ // Search + read/write filter.
+ const [query, setQuery] = useState("");
+ const [kindFilter, setKindFilter] = useState<"all" | "read" | "write">("all");
+
+ const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) =>
+ (a.title ?? a.toolName).localeCompare(b.title ?? b.toolName);
+ const readActions = active.filter((e) => e.isReadOnly).sort(byName);
+ const writeActions = active.filter((e) => !e.isReadOnly).sort(byName);
+
+ const matches = (entry: ToolCatalogEntry) => {
+ if (kindFilter === "read" && !entry.isReadOnly) return false;
+ if (kindFilter === "write" && entry.isReadOnly) return false;
+ const needle = query.trim().toLowerCase();
+ if (!needle) return true;
+ return (
+ (entry.title ?? entry.toolName).toLowerCase().includes(needle) ||
+ (entry.description ?? "").toLowerCase().includes(needle)
+ );
+ };
+
+ const quarantinedActions = [...quarantined].sort(byName);
+
+ const visibleRead = readActions.filter(matches);
+ const visibleWrite = writeActions.filter(matches);
+ const visibleQuarantined = quarantinedActions.filter(matches);
+ const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length;
+
+ if (testAgentsQuery.isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (active.length === 0 && quarantinedActions.length === 0) {
+ return ;
+ }
+
+ if (agents.length === 0) {
+ return (
+
+
No agents to test as
+
+ Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
+
+ Permissions
+ {" "}
+ to test it here.
+
+
+ );
+ }
+
+ const sharedRowProps = {
+ connectionId,
+ appName,
+ allAgents: agents,
+ onSelectAgent: setAgentId,
+ onInteract: () => setHasInteracted(true),
+ };
+
+ return (
+
+ {selectedAgent && (
+
+ )}
+
+
+
+
+
+ setQuery(event.target.value)}
+ />
+
+
setKindFilter("all")} />
+ setKindFilter("read")} />
+ setKindFilter("write")} />
+
+
{visibleCount} matches · sorted A–Z
+
+
+ {visibleCount === 0 ? (
+
+ No actions match “{query}”. Clear the search to see them all.
+
+ ) : (
+
+ {visibleRead.length > 0 && selectedAgent && (
+
+ )}
+ {visibleWrite.length > 0 && selectedAgent && (
+
+ )}
+ {visibleQuarantined.length > 0 && selectedAgent && (
+
"off" as const}
+ agent={selectedAgent}
+ {...sharedRowProps}
+ />
+ )}
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Empty state
+// ---------------------------------------------------------------------------
+
+function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
+ return (
+
+
Nothing to test yet
+
+ Once {appName} is connected, the actions it offers will show up here so you can try them out.
+
+
+ Go to Setup
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Test-as header + agent picker
+// ---------------------------------------------------------------------------
+
+function TestAsHeader({
+ appName,
+ agents,
+ selectedAgent,
+ onSelect,
+ connectionId,
+ compact,
+}: {
+ appName: string;
+ agents: ToolConnectionTestAgent[];
+ selectedAgent: ToolConnectionTestAgent;
+ onSelect: (agentId: string) => void;
+ connectionId: string;
+ compact: boolean;
+}) {
+ if (compact) {
+ return (
+
+
+ Testing as{" "}
+
+
+
{accessSummaryLine(selectedAgent.effectiveAccess)}
+
+ );
+ }
+ return (
+
+
+
+
{accessSummaryLine(selectedAgent.effectiveAccess)}
+
+
+ Runs real actions in {appName}, exactly as this agent would.
+
+
+ );
+}
+
+function AgentPicker({
+ agents,
+ selectedAgent,
+ onSelect,
+ connectionId,
+ appName,
+ inline,
+}: {
+ agents: ToolConnectionTestAgent[];
+ selectedAgent: ToolConnectionTestAgent;
+ onSelect: (agentId: string) => void;
+ connectionId: string;
+ appName: string;
+ inline?: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const filtered = agents.filter((a) =>
+ a.name.toLowerCase().includes(search.trim().toLowerCase()),
+ );
+
+ return (
+ { setOpen(next); if (!next) setSearch(""); }}>
+
+
+ {selectedAgent.name}
+
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ autoFocus
+ />
+
+
+
+ {filtered.length === 0 ? (
+
No agents match.
+ ) : (
+ filtered.map((agent) => {
+ const summary = agent.effectiveAccess;
+ const noAccess = summary.allowedCount === 0 && summary.askFirstCount === 0;
+ return (
+
{
+ onSelect(agent.id);
+ setOpen(false);
+ setSearch("");
+ }}
+ className={cn(
+ "flex w-full items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-accent",
+ agent.id === selectedAgent.id && "bg-accent",
+ )}
+ >
+
+
+ {agent.name}
+
+ {noAccess
+ ? "No access — not allowed for any action"
+ : `Allowed ${summary.allowedCount} · Ask first ${summary.askFirstCount} · Off ${summary.offCount}`}
+
+
+
+ );
+ })
+ )}
+
+
+
Only agents you can assign tasks to are listed.
+
Pick one to preview what they'd see in {appName}.
+
+
+
What the badges mean
+
+ Allowed — runs immediately when you press Run.
+ Ask first — Run is parked in Review for your OK.
+
+ Off — won't run. Change it in{" "}
+
+ Permissions
+ .
+
+
+
+ Badges reflect this agent's current settings, not yours. Swap agents to see how an action would behave for each.
+
+
+
+
+ );
+}
+
+function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
+ return (
+
+ {label}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Action group + rows
+// ---------------------------------------------------------------------------
+
+type RowSharedProps = {
+ connectionId: string;
+ appName: string;
+ allAgents: ToolConnectionTestAgent[];
+ onSelectAgent: (agentId: string) => void;
+ onInteract: () => void;
+};
+
+function ActionGroup({
+ heading,
+ subheading,
+ entries,
+ decisionFor,
+ agent,
+ ...shared
+}: {
+ heading: string;
+ subheading?: string;
+ entries: ToolCatalogEntry[];
+ decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision;
+ agent: ToolConnectionTestAgent;
+} & RowSharedProps) {
+ return (
+
+ {heading}
+ {subheading && {subheading}
}
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+ );
+}
+
+function ActionRow({
+ entry,
+ decision,
+ agent,
+ ...shared
+}: {
+ entry: ToolCatalogEntry;
+ decision: ToolConnectionTestDecision;
+ agent: ToolConnectionTestAgent;
+} & RowSharedProps) {
+ const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent)));
+ const title = entry.title ?? entry.toolName;
+ const sub = actionSubLine(entry);
+
+ useEffect(() => {
+ if (loadStoredAskFirstOutcome(shared.connectionId, entry, agent)) {
+ setOpen(true);
+ }
+ }, [shared.connectionId, entry, agent]);
+
+ return (
+
+
+
+
+
+ {title}
+ {sub && {sub} }
+
+
+
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// The actual tester (form + run + result)
+// ---------------------------------------------------------------------------
+
+type RunOutcome = {
+ result: ToolConnectionTestCallResult;
+ agentName: string;
+ durationMs: number;
+ ranAt: Date;
+};
+
+function testOutcomeStorageKey(connectionId: string, entry: ToolCatalogEntry, agentId: string): string {
+ return `paperclip:test-call:${connectionId}:${agentId}:${entry.id}:${entry.toolName}`;
+}
+
+function loadStoredAskFirstOutcome(connectionId: string, entry: ToolCatalogEntry, agent: ToolConnectionTestAgent): RunOutcome | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.sessionStorage.getItem(testOutcomeStorageKey(connectionId, entry, agent.id));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as {
+ result?: ToolConnectionTestCallResult;
+ agentName?: string;
+ durationMs?: number;
+ ranAt?: string;
+ };
+ if (!parsed.result || parsed.result.decision !== "ask_first" || typeof parsed.result.actionRequestId !== "string") {
+ return null;
+ }
+ return {
+ result: parsed.result,
+ agentName: parsed.agentName || agent.name,
+ durationMs: typeof parsed.durationMs === "number" ? parsed.durationMs : 0,
+ ranAt: parsed.ranAt ? new Date(parsed.ranAt) : new Date(),
+ };
+ } catch {
+ return null;
+ }
+}
+
+function storeAskFirstOutcome(connectionId: string, entry: ToolCatalogEntry, agentId: string, outcome: RunOutcome | null) {
+ if (typeof window === "undefined") return;
+ const key = testOutcomeStorageKey(connectionId, entry, agentId);
+ try {
+ if (!outcome || outcome.result.decision !== "ask_first") {
+ window.sessionStorage.removeItem(key);
+ return;
+ }
+ window.sessionStorage.setItem(key, JSON.stringify({ ...outcome, ranAt: outcome.ranAt.toISOString() }));
+ } catch {
+ // Session storage is only a same-tab convenience. If it is unavailable, the
+ // request is still visible in Review and the backend lifecycle remains intact.
+ }
+}
+
+/** Fold optional fields behind the JsonSchemaForm "More options" disclosure. */
+function splitRequiredOptional(schema: JsonSchemaNode): JsonSchemaNode {
+ const required = new Set(schema.required ?? []);
+ const props = schema.properties ?? {};
+ const next: Record = {};
+ for (const [key, prop] of Object.entries(props)) {
+ next[key] = required.has(key) ? prop : { ...prop, "x-paperclip-advanced": true };
+ }
+ return { ...schema, properties: next };
+}
+
+const GUT_CHECK: Record string> = {
+ allowed: (app, agent) => `This runs a real call against ${app} as ${agent}.`,
+ ask_first: () => `Waiting for your OK before this call leaves Paperclip.`,
+ off: (_app, agent) => `No call will be made — this action is off for ${agent}.`,
+};
+
+function ActionTester({
+ entry,
+ decision,
+ connectionId,
+ appName,
+ agent,
+ allAgents,
+ onSelectAgent,
+ onInteract,
+}: {
+ entry: ToolCatalogEntry;
+ decision: ToolConnectionTestDecision;
+ agent: ToolConnectionTestAgent;
+} & RowSharedProps) {
+ const queryClient = useQueryClient();
+ const { selectedCompanyId } = useCompany();
+ const rawSchema = (entry.inputSchema ?? { type: "object", properties: {} }) as JsonSchemaNode;
+ const formSchema = useMemo(() => splitRequiredOptional(rawSchema), [rawSchema]);
+ const [values, setValues] = useState>(() => getDefaultValues(rawSchema));
+ const [errors, setErrors] = useState>({});
+ const [outcome, setOutcome] = useState(() =>
+ loadStoredAskFirstOutcome(connectionId, entry, agent)
+ );
+
+ // Running card state — keep the spinner visible ≥200ms (anti-flicker).
+ const [running, setRunning] = useState(false);
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const startedAtRef = useRef(0);
+ const cancelledRef = useRef(false);
+
+ const isOff = decision === "off";
+
+ useEffect(() => {
+ if (!running) return;
+ const id = window.setInterval(() => setElapsedMs(Date.now() - startedAtRef.current), 100);
+ return () => window.clearInterval(id);
+ }, [running]);
+
+ const run = useMutation({
+ mutationFn: async () => {
+ const result = await toolsApi.runTestCall(connectionId, {
+ agentId: agent.id,
+ toolName: entry.toolName,
+ parameters: values,
+ });
+ return result;
+ },
+ onSuccess: (result) => {
+ if (cancelledRef.current) return;
+ const durationMs = Date.now() - startedAtRef.current;
+ const finish = () => {
+ if (cancelledRef.current) return;
+ const nextOutcome = { result, agentName: agent.name, durationMs, ranAt: new Date() };
+ setRunning(false);
+ setOutcome(nextOutcome);
+ storeAskFirstOutcome(connectionId, entry, agent.id, nextOutcome);
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionActivity(connectionId) });
+ if (selectedCompanyId) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.actionRequests(selectedCompanyId, "pending") });
+ queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
+ }
+ };
+ const remaining = 200 - durationMs;
+ if (remaining > 0) window.setTimeout(finish, remaining);
+ else finish();
+ },
+ onError: () => {
+ if (cancelledRef.current) return;
+ setRunning(false);
+ },
+ });
+
+ const onRun = () => {
+ const validationErrors = validateJsonSchemaForm(rawSchema, values);
+ setErrors(validationErrors);
+ if (Object.keys(validationErrors).length > 0) return;
+ onInteract();
+ cancelledRef.current = false;
+ startedAtRef.current = Date.now();
+ setElapsedMs(0);
+ setOutcome(null);
+ setRunning(true);
+ run.mutate();
+ };
+
+ const onReset = () => {
+ cancelledRef.current = true;
+ setRunning(false);
+ setOutcome(null);
+ storeAskFirstOutcome(connectionId, entry, agent.id, null);
+ setErrors({});
+ setValues(getDefaultValues(rawSchema));
+ };
+
+ const onCancelRunning = () => {
+ cancelledRef.current = true;
+ setRunning(false);
+ };
+
+ if (isOff) {
+ return (
+
+ );
+ }
+
+ const hasFields = Object.keys(rawSchema.properties ?? {}).length > 0;
+
+ return (
+
+ {hasFields ? (
+
+ ) : (
+
This action takes no inputs.
+ )}
+
+
{GUT_CHECK[decision](appName, agent.name)}
+
+
+
+ {running ? (
+ <>
+ Running…
+ >
+ ) : (
+ <>
+ {outcome ? "Run again" : "Run"}
+ >
+ )}
+
+
+ Reset
+
+
+
+ {running && (
+
+ )}
+
+ {run.isError && !running && (
+
+ Couldn't reach {agent.name}. {run.error instanceof Error ? run.error.message : "Please try again."}
+
+ )}
+
+ {outcome && !running && (
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Running card (T6)
+// ---------------------------------------------------------------------------
+
+function RunningCard({
+ entry,
+ appName,
+ agentName,
+ elapsedMs,
+ onCancel,
+}: {
+ entry: ToolCatalogEntry;
+ appName: string;
+ agentName: string;
+ elapsedMs: number;
+ onCancel: () => void;
+}) {
+ const verb = entry.isReadOnly ? "Reading from" : entry.isWrite ? "Writing to" : "Calling";
+ return (
+
+
+
+ Running…
+
+
+ {verb} {appName} as {agentName}.
+
+
+ Started {seconds(elapsedMs)} ago · Press cancel to stop
+
+ Cancel
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Result branches
+// ---------------------------------------------------------------------------
+
+function ResultPanel({
+ outcome,
+ entry,
+ appName,
+ connectionId,
+}: {
+ outcome: RunOutcome;
+ entry: ToolCatalogEntry;
+ appName: string;
+ connectionId: string;
+}) {
+ const { result } = outcome;
+ if (result.decision === "ask_first") {
+ return ;
+ }
+ if (result.decision === "off") {
+ return (
+
+ {result.error?.message ?? "This action is off and won't run."}
+
+ );
+ }
+ // The gateway can return `decision:"allowed"` (policy let the call through) yet
+ // the upstream MCP tool still fails at the tool layer (`isError:true` in the
+ // result envelope). Surface that as a failure card, not the green "Worked" one.
+ const toolError = result.error ?? mcpToolError(result.result);
+ if (toolError) {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * A tool can return `decision:"allowed"` and still fail at the MCP layer — the
+ * gateway normalizes that into `{ data: { isError: true }, error: "…" }` inside
+ * the result envelope. Pull a renderable error out of that shape, or null when
+ * the result is a clean success.
+ */
+function mcpToolError(value: unknown): { message: string; reasonCode: string | null } | null {
+ if (!value || typeof value !== "object") return null;
+ const envelope = value as Record;
+ const data = envelope.data && typeof envelope.data === "object" ? (envelope.data as Record) : null;
+ const isError = data?.isError === true || envelope.isError === true;
+ if (!isError) return null;
+ // Prefer what the app actually said (normalized content text) over the generic
+ // gateway wrapper string, falling back to a friendly default.
+ const message =
+ (typeof envelope.content === "string" && envelope.content.trim() !== "" && envelope.content)
+ || (typeof envelope.error === "string" && envelope.error.trim() !== "" && envelope.error)
+ || "The app returned an error result.";
+ return { message, reasonCode: "tool_error" };
+}
+
+// --- Allowed (T7) ---------------------------------------------------------
+
+/** Pull a row array out of a tool result for the "n rows came back" heuristic. */
+function asRows(value: unknown): Record[] | null {
+ const isObjArray = (v: unknown): v is Record[] =>
+ Array.isArray(v) && v.length > 0 && v.every((i) => i !== null && typeof i === "object" && !Array.isArray(i));
+ if (isObjArray(value)) return value;
+ if (value && typeof value === "object") {
+ for (const key of ["rows", "values", "items", "data", "results"]) {
+ const inner = (value as Record)[key];
+ if (isObjArray(inner)) return inner as Record[];
+ }
+ }
+ return null;
+}
+
+function isEmptyResult(value: unknown): boolean {
+ if (value === null || value === undefined) return true;
+ if (Array.isArray(value)) return value.length === 0;
+ if (typeof value === "string") return value.trim() === "";
+ if (typeof value === "object") return Object.keys(value as object).length === 0;
+ return false;
+}
+
+function writeVerb(entry: ToolCatalogEntry): string | null {
+ const n = `${entry.toolName} ${entry.title ?? ""}`.toLowerCase();
+ if (/\b(append|add|insert|create|new)\b/.test(n)) return "added";
+ if (/\b(update|edit|set|patch|change|modify)\b/.test(n)) return "updated";
+ if (/\b(delete|remove|clear|trash)\b/.test(n)) return "removed";
+ return null;
+}
+
+function successHeadline(value: unknown, entry: ToolCatalogEntry, appName: string): string {
+ const verb = writeVerb(entry);
+ if (!entry.isReadOnly && verb) return `Worked. Row ${verb}.`;
+ const rows = asRows(value);
+ if (rows) return `Worked. ${rows.length} ${rows.length === 1 ? "row" : "rows"} came back.`;
+ if (isEmptyResult(value)) return "Worked. No data to show.";
+ return `Worked. ${appName} sent back the result.`;
+}
+
+function AllowedResult({
+ outcome,
+ entry,
+ appName,
+ connectionId,
+}: {
+ outcome: RunOutcome;
+ entry: ToolCatalogEntry;
+ appName: string;
+ connectionId: string;
+}) {
+ const value = outcome.result.result;
+ return (
+
+
+
+ {successHeadline(value, entry, appName)}
+
+
+
+ Ran as {outcome.agentName} · {seconds(outcome.durationMs)} · {relTime(outcome.ranAt)}
+
+
+ {!isEmptyResult(value) && (
+
+ )}
+
+
+
+
+ This call is in the{" "}
+
+ Activity tab
+
+ .
+
+
Last run finished in {seconds(outcome.durationMs)}.
+
+ );
+}
+
+/** Pretty preview: table for row arrays, depth-limited JSON otherwise, plain text for strings. */
+function PrettyPreview({ value }: { value: unknown }) {
+ const rows = asRows(value);
+ if (rows) {
+ const columns = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))).slice(0, 6);
+ const shown = rows.slice(0, 6);
+ return (
+
+
+
+
+ {columns.map((col) => (
+ {col}
+ ))}
+
+
+
+ {shown.map((row, i) => (
+
+ {columns.map((col) => (
+ {cellText(row[col])}
+ ))}
+
+ ))}
+
+
+ {rows.length > shown.length && (
+
… {rows.length - shown.length} more rows
+ )}
+
+ );
+ }
+ if (typeof value === "string") {
+ return {value} ;
+ }
+ return (
+
+ {safeStringify(collapseDeep(value, 2))}
+
+ );
+}
+
+function cellText(value: unknown): string {
+ if (value === null || value === undefined) return "—";
+ if (typeof value === "object") return Array.isArray(value) ? `[${value.length}]` : "{…}";
+ return String(value);
+}
+
+/** Replace objects deeper than `maxDepth` with a placeholder so the tree stays readable. */
+function collapseDeep(value: unknown, maxDepth: number, depth = 0): unknown {
+ if (value === null || typeof value !== "object") return value;
+ if (depth >= maxDepth) return Array.isArray(value) ? "[…]" : "{…}";
+ if (Array.isArray(value)) return value.map((v) => collapseDeep(v, maxDepth, depth + 1));
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ out[k] = collapseDeep(v, maxDepth, depth + 1);
+ }
+ return out;
+}
+
+function RawResponseDisclosure({ value }: { value: unknown }) {
+ const [showRaw, setShowRaw] = useState(false);
+ if (value === undefined || value === null) return null;
+ return (
+
+
setShowRaw((prev) => !prev)}
+ className="text-xs font-semibold uppercase tracking-wide text-primary hover:underline"
+ >
+ {showRaw ? "Hide raw response" : "Show raw response"}
+
+ {showRaw && (
+
+ {safeStringify(value)}
+
+ )}
+
+ );
+}
+
+// --- Error (T8) -----------------------------------------------------------
+
+function ErrorResult({
+ outcome,
+ appName,
+ connectionId,
+ error,
+}: {
+ outcome: RunOutcome;
+ appName: string;
+ connectionId: string;
+ error: { message: string; reasonCode: string | null };
+}) {
+ const hints = errorHints(error.message, error.reasonCode);
+ return (
+
+
+
+
+ Tried as {outcome.agentName} · {seconds(outcome.durationMs)} · {relTime(outcome.ranAt)}
+
+
+
What {appName} said
+
{error.message}
+ {error.reasonCode &&
code: {error.reasonCode}
}
+
+
+
What to try
+
+ {hints.map((hint) => (
+ {hint}
+ ))}
+
+
+
Adjust the input above and try again.
+
+ Also visible in the{" "}
+
+ Activity tab
+
+ .
+
+
+ );
+}
+
+// --- Ask first (T9) — live status polled from the action-request snapshot ---
+
+/** Phases that have settled — once reached, the panel stops polling. */
+const TERMINAL_PHASES: ReadonlySet = new Set([
+ "done",
+ "denied",
+ "cancelled",
+ "expired",
+]);
+
+/** Compact "Where" line from the redacted parameter snapshot: `key: value` pairs. */
+function formatWhere(parameters: Record | null | undefined): string | null {
+ if (!parameters) return null;
+ const parts: string[] = [];
+ for (const [key, value] of Object.entries(parameters)) {
+ if (value === null || value === undefined) continue;
+ if (typeof value === "object") continue;
+ parts.push(`${key}: ${String(value)}`);
+ if (parts.length >= 3) break;
+ }
+ return parts.length ? parts.join(" · ") : null;
+}
+
+function AskFirstResult({
+ outcome,
+ entry,
+ appName,
+ connectionId,
+}: {
+ outcome: RunOutcome;
+ entry: ToolCatalogEntry;
+ appName: string;
+ connectionId: string;
+}) {
+ const { selectedCompanyId } = useCompany();
+ const queryClient = useQueryClient();
+ const actionRequestId = outcome.result.actionRequestId;
+ const [cancelled, setCancelled] = useState(false);
+
+ const statusQuery = useQuery({
+ queryKey: queryKeys.tools.testCallStatus(connectionId, actionRequestId ?? "__none__"),
+ queryFn: () => toolsApi.getTestCallStatus(connectionId, actionRequestId!),
+ enabled: !!actionRequestId && !cancelled,
+ // Poll until the request settles (approved+done, denied, cancelled, expired).
+ refetchInterval: (query) => {
+ const phase = query.state.data?.phase;
+ return phase && TERMINAL_PHASES.has(phase) ? false : 2000;
+ },
+ });
+
+ const cancel = useMutation({
+ mutationFn: () => toolsApi.declineActionRequest(selectedCompanyId!, actionRequestId!),
+ onSuccess: () => {
+ setCancelled(true);
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.actionRequests(selectedCompanyId!, "pending") });
+ if (selectedCompanyId) queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
+ },
+ });
+
+ const status = statusQuery.data;
+ const phase: ToolConnectionTestCallStatus["phase"] = cancelled ? "cancelled" : status?.phase ?? "waiting";
+
+ // Once the call has been approved and run, mutate into the real result shape
+ // so the tester sees the response (or failure) without re-running.
+ if (phase === "done" && status) {
+ // Same as the allowed path: an approved call can still fail at the MCP tool
+ // layer (isError:true in the envelope) without a top-level error.
+ const toolError = status.error ?? mcpToolError(status.result);
+ if (toolError) {
+ const errorOutcome: RunOutcome = {
+ result: { decision: "allowed", invocationId: status.invocationId, error: toolError },
+ agentName: outcome.agentName,
+ durationMs: status.durationMs ?? outcome.durationMs,
+ ranAt: status.resolvedAt ? new Date(status.resolvedAt) : outcome.ranAt,
+ };
+ return ;
+ }
+ const allowedOutcome: RunOutcome = {
+ result: { decision: "allowed", invocationId: status.invocationId, result: status.result },
+ agentName: outcome.agentName,
+ durationMs: status.durationMs ?? outcome.durationMs,
+ ranAt: status.resolvedAt ? new Date(status.resolvedAt) : outcome.ranAt,
+ };
+ return ;
+ }
+
+ const requestedAt = status?.requestedAt ? new Date(status.requestedAt) : outcome.ranAt;
+ const where = formatWhere(status?.parameters);
+ const statusLabel =
+ phase === "running"
+ ? "Approved · running"
+ : phase === "denied"
+ ? "Denied — see Review for why"
+ : phase === "cancelled"
+ ? "Cancelled"
+ : phase === "expired"
+ ? "Expired — send it again"
+ : `Waiting · ${relTime(requestedAt)}`;
+ const settled = phase === "denied" || phase === "cancelled" || phase === "expired";
+
+ return (
+
+
+
+ Sent for your OK.
+
+
{outcome.agentName} needs your approval before this runs.
+
+
+
+
Action
+ {entry.title ?? entry.toolName}
+
+ {where && (
+
+
Where
+ {where}
+
+ )}
+
+
Status
+
+ {phase === "running" && }
+ {statusLabel}
+
+
+
+
+ {!settled && (
+
+ Approve it in the{" "}
+
+ Review tab
+ {" "}
+ to finish the test. You can also cancel the request.
+
+ )}
+
+
+
+ Open Review tab
+
+ {phase === "waiting" && actionRequestId && selectedCompanyId && (
+ cancel.mutate()} disabled={cancel.isPending}>
+ {cancel.isPending ? "Cancelling…" : "Cancel this request"}
+
+ )}
+
+
+ );
+}
+
+// --- Off (T10) ------------------------------------------------------------
+
+function OffExplanation({
+ entry,
+ connectionId,
+ appName,
+ agent,
+ allAgents,
+ onSelectAgent,
+}: {
+ entry: ToolCatalogEntry;
+ connectionId: string;
+ appName: string;
+ agent: ToolConnectionTestAgent;
+ allAgents: ToolConnectionTestAgent[];
+ onSelectAgent: (agentId: string) => void;
+}) {
+ const title = entry.title ?? entry.toolName;
+ const permHref = `${appTabHref(connectionId, "permissions")}?focus=${encodeURIComponent(entry.id)}`;
+
+ // Decision for this action across every agent we can test as.
+ const others = allAgents.filter((a) => a.id !== agent.id);
+ const decisionOf = (a: ToolConnectionTestAgent): ToolConnectionTestDecision => {
+ const tool = a.effectiveAccess.tools.find(
+ (t) => t.toolName === entry.toolName || t.gatewayToolName === entry.toolName,
+ );
+ return tool?.decision ?? "off";
+ };
+ const allOff = allAgents.every((a) => decisionOf(a) === "off");
+
+ const whyBody = entry.status === "quarantined"
+ ? "This action is new and hasn't been turned on yet."
+ : allOff
+ ? "An admin set it to Off for all agents using this app."
+ : `${agent.name}'s access profile sets this action to Off.`;
+
+ // "Last changed by {Actor} · {relativeTime}" — only the access config carries
+ // this; a quarantined action has never been configured, so there's nothing to
+ // attribute. Actor is omitted when the latest edit isn't agent-attributable.
+ const { lastChangedAt, lastChangedByName } = agent.effectiveAccess;
+ const auditHint =
+ entry.status !== "quarantined" && lastChangedAt
+ ? `Last changed${lastChangedByName ? ` by ${lastChangedByName}` : ""} · ${relTime(new Date(lastChangedAt))}`
+ : null;
+
+ const otherSettings = others.map((a) => ({ name: a.name, decision: decisionOf(a) }));
+ const tryAgents = others.filter((a) => decisionOf(a) !== "off");
+
+ return (
+
+
+
+
+
+
{title} is off for {agent.name}.
+
It won't run here, and it won't run from a task either.
+
+ Want to test it? Turn it on for {agent.name} in{" "}
+
+ Permissions
+ {" "}
+ — set it to Allowed or Ask first.
+
+
+
+
+ Open Permissions →
+
+
No call will be made — this action is off for {agent.name}.
+
+
+
+ Why this is off
+ {whyBody}
+ {auditHint && {auditHint}
}
+ {otherSettings.length > 0 && (
+
+
Other agents using {appName}:
+
+ {otherSettings.map((s) => (
+
+ {s.name}: {DECISION_META[s.decision].label}
+
+ ))}
+
+
+ )}
+ {tryAgents.length > 0 && (
+
+
Try as a different agent:
+
+ {tryAgents.slice(0, 4).map((other) => (
+ onSelectAgent(other.id)}
+ className="rounded-full border border-border px-2.5 py-1 text-(length:--text-micro) font-medium text-foreground hover:bg-accent"
+ >
+ {other.name}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function safeStringify(value: unknown): string {
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+/**
+ * Tailored next steps keyed on the upstream/gateway error. Mirrors the
+ * board-accepted copy-spec error-hint lookup (NOT_FOUND / PERMISSION_DENIED /
+ * INVALID_ARGUMENT / RATE_LIMIT) with the locked generic fallback otherwise.
+ */
+export function errorHints(message: string, reasonCode: string | null | undefined): string[] {
+ const haystack = `${reasonCode ?? ""} ${message}`.toUpperCase();
+ if (haystack.includes("NOT_FOUND")) {
+ return [
+ "Double-check the ID or name you entered — pick it from a dropdown if one is offered.",
+ "Make sure this agent has access to that resource in the connected account.",
+ ];
+ }
+ if (haystack.includes("PERMISSION") || haystack.includes("FORBIDDEN") || haystack.includes("UNAUTHORIZED")) {
+ return [
+ "The connected account may not have permission for this action.",
+ "Reconnect the app from Setup if its access was recently changed.",
+ ];
+ }
+ if (haystack.includes("INVALID_ARGUMENT") || haystack.includes("INVALID") || haystack.includes("BAD_REQUEST")) {
+ return [
+ "Check the field formats above — a value may be the wrong type or shape.",
+ "Open “More options” to confirm any advanced fields are filled in correctly.",
+ ];
+ }
+ if (haystack.includes("RATE_LIMIT") || haystack.includes("RESOURCE_EXHAUSTED") || haystack.includes("429")) {
+ return ["The app is rate-limiting calls right now — wait a moment and run it again."];
+ }
+ // Locked generic fallback (copy-spec decision #2).
+ return ["Check the inputs above and try again."];
+}
diff --git a/ui/src/pages/apps/app-detail/types.ts b/ui/src/pages/apps/app-detail/types.ts
new file mode 100644
index 00000000000..8fd147e999d
--- /dev/null
+++ b/ui/src/pages/apps/app-detail/types.ts
@@ -0,0 +1,41 @@
+import type {
+ Agent,
+ AppGalleryEntry,
+ ToolCallEvent,
+ ToolConnectionActivityResponse,
+ ToolConnectionLifecycleEvent,
+ ToolCatalogEntry,
+ ToolConnection,
+} from "@paperclipai/shared";
+
+export type AccessDraft = { mode: "all" | "specific"; agentIds: Set };
+
+export interface AppDetailSectionProps {
+ connectionId: string;
+ connection: ToolConnection;
+ appName: string;
+ galleryEntry: AppGalleryEntry | null;
+ catalog: ToolCatalogEntry[];
+ active: ToolCatalogEntry[];
+ readOnly: ToolCatalogEntry[];
+ canChange: ToolCatalogEntry[];
+ quarantined: ToolCatalogEntry[];
+ enabledIds: Set;
+ askFirstIds: Set;
+ 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;
+}
diff --git a/ui/src/pages/apps/app-tabs.ts b/ui/src/pages/apps/app-tabs.ts
new file mode 100644
index 00000000000..d2f543f12cf
--- /dev/null
+++ b/ui/src/pages/apps/app-tabs.ts
@@ -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 = new Set(["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";
+}
diff --git a/ui/src/pages/apps/gateways/AppsSubNav.tsx b/ui/src/pages/apps/gateways/AppsSubNav.tsx
new file mode 100644
index 00000000000..e6f734e9ef1
--- /dev/null
+++ b/ui/src/pages/apps/gateways/AppsSubNav.tsx
@@ -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 (
+
+ {ITEMS.map((item) => {
+ const isActive = item.key === active;
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx
new file mode 100644
index 00000000000..9274b52e500
--- /dev/null
+++ b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx
@@ -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(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 (
+
+
+
+ Connect a client
+
+ Pick how you’ll point your client at this gateway.
+
+
+
+
+
+ {snippets.map((snippet) => (
+ 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}
+
+ ))}
+ 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
+
+
+
+
+ {active === "raw_url" ? (
+
+
Endpoint URL
+
+
+ {endpoint}
+
+ void copyText(endpoint, "Endpoint URL")}>
+
+ Copy
+
+
+
+ Authenticate with Authorization: Bearer <token> over streamable HTTP.
+
+
+ ) : activeSnippet ? (
+
+
+
{activeSnippet.label}
+
void copyText(configText, `${activeSnippet.label} config`)}
+ >
+
+ Copy
+
+
+
+ {configText}
+
+ {activeSnippet.notes.length > 0 ? (
+
+ {activeSnippet.notes.map((note) => (
+ {note}
+ ))}
+
+ ) : null}
+
+ ) : (
+
No client snippets available for this gateway.
+ )}
+
+
+
Token
+ {createdToken ? (
+
+
+ {revealed ? createdToken.token : maskedTokenLabel(createdToken)}
+
+ {revealed ? (
+ void copyText(createdToken.token, "Access token")}
+ >
+
+ Copy
+
+ ) : (
+ setRevealed(true)}>
+ Show
+
+ )}
+
+ ) : (
+
+ Mint a token on the Tokens tab, then paste it where the
+ snippet shows Bearer …. You won’t see a token’s full value again after it’s
+ created.
+
+ )}
+
+ 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.
+
+
+
+
+
+
+ onOpenChange(false)}>
+
+ Done
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/GatewayDetail.tsx b/ui/src/pages/apps/gateways/GatewayDetail.tsx
new file mode 100644
index 00000000000..d8ed2e7ac2a
--- /dev/null
+++ b/ui/src/pages/apps/gateways/GatewayDetail.tsx
@@ -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(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 Select a company to manage gateways.
;
+ }
+ if (!activeTab) {
+ return ;
+ }
+ if (gatewaysQuery.isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+ if (gatewaysQuery.isError) {
+ return gatewaysQuery.refetch()} />;
+ }
+ if (!gateway) {
+ return (
+
+
We couldn’t find that gateway.
+
navigate("/apps/gateways")}>
+ Back to gateways
+
+
+ );
+ }
+
+ const endpointHost = (() => {
+ try {
+ return `${new URL(window.location.origin).host}${gateway.endpointPath}`;
+ } catch {
+ return gateway.endpointPath;
+ }
+ })();
+
+ return (
+
+
+
+
+
+ Apps · Gateways
+
+
+
{gateway.name}
+
{endpointHost}
+
+
setSnippetOpen(true)}>
+
+ Show snippet
+
+
+
+
+ {GATEWAY_TABS.map((item) => {
+ const isActive = item.key === activeTab;
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+
+ {activeTab === "overview" && (
+
toggleMutation.mutate()}
+ />
+ )}
+ {activeTab === "apps" && }
+ {activeTab === "tokens" && (
+ setCreatedToken(token)}
+ />
+ )}
+ {activeTab === "activity" && (
+
+ )}
+ {activeTab === "advanced" && (
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/GatewaysList.tsx b/ui/src/pages/apps/gateways/GatewaysList.tsx
new file mode 100644
index 00000000000..d6728cc3430
--- /dev/null
+++ b/ui/src/pages/apps/gateways/GatewaysList.tsx
@@ -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 Select a company to manage gateways.
;
+ }
+
+ 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 (
+
+
+
+
+
+ {gatewaysQuery.isLoading ? (
+
+
+
+
+ ) : gatewaysQuery.isError ? (
+
gatewaysQuery.refetch()} />
+ ) : gateways.length === 0 ? (
+ setCreating(true)} />
+ ) : (
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder="Search by name, app, or owner"
+ className="pl-9"
+ aria-label="Search gateways"
+ />
+
+
setCreating(true)}>
+
+ New gateway
+
+
+
+ {(() => {
+ 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) => (
+
event.stopPropagation()}
+ onCheckedChange={() => toggleMutation.mutate({ gateway })}
+ aria-label={`Turn ${gateway.name} ${isGatewayOn(gateway) ? "off" : "on"}`}
+ />
+ );
+ const empty = (
+
+ No gateways match “{search.trim()}”.
+
+ );
+ return (
+ <>
+ {/* Desktop / tablet: full table. */}
+
+
+
+
+ Gateway
+ Scope
+ Apps
+ Tokens
+ Last used
+ On
+
+
+
+ {rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
+ navigate(href)}
+ className="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30"
+ >
+
+ {gateway.name}
+
+ {endpointHost(gateway.endpointPath, gateway.displaySlug)}
+
+
+ {scope}
+ {appsLabel}
+
+ {active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
+
+
+ {lastUsed ? : "—"}
+
+
+ {toggle(gateway)}
+
+
+ ))}
+ {rows.length === 0 ? (
+
+ {empty}
+
+ ) : null}
+
+
+
+
+ {/* Mobile: stacked cards so the On toggle stays reachable and thumb-sized. */}
+
+ {rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
+
navigate(href)}
+ className="cursor-pointer rounded-lg border border-border p-4 transition-colors hover:bg-muted/30"
+ >
+
+
+
{gateway.name}
+
+ {endpointHost(gateway.endpointPath, gateway.displaySlug)}
+
+
+
{toggle(gateway)}
+
+
+
+
+ 0 ? ` · ${expiring} expiring` : ""}`}
+ />
+ : "—"}
+ />
+
+
+ ))}
+ {rows.length === 0 ? (
+
{empty}
+ ) : null}
+
+ >
+ );
+ })()}
+
+
+
Why a gateway?
+
+ 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.
+
+
+
+ )}
+
+ navigate(gatewayTabHref(gatewayId, "overview"))}
+ />
+
+ );
+}
+
+/** Show `mcp.host/g/` 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 (
+
+
{label}
+ {value}
+
+ );
+}
+
+function EmptyGateways({ onCreate }: { onCreate: () => void }) {
+ return (
+
+
No gateways yet
+
+ Group your connected apps into one safe endpoint you can hand to a client, then revoke it in one
+ move.
+
+
+
+ New gateway
+
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/NewGatewayDialog.tsx b/ui/src/pages/apps/gateways/NewGatewayDialog.tsx
new file mode 100644
index 00000000000..6d630bbcb5b
--- /dev/null
+++ b/ui/src/pages/apps/gateways/NewGatewayDialog.tsx
@@ -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) {
+ event.preventDefault();
+ if (!name.trim() || !profileId) return;
+ createMutation.mutate();
+ }
+
+ const profilesLoading = profilesQuery.isLoading;
+ const noProfiles = !profilesLoading && activeProfiles.length === 0;
+
+ return (
+
+
+
+ New gateway
+
+ One safe MCP endpoint that exposes only the apps in its access profile. Hand it to a client
+ like Cursor or Claude Desktop.
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/gateway-helpers.test.ts b/ui/src/pages/apps/gateways/gateway-helpers.test.ts
new file mode 100644
index 00000000000..d7578297b5e
--- /dev/null
+++ b/ui/src/pages/apps/gateways/gateway-helpers.test.ts
@@ -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 {
+ 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 {
+ 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([]);
+ });
+});
diff --git a/ui/src/pages/apps/gateways/gateway-helpers.ts b/ui/src/pages/apps/gateways/gateway-helpers.ts
new file mode 100644
index 00000000000..9c00a440d32
--- /dev/null
+++ b/ui/src/pages/apps/gateways/gateway-helpers.ts
@@ -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): string {
+ return `${token.tokenPrefix}•••`;
+}
+
+export function tokenStatus(
+ token: Pick,
+ 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 = {
+ 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((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,
+ agentNames: Map,
+): 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 {
+ 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();
+ for (const conn of connections) {
+ connectionsByApplication.set(conn.applicationId, [
+ ...(connectionsByApplication.get(conn.applicationId) ?? []),
+ conn,
+ ]);
+ }
+
+ const toolCountByApp = new Map();
+ const includedApplicationIds = new Set();
+ 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 {
+ return JSON.stringify(config, null, 2);
+}
diff --git a/ui/src/pages/apps/gateways/gateway-tabs.ts b/ui/src/pages/apps/gateways/gateway-tabs.ts
new file mode 100644
index 00000000000..7fd5607f5cf
--- /dev/null
+++ b/ui/src/pages/apps/gateways/gateway-tabs.ts
@@ -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";
+}
diff --git a/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx b/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx
new file mode 100644
index 00000000000..d64f1c240e9
--- /dev/null
+++ b/ui/src/pages/apps/gateways/panels/AppsToolsPanel.tsx
@@ -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 (
+
+
+ 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.
+
+
+ {apps.length === 0 ? (
+
+ No apps are assigned to this gateway’s profile yet.
+
+ ) : (
+
+
+
+
+ App
+ Tools
+ Status
+
+
+
+
+ {apps.map((app) => {
+ const href = app.connection
+ ? `/apps/${app.connection.id}/setup`
+ : `/apps/app/${app.application.id}/setup`;
+ return (
+
+
+
+ {gatewayAppDisplayName(app)}
+
+ {app.needsAttention && app.attentionReason ? (
+ {app.attentionReason}
+ ) : null}
+
+
+ {app.toolCount} {app.toolCount === 1 ? "tool" : "tools"}
+
+
+
+ {app.needsAttention ? "Needs attention" : "Healthy"}
+
+
+
+
+ Open →
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/panels/GatewayActivityPanel.tsx b/ui/src/pages/apps/gateways/panels/GatewayActivityPanel.tsx
new file mode 100644
index 00000000000..3926c38631d
--- /dev/null
+++ b/ui/src/pages/apps/gateways/panels/GatewayActivityPanel.tsx
@@ -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 = {
+ allowed: "Allowed",
+ blocked: "Blocked",
+ asked_first: "Ask first",
+ waiting: "Waiting",
+ failed: "Failed",
+ unknown: "—",
+};
+
+const OUTCOME_CLASS: Record = {
+ 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 (
+
+
+
+
+
+ );
+ }
+ if (activityQuery.isError) {
+ return activityQuery.refetch()} />;
+ }
+
+ return (
+
+
+ Every call through this gateway in the last 7 days, with why it was allowed, blocked, or paused.
+
+ {events.length === 0 ? (
+
+ No calls have gone through this gateway yet.
+
+ ) : (
+
+ {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 (
+
+
+
{actor}
+
+ {app} · {tool} ·
+
+
+
+ {OUTCOME_LABEL[outcome]}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx b/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx
new file mode 100644
index 00000000000..f421af4967c
--- /dev/null
+++ b/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx
@@ -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 (
+
+
+ Transport
+
+
+
+
+
+
+
+
+ {endpoint}
+
+ void copy(endpoint, "Endpoint URL")}>
+
+ Copy
+
+
+
+
+
+
+
Raw configuration
+ void copy(rawConfig, "Gateway config JSON")}>
+
+ Copy JSON
+
+
+
+ {rawConfig}
+
+
+
+
+ Danger zone
+
+ Archiving takes the gateway offline for every client. Existing tokens stop working. Type the
+ gateway name to confirm.
+
+ {confirming ? (
+
+
setConfirmName(e.target.value)}
+ placeholder={gateway.name}
+ aria-label="Type the gateway name to confirm archive"
+ />
+
+ archiveMutation.mutate()}
+ >
+ {archiveMutation.isPending ? "Archiving…" : "Archive gateway"}
+
+ { setConfirming(false); setConfirmName(""); }}>
+ Cancel
+
+
+
+ ) : (
+ setConfirming(true)}>
+ Archive gateway
+
+ )}
+
+
+ );
+}
+
+function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx
new file mode 100644
index 00000000000..cb940afe280
--- /dev/null
+++ b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx
@@ -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;
+ projectNames: Map;
+ 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 (
+
+
+
+
{on ? "On" : "Off"}
+
+
+
+
Toggle the whole gateway off here.
+
+
+ {apps.length} {apps.length === 1 ? "app" : "apps"}
+ {profile ? ` · ${allowedToolsLabel(profile)}` : ""}
+
+
+ {active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
+
+
+ {needsAttention.length === 0 ? "All green" : `${needsAttention.length} needs attention`}
+
+
+
+
+
+
+
Who can use it
+
+ Anyone holding an active token below, restricted by the rules in the bound profile.
+
+
+
+
+ Scope · {formatScope(gateway, projectNames, agentNames)}
+ Profile · {profile?.name ?? "Unavailable"}
+ {active} active {active === 1 ? "token" : "tokens"}
+
+
+
+
+ Apps in this gateway
+ {apps.length === 0 ? (
+
+ This gateway’s profile doesn’t include any apps yet.
+
+ ) : (
+
+ {apps.map((app) => (
+
+ ))}
+
+ )}
+
+
+
+
+
How clients connect
+ void copy(snippet, "Client config")}>
+
+ Copy
+
+
+
+ {snippet}
+
+
+
+ );
+}
+
+function StatCard({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function Chip({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function AppRow({ app }: { app: GatewayAppRow }) {
+ const href = app.connection ? `/apps/${app.connection.id}/setup` : `/apps/app/${app.application.id}/setup`;
+ return (
+
+
+
+ {gatewayAppDisplayName(app)}
+
+
+ {app.toolCount} {app.toolCount === 1 ? "tool" : "tools"}
+ {app.needsAttention && app.attentionReason ? ` · ${app.attentionReason}` : ""}
+
+
+
+ {app.needsAttention ? "Needs attention" : "Healthy"}
+
+
+ );
+}
diff --git a/ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx b/ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx
new file mode 100644
index 00000000000..b475e13acf5
--- /dev/null
+++ b/ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx
@@ -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) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ for (let i = 0; i < 3; i += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ }
+}
+
+function token(overrides: Partial = {}): 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 {
+ 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;
+
+ 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({node} );
+ });
+ 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(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( );
+ 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( );
+
+ 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( );
+
+ 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");
+ });
+});
diff --git a/ui/src/pages/apps/gateways/panels/TokensPanel.tsx b/ui/src/pages/apps/gateways/panels/TokensPanel.tsx
new file mode 100644
index 00000000000..418fe6eff0d
--- /dev/null
+++ b/ui/src/pages/apps/gateways/panels/TokensPanel.tsx
@@ -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 = {
+ 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 (
+
+ {TOKEN_STATUS_LABEL[status]}
+
+ );
+}
+
+/** One label:value pair inside a mobile stacked card. */
+function TokenField({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
+
+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(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) {
+ 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 (
+
+
+
+ Each token is a separate way in. Revoke any one without breaking the others.
+
+
setMinting((value) => !value)}>
+
+ Mint token
+
+
+
+ {minting ? (
+
+
+
+ Name
+ setName(e.target.value)} placeholder="cto-cursor" required autoFocus />
+
+
+ Owner / client
+ setClientLabel(e.target.value)}
+ placeholder="Cursor on work laptop"
+ />
+
+
+
+
+ Note (why it exists)
+ setOwnerNote(e.target.value)} placeholder="Dotta’s MacBook" />
+
+
+ Expires
+ setExpiresAt(e.target.value)} required />
+
+
+
+ setMinting(false)}>
+ Cancel
+
+
+ {createMutation.isPending ? "Minting…" : "Mint token"}
+
+
+
+ ) : null}
+
+ {created ? (
+
+
+
+
New token — copy now
+
+ You won’t see the full value again. Store it in your client’s config or your secret manager.
+
+
+
setCreated(null)} aria-label="Dismiss new token">
+ Dismiss
+
+
+
+
+ {revealed ? created.token : maskedTokenLabel(created)}
+
+ {revealed ? (
+ void copyToken(created.token)}>
+
+ Copy
+
+ ) : (
+ setRevealed(true)}>
+ Show
+
+ )}
+
+
+ ) : null}
+
+ {tokens.length === 0 ? (
+
+ No tokens yet. Mint one for the client that will connect to this gateway.
+
+ ) : (
+ <>
+ {/* Desktop / tablet: full table. */}
+
+
+
+
+ Token
+ Owner
+ Created
+ Last used
+ Expires
+ Status
+
+
+
+
+ {tokens.map((token) => {
+ const status = tokenStatus(token);
+ const canRevoke = status !== "revoked";
+ return (
+
+
+ {token.name}
+ {maskedTokenLabel(token)}
+
+ {token.clientLabel || token.ownerNote || "—"}
+
+
+ {token.lastUsedAt ? : "—"}
+
+
+ {token.revokedAt ? "—" : token.expiresAt ? : "no expiry"}
+
+
+
+
+
+ {canRevoke ? (
+ {
+ setConfirmToken({ id: token.id, name: token.name });
+ setRevokeName("");
+ }}
+ >
+ Revoke
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+
+ {/* Mobile: stacked cards so status + Revoke stay reachable. */}
+
+ {tokens.map((token) => {
+ const status = tokenStatus(token);
+ const canRevoke = status !== "revoked";
+ return (
+
+
+
+
{token.name}
+
{maskedTokenLabel(token)}
+
+
+
+
+
+ } />
+ : "—"}
+ />
+ : "no expiry"
+ }
+ />
+
+ {canRevoke ? (
+
{
+ setConfirmToken({ id: token.id, name: token.name });
+ setRevokeName("");
+ }}
+ >
+ Revoke
+
+ ) : null}
+
+ );
+ })}
+
+ >
+ )}
+
+
+
+ Every mint, reveal, and revoke is recorded in Activity.
+
+
+ {confirmToken ? (
+
+
+
+
Revoke this token?
+
+ Any client using {confirmToken.name} goes
+ silent immediately. This can’t be undone. Type the token name to confirm.
+
+
+
setRevokeName(e.target.value)}
+ placeholder={confirmToken.name}
+ aria-label="Type the token name to confirm"
+ autoFocus
+ />
+
+ {
+ setConfirmToken(null);
+ setRevokeName("");
+ }}
+ >
+ Cancel
+
+ revokeMutation.mutate(confirmToken.id)}
+ >
+ {revokeMutation.isPending ? "Revoking…" : "Revoke token"}
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/pages/apps/google-sheets.ts b/ui/src/pages/apps/google-sheets.ts
new file mode 100644
index 00000000000..75cd6e6cb89
--- /dev/null
+++ b/ui/src/pages/apps/google-sheets.ts
@@ -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 | null | undefined,
+ allowedSpreadsheetIds: string[],
+): Record {
+ const env = currentConfig && typeof currentConfig.env === "object" && !Array.isArray(currentConfig.env)
+ ? currentConfig.env as Record
+ : {};
+ return {
+ ...(currentConfig ?? {}),
+ allowedSpreadsheetIds,
+ env: {
+ ...env,
+ GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: allowedSpreadsheetIds.join(","),
+ },
+ };
+}
diff --git a/ui/src/pages/apps/store-cards.tsx b/ui/src/pages/apps/store-cards.tsx
new file mode 100644
index 00000000000..0cc505c12cc
--- /dev/null
+++ b/ui/src/pages/apps/store-cards.tsx
@@ -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 (
+
+
+
+
+
+
Connect your own tool
+
+ Paste the URL from a custom or self-hosted MCP server and review its actions before enabling it.
+
+
+ Connect →
+
+ );
+}
+
+/** Labeled door to the developer control-plane (PAP-12371, Finding A cross-link). */
+export function AdvancedToolsLink() {
+ return (
+
+
+ Developer tools (advanced)
+
+ );
+}
diff --git a/ui/src/pages/apps/useReviewCount.ts b/ui/src/pages/apps/useReviewCount.ts
new file mode 100644
index 00000000000..872ba147619
--- /dev/null
+++ b/ui/src/pages/apps/useReviewCount.ts
@@ -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;
+}
diff --git a/ui/storybook/stories/access-profiles.stories.tsx b/ui/storybook/stories/access-profiles.stories.tsx
new file mode 100644
index 00000000000..43f0682861e
--- /dev/null
+++ b/ui/storybook/stories/access-profiles.stories.tsx
@@ -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 {
+ 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,
+ 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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Tools/Access profiles",
+ parameters: { layout: "fullscreen" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const IndexPopulated: Story = {
+ name: "Index — populated",
+ render: () => ,
+};
+
+export const IndexNewToolsChip: Story = {
+ name: "Index — new tools chip (AP1 v2)",
+ render: () => ,
+};
+
+export const IndexLongProfileName: Story = {
+ name: "Index — long profile name",
+ render: () => (
+
+ ),
+};
+
+export const AccessCheckerSheet: Story = {
+ name: "Access checker sheet (AP8)",
+ render: () => ,
+};
+
+export const IndexEmpty: Story = {
+ name: "Index — empty (template cards)",
+ render: () => ,
+};
+
+export const IndexArchived: Story = {
+ name: "Index — archived filter",
+ render: () => ,
+};
+
+export const DetailAssigned: Story = {
+ name: "Detail — assigned",
+ render: () => ,
+};
+
+export const DetailNewToolsBanner: Story = {
+ name: "Detail — new tools banner (AP16)",
+ render: () => ,
+};
+
+export const DetailNewToolsModal: Story = {
+ name: "Detail — new tools review modal (AP16)",
+ render: () => ,
+};
+
+export const DetailAutoAllowedRows: Story = {
+ name: "Detail — auto-added row notes (AP16b)",
+ render: () => ,
+};
+
+export const AgentAccessProfilesCard: Story = {
+ name: "Agent page — access profiles card (AP19)",
+ render: () => ,
+};
+
+export const DetailUnassignedPostCreate: Story = {
+ name: "Detail — unassigned post-create",
+ render: () => (
+
+ ),
+};
+
+export const DetailArchived: Story = {
+ name: "Detail — archived",
+ render: () => (
+
+ ),
+};
+
+export const DetailDegraded: Story = {
+ name: "Detail — degraded",
+ render: () => (
+
+ ),
+};
+
+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 (
+
+ {}}
+ copyOptions={PROFILES.filter((p) => p.status !== "draft")}
+ name={name}
+ onName={setName}
+ description={description}
+ onDescription={setDescription}
+ profileKey={profileKey}
+ onProfileKey={setProfileKey}
+ />
+
+ );
+ },
+};
+
+export const WizardStep2: Story = {
+ name: "Wizard — step 2 (Choose tools)",
+ render: function Step2() {
+ const [selections, setSelections] = useState({
+ "app-gmail": { kind: "all" },
+ "app-slack": { kind: "some", included: ["s-list", "s-history"] },
+ });
+ const [rules, setRules] = useState([]);
+ const [action, setAction] = useState<"deny" | "allow">("deny");
+ return (
+
+
+
+ );
+ },
+};
+
+export const WizardStep2Partial: Story = {
+ name: "Wizard — step 2 partial selection (AP4b)",
+ render: function Step2Partial() {
+ const [selections, setSelections] = useState({
+ "app-gmail": { kind: "all_except", excluded: ["g-delete", "g-purge"] },
+ "app-slack": { kind: "all" },
+ });
+ const [rules, setRules] = useState([
+ { id: "r1", kind: "risk_level", value: "destructive", riskLevel: "destructive", effect: "exclude" },
+ ]);
+ const [action, setAction] = useState<"deny" | "allow">("deny");
+ return (
+
+
+
+ );
+ },
+};
+
+export const WizardStep3: Story = {
+ name: "Wizard — step 3 (Assign)",
+ render: function Step3() {
+ const [selected, setSelected] = useState>(new Set(["a-sage"]));
+ const [companyDefault, setCompanyDefault] = useState(false);
+ return (
+
+
+ setSelected((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ })
+ }
+ companyDefault={companyDefault}
+ onCompanyDefault={setCompanyDefault}
+ />
+
+ );
+ },
+};
diff --git a/ui/storybook/stories/action-card.stories.tsx b/ui/storybook/stories/action-card.stories.tsx
new file mode 100644
index 00000000000..869b070ac91
--- /dev/null
+++ b/ui/storybook/stories/action-card.stories.tsx
@@ -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 = {
+ title: "Tools & Access/ActionCard",
+ component: ActionCard,
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Pending: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Stale: Story = {
+ render: () => (
+
+ ),
+};
+
+export const DesktopSideBySide: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Mobile: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Bindings: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/ui/storybook/stories/activity-feed.stories.tsx b/ui/storybook/stories/activity-feed.stories.tsx
new file mode 100644
index 00000000000..77b8df695f4
--- /dev/null
+++ b/ui/storybook/stories/activity-feed.stories.tsx
@@ -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 {
+ 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 (
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Tools/Activity (audit feed)",
+ parameters: { layout: "fullscreen" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Populated: Story = {
+ name: "Populated",
+ render: () => ,
+};
+
+export const RowExpanded: Story = {
+ name: "Row expanded (use the chevron)",
+ render: () => ,
+};
+
+export const TrueEmpty: Story = {
+ name: "Empty",
+ render: () => ,
+};
+
+export const FilteredEmpty: Story = {
+ name: "Filtered empty (type 'refund' in search)",
+ render: () => ,
+};
diff --git a/ui/storybook/stories/app-activity-lifecycle.stories.tsx b/ui/storybook/stories/app-activity-lifecycle.stories.tsx
new file mode 100644
index 00000000000..16252d42b55
--- /dev/null
+++ b/ui/storybook/stories/app-activity-lifecycle.stories.tsx
@@ -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 {
+ 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 {
+ 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 {
+ 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 (
+
+ );
+}
+
+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: () => ,
+};
+
+export const LifecycleOnly: Story = {
+ name: "Lifecycle only",
+ render: () => ,
+};
+
+export const Loading: Story = {
+ name: "Loading",
+ render: () => ,
+};
+
+export const Empty: Story = {
+ name: "Empty",
+ render: () => ,
+};
diff --git a/ui/storybook/stories/apps-connect-discoverability.stories.tsx b/ui/storybook/stories/apps-connect-discoverability.stories.tsx
new file mode 100644
index 00000000000..3868381f741
--- /dev/null
+++ b/ui/storybook/stories/apps-connect-discoverability.stories.tsx
@@ -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 (
+
+
+
+ );
+}
+
+function PasteConfigHost() {
+ const client = useMemo(() => seededClient(), []);
+ return (
+
+
+
+ );
+}
+
+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: () => ,
+};
+
+export const PasteConfigRedirectHint: Story = {
+ name: "Paste a config — redirect hint",
+ render: () => ,
+};
+
+/**
+ * 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("textarea");
+ const button = Array.from(document.querySelectorAll("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 (
+
+
+
+ );
+}
+
+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: () => (
+
+ ),
+};
+
+export const PasteConfigPreviewMixed: Story = {
+ name: "Paste a config — mixed remote + stdio",
+ render: () => (
+
+ ),
+};
+
+export const PasteConfigPreviewStdioOnly: Story = {
+ name: "Paste a config — stdio only (draft, no activation)",
+ render: () => (
+
+ ),
+};
diff --git a/ui/storybook/stories/apps-connect-name.stories.tsx b/ui/storybook/stories/apps-connect-name.stories.tsx
new file mode 100644
index 00000000000..09a4510dfcb
--- /dev/null
+++ b/ui/storybook/stories/apps-connect-name.stories.tsx
@@ -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("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("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 (
+
+
+
+ );
+}
+
+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: () => ,
+};
+
+export const CustomName: Story = {
+ name: "Gallery key step — custom name",
+ render: () => ,
+};
diff --git a/ui/storybook/stories/gateways-tab.stories.tsx b/ui/storybook/stories/gateways-tab.stories.tsx
new file mode 100644
index 00000000000..1a24d272db1
--- /dev/null
+++ b/ui/storybook/stories/gateways-tab.stories.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
+
+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: () => ,
+};
+
+export const Populated: Story = {
+ name: "Gateways — populated",
+ render: () => ,
+};
+
+export const SingleGateway: Story = {
+ name: "Gateways — single gateway with no tokens",
+ render: () => ,
+};
diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx
index 23c45f1c77b..f97d4af49a0 100644
--- a/ui/storybook/stories/issue-thread-interactions.stories.tsx
+++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx
@@ -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 (
+ undefined : undefined}
+ onRejectInteraction={interactive ? () => undefined : undefined}
+ />
+ );
+}
+
+export const ToolActionPendingWrite: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionPendingDestructive: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionRunning: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionExecuted: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionFailed: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionDeclined: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionExpired: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionLegacyGeneric: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const ToolActionAllStates: Story = {
+ render: () => (
+
+