mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat(mcp) [split 7/8]: activate Apps and gateway UI (#9562)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 7/8 and focuses on Apps/Gateways UI and feature-flagged activation > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: The standalone UI foundation needs feature-flagged routes, navigation, app detail flows, gateways, Storybook scenarios, and QA configuration. - Proposed solution: Adds Apps/Gateways pages and components, navigation/route activation, remaining page integrations, Storybook stories, and QA Vite configuration. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/06-ui-tools-foundation`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: UXDesigner sanity pass on Apps/Gateways flows and flag-off behavior; Greptile on every PR. ## What Changed - Adds Apps/Gateways pages and components, navigation/route activation, remaining page integrations, Storybook stories, and QA Vite configuration. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` - `pnpm check:token-gates` — all gates clean - Focused UI Vitest run with `NODE_ENV=test` — 17 files, 147 tests passed ## Risks - Navigation or flag regressions could expose incomplete experiences; activation remains controlled by existing experimental settings. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. ## UI Evidence QA captured these from the live Garden MCP split stack at 1440px and verified clean rendering:    --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a116713b9a
commit
f712cedbc2
79 files changed
+17681
-146
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
+62
-1
@@ -5,6 +5,7 @@ import { Layout } from "./components/Layout";
|
||||
import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate";
|
||||
import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate";
|
||||
import { CasesExperimentalGate } from "./components/CasesExperimentalGate";
|
||||
import { AppsExperimentalGate } from "./components/AppsExperimentalGate";
|
||||
import { Cases } from "./pages/Cases";
|
||||
import { CaseDetail } from "./pages/CaseDetail";
|
||||
import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
|
||||
@@ -47,6 +48,17 @@ import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab";
|
||||
import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute";
|
||||
import { ProfileWizardRoute } from "./pages/tools/profiles/ProfileWizardRoute";
|
||||
import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute";
|
||||
import { Connections } from "./pages/apps/Connections";
|
||||
import { Browse } from "./pages/apps/Browse";
|
||||
import { AppsConnect } from "./pages/apps/AppsConnect";
|
||||
import { AppsReview } from "./pages/apps/AppsReview";
|
||||
import { AppDetail } from "./pages/apps/AppDetail";
|
||||
import { AppNotConnected } from "./pages/apps/AppNotConnected";
|
||||
import { GatewaysList } from "./pages/apps/gateways/GatewaysList";
|
||||
import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { SkillStudio } from "./pages/SkillStudio";
|
||||
@@ -94,11 +106,36 @@ function boardRoutes() {
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
<Route path="company/settings/secrets" element={<Secrets />} />
|
||||
<Route path="company/settings/tools" element={<LegacyToolsSettingsRedirect />} />
|
||||
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
|
||||
<Route path="tools" element={<LegacyToolsRedirect />} />
|
||||
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
|
||||
<Route element={<AppsExperimentalGate />}>
|
||||
<Route path="apps" element={<Connections />} />
|
||||
<Route path="apps/browse" element={<Browse />} />
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps/browse" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps/browse" replace />} />
|
||||
<Route path="apps/review" element={<AppsReview />} />
|
||||
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
|
||||
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
|
||||
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
|
||||
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
|
||||
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
|
||||
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
|
||||
<Route path="apps/:connectionId" element={<Navigate to="setup" replace />} />
|
||||
<Route path="apps/:connectionId/:tab" element={<AppDetail />} />
|
||||
</Route>
|
||||
<Route path="company/settings/instance" element={<Navigate to="general" replace />} />
|
||||
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
|
||||
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
|
||||
@@ -235,6 +272,12 @@ function boardRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppsConnectEntryRoute() {
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
return searchParams.get("byo") === "1" ? <AppsConnect /> : <Navigate to="/apps/browse" replace />;
|
||||
}
|
||||
|
||||
function InboxRootRedirect() {
|
||||
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
|
||||
}
|
||||
@@ -307,6 +350,24 @@ function LegacySettingsRedirect() {
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyToolsSettingsRedirect() {
|
||||
const { tab } = useParams<{ tab?: string }>();
|
||||
return <Navigate to={legacyToolsRedirectTarget(tab)} replace />;
|
||||
}
|
||||
|
||||
// The developer "Tools" surface moved under the Apps "Advanced setup" door
|
||||
// (PAP-10862). `/tools` and `/tools/:tab` redirect to their new home.
|
||||
function LegacyToolsRedirect() {
|
||||
const { tab } = useParams<{ tab?: string }>();
|
||||
return <Navigate to={legacyToolsRedirectTarget(tab)} replace />;
|
||||
}
|
||||
|
||||
function legacyToolsRedirectTarget(tab?: string) {
|
||||
if (!tab) return "/apps/advanced/profiles";
|
||||
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps";
|
||||
return `/apps/advanced/${tab}`;
|
||||
}
|
||||
|
||||
function OnboardingRoutePage() {
|
||||
const { companies } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDetailSidebar } from "./AppConnectionSidebar";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const currentPath = vi.hoisted(() => ({ value: "/apps/conn-1/permissions" }));
|
||||
const mockToolsApi = vi.hoisted(() => ({
|
||||
getConnection: vi.fn(),
|
||||
listApplications: vi.fn(),
|
||||
listConnections: vi.fn(),
|
||||
listGallery: vi.fn(),
|
||||
listAppsAttention: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={to} onClick={onClick} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: mockToolsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarNavItem", () => ({
|
||||
SidebarNavItem: (props: {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
badge?: number;
|
||||
badgeTone?: string;
|
||||
badgeLabel?: string;
|
||||
}) => {
|
||||
sidebarNavItemMock(props);
|
||||
return (
|
||||
<div data-to={props.to} data-active={props.to === currentPath.value ? "true" : "false"}>
|
||||
{props.label}
|
||||
{props.badge ? ` ${props.badge}` : ""}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conn-1",
|
||||
name: "GitHub",
|
||||
transport: "remote_http",
|
||||
status: "active",
|
||||
healthStatus: "healthy",
|
||||
enabled: true,
|
||||
config: {},
|
||||
transportConfig: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AppConnectionSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPath.value = "/apps/conn-1/permissions";
|
||||
mockToolsApi.getConnection.mockResolvedValue(connection());
|
||||
mockToolsApi.listApplications.mockResolvedValue({
|
||||
applications: [{
|
||||
id: "app-1",
|
||||
applicationKey: "github",
|
||||
name: "GitHub",
|
||||
description: "GitHub app",
|
||||
status: "active",
|
||||
}],
|
||||
});
|
||||
mockToolsApi.listConnections.mockResolvedValue({
|
||||
connections: [connection({ id: "archived-1", applicationId: "app-1", status: "archived" })],
|
||||
});
|
||||
mockToolsApi.listGallery.mockResolvedValue({
|
||||
apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }],
|
||||
});
|
||||
mockToolsApi.listAppsAttention.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
connection: connection(),
|
||||
pendingActionRequestCount: 2,
|
||||
quarantinedCatalogEntryCount: 3,
|
||||
healthNeedsAttention: false,
|
||||
reasons: ["pending_action_requests", "quarantined_catalog_entries"],
|
||||
},
|
||||
],
|
||||
totals: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebar(element = <AppDetailSidebar kind="connection" connectionId="conn-1" />) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{element}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders a back link and the connected app tabs (including Test)", async () => {
|
||||
await renderSidebar();
|
||||
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(6);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 5, badgeTone: "danger" }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/test", label: "Test", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/activity", label: "Activity", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/advanced", label: "Advanced", end: true }));
|
||||
});
|
||||
|
||||
it("marks the current tab active through the nav item target", async () => {
|
||||
await renderSidebar();
|
||||
|
||||
expect(container.querySelector('[data-to="/apps/conn-1/permissions"]')?.getAttribute("data-active")).toBe("true");
|
||||
expect(container.querySelector('[data-to="/apps/conn-1/setup"]')?.getAttribute("data-active")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders application-mode tabs under the not-connected app route", async () => {
|
||||
currentPath.value = "/apps/app/app-1/review";
|
||||
|
||||
await renderSidebar(<AppDetailSidebar kind="application" applicationId="app-1" />);
|
||||
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(mockToolsApi.getConnection).not.toHaveBeenCalled();
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/review", label: "Review", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/permissions", label: "Permissions", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/activity", label: "Activity", end: true }));
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/advanced", label: "Advanced", end: true }));
|
||||
expect(container.querySelector('[data-to="/apps/app/app-1/review"]')?.getAttribute("data-active")).toBe("true");
|
||||
// The Test tab needs a live connection, so it is hidden in application mode.
|
||||
expect(container.querySelector('[data-to="/apps/app/app-1/test"]')).toBeNull();
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps rendering a connection sidebar when its connection is unavailable", async () => {
|
||||
mockToolsApi.getConnection.mockResolvedValue(undefined);
|
||||
mockToolsApi.listGallery.mockResolvedValue({ apps: [] });
|
||||
mockToolsApi.listAppsAttention.mockResolvedValue({ apps: [], totals: {} });
|
||||
|
||||
await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("App");
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(6);
|
||||
});
|
||||
|
||||
it("keeps rendering an application sidebar when its application is unavailable", async () => {
|
||||
mockToolsApi.listApplications.mockResolvedValue({ applications: [] });
|
||||
mockToolsApi.listConnections.mockResolvedValue({ connections: [] });
|
||||
mockToolsApi.listGallery.mockResolvedValue({ apps: [] });
|
||||
mockToolsApi.listAppsAttention.mockResolvedValue({ apps: [], totals: {} });
|
||||
|
||||
await renderSidebar(<AppDetailSidebar kind="application" applicationId="missing-app" />);
|
||||
|
||||
expect(container.textContent).toContain("App");
|
||||
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
|
||||
expect(container.querySelectorAll("[data-to]").length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import type { AppGalleryEntry, ToolApplication, ToolConnection } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
APP_TABS,
|
||||
CONNECTED_ONLY_APP_TABS,
|
||||
appApplicationTabHref,
|
||||
appTabHref,
|
||||
type AppTabKey,
|
||||
} from "@/pages/apps/app-tabs";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
type AppDetailSidebarProps =
|
||||
| { kind: "connection"; connectionId: string }
|
||||
| { kind: "application"; applicationId: string };
|
||||
|
||||
export function AppDetailSidebar(props: AppDetailSidebarProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connection(props.kind === "connection" ? props.connectionId : "__none__"),
|
||||
queryFn: () => toolsApi.getConnection(props.kind === "connection" ? props.connectionId : ""),
|
||||
enabled: props.kind === "connection" && !!props.connectionId,
|
||||
});
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||||
enabled: props.kind === "application" && !!selectedCompanyId,
|
||||
});
|
||||
const connectionsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||||
enabled: props.kind === "application" && !!selectedCompanyId,
|
||||
});
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const attentionQuery = useQuery({
|
||||
queryKey: queryKeys.apps.attention(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listAppsAttention(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const application = props.kind === "application"
|
||||
? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId)
|
||||
: null;
|
||||
const appConnections = props.kind === "application"
|
||||
? (connectionsQuery.data?.connections ?? []).filter((candidate) => candidate.applicationId === props.applicationId)
|
||||
: [];
|
||||
const previousConnection = latestArchivedConnection(appConnections);
|
||||
const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App";
|
||||
const logoEntry = galleryEntryFor(galleryQuery.data?.apps ?? [], connection, application ?? undefined);
|
||||
const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null;
|
||||
const attentionItem = reviewConnectionId
|
||||
? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId)
|
||||
: null;
|
||||
const reviewCount =
|
||||
(attentionItem?.pendingActionRequestCount ?? 0) + (attentionItem?.quarantinedCatalogEntryCount ?? 0);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
|
||||
<div className="flex shrink-0 flex-col gap-3 px-3 py-3">
|
||||
<Link
|
||||
to="/apps"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">All apps</span>
|
||||
</Link>
|
||||
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
|
||||
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={28} />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{APP_TABS.filter(
|
||||
(tab) => props.kind === "connection" || !CONNECTED_ONLY_APP_TABS.has(tab.key),
|
||||
).map((tab) => (
|
||||
<SidebarNavItem
|
||||
key={tab.key}
|
||||
to={tabHref(props, tab.key)}
|
||||
label={tab.label}
|
||||
icon={tab.icon}
|
||||
end
|
||||
badge={tab.key === "review" && reviewCount > 0 ? reviewCount : undefined}
|
||||
badgeTone="danger"
|
||||
badgeLabel="needing review"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
|
||||
return props.kind === "connection"
|
||||
? appTabHref(props.connectionId, tab)
|
||||
: appApplicationTabHref(props.applicationId, tab);
|
||||
}
|
||||
|
||||
function galleryEntryFor(
|
||||
apps: AppGalleryEntry[],
|
||||
connection: ToolConnection | undefined,
|
||||
application: ToolApplication | undefined,
|
||||
): AppGalleryEntry | null {
|
||||
if (application?.applicationKey) {
|
||||
const keyed = apps.find((app) => app.key === application.applicationKey);
|
||||
if (keyed) return keyed;
|
||||
}
|
||||
const name = (connection?.name ?? application?.name)?.toLowerCase();
|
||||
if (!name) return null;
|
||||
return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.key === name) ?? null;
|
||||
}
|
||||
|
||||
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
|
||||
const archived = connections.filter((connection) => connection.status === "archived");
|
||||
if (archived.length === 0) return null;
|
||||
return archived.reduce((latest, connection) => {
|
||||
const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime();
|
||||
const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime();
|
||||
return connectionTime > latestTime ? connection : latest;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppsExperimentalGate } from "./AppsExperimentalGate";
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => (
|
||||
<div data-testid="navigate" data-to={to} data-replace={String(replace)} />
|
||||
),
|
||||
Outlet: () => <div data-testid="apps-content">Apps content</div>,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("AppsExperimentalGate", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderGate() {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppsExperimentalGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("redirects to the dashboard when apps are disabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
await renderGate();
|
||||
|
||||
expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe("/dashboard");
|
||||
expect(container.querySelector('[data-testid="apps-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders apps routes when apps are enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
|
||||
await renderGate();
|
||||
|
||||
expect(container.querySelector('[data-testid="apps-content"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing while the flag is loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
await renderGate();
|
||||
|
||||
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="apps-content"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Navigate, Outlet } from "@/lib/router";
|
||||
import { useAppsEnabled } from "@/hooks/useAppsEnabled";
|
||||
|
||||
export function AppsExperimentalGate() {
|
||||
const { enabled, loaded } = useAppsEnabled();
|
||||
|
||||
if (!loaded) return null;
|
||||
if (!enabled) return <Navigate to="/dashboard" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppsSidebar } from "./AppsSidebar";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockToolsApi = vi.hoisted(() => ({
|
||||
listRuntimeSlots: vi.fn(),
|
||||
listActionRequests: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={to} onClick={onClick} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: mockToolsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarNavItem", () => ({
|
||||
SidebarNavItem: (props: {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
liveCount?: number;
|
||||
badge?: number;
|
||||
}) => {
|
||||
sidebarNavItemMock(props);
|
||||
return <div data-to={props.to}>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// React 19 does not export a usable `act` in this vitest/jsdom setup; use a
|
||||
// flushSync-based helper (PAP-12371 gotcha).
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("AppsSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockToolsApi.listRuntimeSlots.mockResolvedValue({
|
||||
runtimeSlots: [
|
||||
{ id: "slot-1", status: "running" },
|
||||
{ id: "slot-2", status: "stopped" },
|
||||
],
|
||||
});
|
||||
mockToolsApi.listActionRequests.mockResolvedValue({ actionRequests: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders Apps and Developer sections in one sidebar", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps");
|
||||
expect(container.textContent).toContain("Developer");
|
||||
// The Developer boundary caption frames who the door is for (PAP-13241 §5).
|
||||
expect(container.textContent).toContain("Advanced setup for developers");
|
||||
// "Run your own" / "Paste a config" moved to the Connect-an-app page (PAP-10922);
|
||||
// assert their absence at the item level below.
|
||||
|
||||
// Three peer consumer doors: Browse (store) · Connections · Review (PAP-13254).
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/browse", label: "Browse" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps", label: "Connections", end: true }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/review", label: "Review" }),
|
||||
);
|
||||
// "Needs attention" is no longer a top-level door — it folds into Connections.
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Needs attention" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Run your own" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Paste a config" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Applications" }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles", end: true }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/runtime", label: "Health", end: true, liveCount: 1 }),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "/apps/advanced/audit", label: "Activity", end: true }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs";
|
||||
import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
|
||||
import { useReviewCount } from "@/pages/apps/useReviewCount";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
/**
|
||||
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
|
||||
* PAP-13254 / U3).
|
||||
*
|
||||
* ← Back · APPS: Browse / Connections / Review (n)
|
||||
* DEVELOPER: Gateways / Profiles / Rules / Health / Activity
|
||||
*
|
||||
* The three consumer doors are peers: "Browse" (the store — discover + add),
|
||||
* "Connections" (your connected tools + health), and "Review" (PAP-12371,
|
||||
* Finding B — decisions waiting on your OK, with a live pending count).
|
||||
* "Needs attention" is no longer a door: health/error triage folds into
|
||||
* Connections as a status filter + banner, so approvals are never buried
|
||||
* behind an error label. The Developer section was folded in from the retired
|
||||
* ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a
|
||||
* one-line caption frames who it's for (Finding A). "Run your own" and "Paste a
|
||||
* config" moved out of the sidebar into rows on the Connect-an-app page
|
||||
* (PAP-10922).
|
||||
*/
|
||||
export function AppsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
|
||||
const reviewCount = useReviewCount();
|
||||
const { enabled: smokeLabEnabled } = useSmokeLabEnabled();
|
||||
const developerTabs = DEVELOPER_TABS.filter(
|
||||
(tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled,
|
||||
);
|
||||
|
||||
const runtimeSlots = useQuery({
|
||||
queryKey: queryKeys.tools.runtimeSlots(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listRuntimeSlots(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const runtimeActiveCount = (runtimeSlots.data?.runtimeSlots ?? [])
|
||||
.filter((slot) => slot.status === "running").length;
|
||||
|
||||
return (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<AppWindow className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">Apps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="px-3 pb-1 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Apps
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/apps/browse" label="Browse" icon={Store} />
|
||||
<SidebarNavItem to="/apps" label="Connections" icon={AppWindow} end />
|
||||
<SidebarNavItem
|
||||
to="/apps/review"
|
||||
label="Review"
|
||||
icon={ShieldQuestion}
|
||||
badge={reviewCount > 0 ? reviewCount : undefined}
|
||||
badgeTone="warning"
|
||||
badgeLabel="waiting for your OK"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Developer
|
||||
</div>
|
||||
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
|
||||
Advanced setup for developers. Most teams never open this.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{developerTabs.map((tab) => (
|
||||
<SidebarNavItem
|
||||
key={tab.key}
|
||||
to={advancedTabHref(tab.key)}
|
||||
label={tab.label}
|
||||
icon={tab.icon}
|
||||
end
|
||||
liveCount={tab.key === "runtime" && runtimeActiveCount > 0 ? runtimeActiveCount : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const mockHealthApi = vi.hoisted(() => ({
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
@@ -59,6 +60,20 @@ vi.mock("./CompanySettingsSidebar", () => ({
|
||||
CompanySettingsSidebar: () => <div>Company settings sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./AppsSidebar", () => ({
|
||||
AppsSidebar: () => <div>Apps sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./AppConnectionSidebar", () => ({
|
||||
AppDetailSidebar: (props: { kind: "connection"; connectionId: string } | { kind: "application"; applicationId: string }) => (
|
||||
<div>
|
||||
{props.kind === "connection"
|
||||
? `App detail sidebar connection ${props.connectionId}`
|
||||
: `App detail sidebar application ${props.applicationId}`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./BreadcrumbBar", () => ({
|
||||
BreadcrumbBar: () => <div>Breadcrumbs</div>,
|
||||
}));
|
||||
@@ -245,6 +260,7 @@ describe("Layout", () => {
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
|
||||
mockPluginSlots.slots = [];
|
||||
mockPluginSlotContexts.length = 0;
|
||||
mockSidebarState.sidebarOpen = true;
|
||||
@@ -485,6 +501,218 @@ describe("Layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the app sidebar and shows the Apps sidebar in the secondary pane on legacy tools routes", async () => {
|
||||
currentPathname = "/PAP/tools/runtime";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mount the Apps secondary sidebar while experimental apps are disabled", async () => {
|
||||
currentPathname = "/PAP/apps/browse";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Apps sidebar on the M8 advanced-setup tabs", async () => {
|
||||
currentPathname = "/PAP/apps/advanced/run-your-own";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Apps sidebar on legacy developer tabs behind the Advanced door", async () => {
|
||||
currentPathname = "/PAP/apps/advanced/runtime";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// Reserved Apps subroutes are not connection ids. They must keep the
|
||||
// top-level Apps sidebar, never mount a detail sidebar for a phantom app.
|
||||
it.each(["browse", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
|
||||
currentPathname = `/PAP/apps/${route}`;
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("App detail sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Apps sidebar on the gateways list and detail routes", async () => {
|
||||
for (const pathname of ["/PAP/apps/gateways", "/PAP/apps/gateways/gw-1/overview"]) {
|
||||
currentPathname = pathname;
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Apps sidebar");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("App detail sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the app connection sidebar on app detail routes", async () => {
|
||||
currentPathname = "/PAP/apps/conn-1/permissions";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("App detail sidebar connection conn-1");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Apps sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the app detail sidebar on not-connected app routes", async () => {
|
||||
currentPathname = "/PAP/apps/app/app-1/permissions";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("App detail sidebar application app-1");
|
||||
expect(container.textContent).toContain("Main company nav");
|
||||
expect(container.textContent).not.toContain("Apps sidebar");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("forces the app sidebar rail only for the Skills Store route", async () => {
|
||||
async function renderAt(pathname: string) {
|
||||
currentPathname = pathname;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
import { CompanySettingsNav } from "./access/CompanySettingsNav";
|
||||
import { AppsSidebar } from "./AppsSidebar";
|
||||
import { AppDetailSidebar } from "./AppConnectionSidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -27,6 +29,7 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useAppsEnabled } from "../hooks/useAppsEnabled";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
@@ -45,13 +48,19 @@ import { NotFoundPage } from "../pages/NotFound";
|
||||
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
|
||||
|
||||
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
|
||||
if (!companyPrefix) return null;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return null;
|
||||
return segments[1]?.toLowerCase() ?? null;
|
||||
return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] {
|
||||
if (!companyPrefix) return [];
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return [];
|
||||
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return [];
|
||||
return segments.slice(1);
|
||||
}
|
||||
|
||||
const RESERVED_APP_SUBPATHS = new Set(["browse", "connect", "review", "attention", "gateways", "advanced", "app"]);
|
||||
|
||||
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments[0]?.toLowerCase() === "skills") return true;
|
||||
@@ -91,7 +100,19 @@ export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const { enabled: appsEnabled } = useAppsEnabled();
|
||||
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
|
||||
const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix);
|
||||
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
|
||||
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
|
||||
const appDetailConnectionId =
|
||||
isAppsRoute && companyPathSegments[1] && !RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase())
|
||||
? companyPathSegments[1]
|
||||
: null;
|
||||
const appDetailApplicationId =
|
||||
isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2]
|
||||
? companyPathSegments[2]
|
||||
: null;
|
||||
// The Skills Store renders its own secondary (category) sidebar, so the main
|
||||
// app nav collapses to its rail throughout the Skills Store section (PAP-10879).
|
||||
const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix);
|
||||
@@ -138,6 +159,12 @@ export function Layout() {
|
||||
// both desktop (SecondarySidebar) and mobile (off-canvas drawer).
|
||||
const secondarySidebar = isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : appsEnabled && appDetailConnectionId ? (
|
||||
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
|
||||
) : appsEnabled && appDetailApplicationId ? (
|
||||
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
|
||||
) : appsEnabled && (isAppsRoute || isToolsRoute) ? (
|
||||
<AppsSidebar />
|
||||
) : routeSidebarSlot ? (
|
||||
<PluginSlotMount
|
||||
slot={routeSidebarSlot}
|
||||
|
||||
@@ -452,6 +452,27 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the Apps nav item unless experimental apps are enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
|
||||
const disabledRoot = await renderSidebar();
|
||||
|
||||
expect([...container.querySelectorAll("a")].some((anchor) => anchor.textContent === "Apps")).toBe(false);
|
||||
|
||||
flushSync(() => {
|
||||
disabledRoot.unmount();
|
||||
});
|
||||
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
|
||||
const enabledRoot = await renderSidebar();
|
||||
|
||||
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Apps");
|
||||
expect(link?.getAttribute("href")).toBe("/apps");
|
||||
|
||||
flushSync(() => {
|
||||
enabledRoot.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Pipelines nav item when pipelines are enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pin,
|
||||
AppWindow,
|
||||
MessagesSquare,
|
||||
GanttChartSquare,
|
||||
} from "lucide-react";
|
||||
@@ -79,6 +80,7 @@ export function Sidebar() {
|
||||
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const showApps = experimentalSettings?.enableApps === true;
|
||||
const showPipelines = experimentalSettings?.enablePipelines === true;
|
||||
const goalsLinkPending = experimentalSettings === undefined;
|
||||
const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true;
|
||||
@@ -269,6 +271,7 @@ export function Sidebar() {
|
||||
|
||||
<SidebarSection label="Company" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
{showApps ? <SidebarNavItem to="/apps" label="Apps" icon={AppWindow} /> : null}
|
||||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
|
||||
@@ -46,7 +46,7 @@ interface SidebarNavItemProps {
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
badgeTone?: "default" | "danger" | "warning";
|
||||
/**
|
||||
* Accessible noun for the numeric badge when collapsed to the rail, where the
|
||||
* count is rendered as a dot (e.g. `badgeLabel="unread"` → "Inbox, 28 unread").
|
||||
@@ -152,7 +152,11 @@ export function SidebarNavItem({
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-(--shadow-extract-12)",
|
||||
badgeTone === "danger" ? "bg-red-600" : "bg-primary",
|
||||
badgeTone === "danger"
|
||||
? "bg-red-600"
|
||||
: badgeTone === "warning"
|
||||
? "bg-amber-500"
|
||||
: "bg-primary",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -192,7 +196,9 @@ export function SidebarNavItem({
|
||||
"ml-auto px-1.5 leading-none",
|
||||
badgeTone === "danger"
|
||||
? "bg-red-600/90 text-red-50"
|
||||
: "bg-primary text-primary-foreground",
|
||||
: badgeTone === "warning"
|
||||
? "bg-amber-500/90 text-amber-50"
|
||||
: "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
|
||||
@@ -1477,6 +1477,14 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
--project-none: #64748b; /* Semantic rename of --hex-64748b (DECISION-SHEET.md A3, value unchanged). 'No project assigned' muted-slate fallback (TOKEN-AUDIT.md 1.3) across Routines/MarkdownEditor/RoutineRunVariablesDialog/RoutineList/IssueColumns/editable-sections; also ActivityCharts.tsx status 'backlog'. */
|
||||
--project-seed: #6366f1; /* Semantic rename of --hex-6366f1 (DECISION-SHEET.md A3, value unchanged). Project-color-fallback indigo seed default (ProjectDetail/PipelineSettings/IssueProperties/NewIssueDialog) — new-project-color-picker-seed family per TOKEN-AUDIT.md 1.3. */
|
||||
--liveness-blue: #2563eb; /* DECISION-SHEET.md A6: IssueChatThread.tsx human-message 'liveness blue' bubble (PAP-95 rev 5). Same value as --status-task-in_progress by coincidence, decoupled on purpose so a future status-hue change doesn't drag the chat bubble along. */
|
||||
--app-logo-tile-1: #ff4f00;
|
||||
--app-logo-tile-2: #24292f;
|
||||
--app-logo-tile-3: #4a154b;
|
||||
--app-logo-tile-4: #0f172a;
|
||||
--app-logo-tile-5: #5e6ad2;
|
||||
--app-logo-tile-6: #1a73e8;
|
||||
--app-logo-tile-7: #0f9d58;
|
||||
--app-logo-tile-8: #ea4335;
|
||||
--gradient-extract-1: linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02)); /* Dashboard.tsx budget-alert card gradient. */
|
||||
--gradient-extract-2: linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02)); /* Costs.tsx subtle white card gradient. */
|
||||
--gradient-extract-3: radial-gradient(circle at top left,rgba(244,114,182,0.08),transparent 35%),radial-gradient(circle at bottom right,rgba(56,189,248,0.1),transparent 32%); /* AccountingModelCard.tsx decorative overlay. */
|
||||
@@ -1548,6 +1556,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
*/
|
||||
:root {
|
||||
--sz-320px: 320px; /* Extracted from ui/src/components/ActiveAgentsPanel.tsx (h-[320px]). */
|
||||
--sz-390px: 390px;
|
||||
--sz-64px: 64px;
|
||||
--sz-3px: 3px; /* Extracted from ui/src/components/ActivityCharts.tsx (gap-[3px]). */
|
||||
--sz-calc-1: calc(100% - 1.5rem); /* Extracted from ui/src/components/ActivityFeed.tsx (w-[calc(100%-1.5rem)]). */
|
||||
--sz-calc-2: calc(100% - 0.75rem); /* Extracted from ui/src/components/ActivityFeed.tsx (w-[calc(100%-0.75rem)]). */
|
||||
@@ -1577,6 +1587,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
--sz-94vw: 94vw; /* Extracted from ui/src/components/FileViewerSheet.tsx (w-[94vw]). */
|
||||
--sz-1280px: 1280px; /* Extracted from ui/src/components/FileViewerSheet.tsx (max-w-[1280px]). */
|
||||
--sz-60vh: 60vh; /* Extracted from ui/src/components/FrontDoor.tsx (min-h-[60vh]). */
|
||||
--sz-52vh: 52vh;
|
||||
--sz-40rem: 40rem;
|
||||
--sz-44rem: 44rem;
|
||||
--sz-30pct: 30%;
|
||||
--sz-24pct: 24%;
|
||||
--sz-18pct: 18%;
|
||||
--sz-12pct: 12%;
|
||||
--sz-calc-6: min(20rem,calc(100vw - 2rem)); /* Extracted from ui/src/components/InlineEntitySelector.tsx (w-[min(20rem,calc(100vw-2rem))]). */
|
||||
--sz-18rem: 18rem; /* Extracted from ui/src/components/IssueBlockedNotice.tsx (max-w-[18rem]). */
|
||||
--sz-70vh: 70vh; /* Extracted from ui/src/components/IssueChatThread.test.tsx (max-h-[70vh]). */
|
||||
@@ -1801,6 +1818,12 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
--gtc-56: minmax(0,1fr) minmax(14rem,18rem); /* Extracted from ui/src/pages/TeamCatalog.tsx (grid-cols-[minmax(0,1fr)_minmax(14rem,18rem)]). */
|
||||
--gtc-57: repeat(14,minmax(0,1fr)); /* Extracted from ui/src/pages/UserProfile.tsx (grid-cols-[repeat(14,minmax(0,1fr))]). */
|
||||
--gtc-58: auto 1fr auto; /* Extracted from ui/src/pages/UserProfile.tsx (grid-cols-[auto_1fr_auto]). */
|
||||
--gtc-59: 8rem 1fr;
|
||||
--gtc-60: 1fr 1fr;
|
||||
--gtc-61: 1fr 1fr 1fr;
|
||||
--gtc-62: 1fr minmax(0,18rem);
|
||||
--gtc-63: 280px 1fr;
|
||||
--gtc-64: minmax(0,20rem) 1fr;
|
||||
--gtr-1: auto auto; /* Extracted from ui/src/components/ui/card.tsx (grid-rows-[auto_auto]). */
|
||||
--gtr-2: 1fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[1fr]). */
|
||||
--gtr-3: 0fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[0fr]). */
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
|
||||
import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils";
|
||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { toolsApi } from "../api/tools";
|
||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
@@ -87,6 +88,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||
import { AgentToolsTab } from "./AgentToolsTab";
|
||||
import {
|
||||
appendCapped,
|
||||
LIVE_TRANSCRIPT_RENDER_LIMIT,
|
||||
@@ -270,12 +272,13 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "runs" | "budget";
|
||||
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "budget";
|
||||
|
||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||
if (value === "instructions" || value === "prompts") return "instructions";
|
||||
if (value === "configure" || value === "configuration") return "configuration";
|
||||
if (value === "skills") return "skills";
|
||||
if (value === "tools") return "tools";
|
||||
if (value === "budget") return "budget";
|
||||
if (value === "runs") return value;
|
||||
return "dashboard";
|
||||
@@ -892,10 +895,12 @@ export function AgentDetail() {
|
||||
? "configuration"
|
||||
: activeView === "skills"
|
||||
? "skills"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: activeView === "tools"
|
||||
? "tools"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
@@ -999,6 +1004,8 @@ export function AgentDetail() {
|
||||
crumbs.push({ label: "Configuration" });
|
||||
// } else if (activeView === "skills") { // TODO: bring back later
|
||||
// crumbs.push({ label: "Skills" });
|
||||
} else if (activeView === "tools") {
|
||||
crumbs.push({ label: "Tools" });
|
||||
} else if (activeView === "runs") {
|
||||
crumbs.push({ label: "Runs" });
|
||||
} else if (activeView === "budget") {
|
||||
@@ -1243,6 +1250,7 @@ export function AgentDetail() {
|
||||
{ value: "instructions", label: "Instructions" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "tools", label: "Tools" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
]}
|
||||
@@ -1360,6 +1368,10 @@ export function AgentDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "tools" && resolvedCompanyId && (
|
||||
<AgentToolsTab agent={agent} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
|
||||
{activeView === "runs" && (
|
||||
<RunsTab
|
||||
runs={heartbeats ?? []}
|
||||
@@ -3850,6 +3862,13 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
() => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
|
||||
[adapter, censorUsernameInLogs, logLines, parserTick],
|
||||
);
|
||||
const toolDecisionLookup = useQuery({
|
||||
queryKey: queryKeys.tools.runDecisions(run.companyId, run.id),
|
||||
queryFn: () => toolsApi.getRunDecisionLookup(run.companyId, run.id),
|
||||
enabled: Boolean(run.companyId && run.id),
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
staleTime: isLive ? 1000 : 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTranscriptMode("nice");
|
||||
@@ -3936,6 +3955,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<div className="max-h-(--sz-38rem) overflow-y-auto rounded-2xl border border-border/70 bg-background/40 p-3 sm:p-4">
|
||||
<RunTranscriptView
|
||||
entries={transcript}
|
||||
toolDecisions={toolDecisionLookup.data?.decisions ?? []}
|
||||
mode={transcriptMode}
|
||||
streaming={isLive}
|
||||
limit={isLive ? LIVE_TRANSCRIPT_RENDER_LIMIT : undefined}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolPolicy,
|
||||
ToolProfileEffectiveSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
|
||||
const mockToolsApi = vi.hoisted(() => ({
|
||||
getEffectiveProfilesForAgent: vi.fn(),
|
||||
listConnections: vi.fn(),
|
||||
listPolicies: vi.fn(),
|
||||
listCatalog: vi.fn(),
|
||||
listAudit: vi.fn(),
|
||||
putConnectionInstalls: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/tools", () => ({ toolsApi: mockToolsApi }));
|
||||
|
||||
// Render the company-aware Link as a plain anchor so we don't need a Router.
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...rest }: { to: string; children: unknown }) =>
|
||||
createElement("a", { href: to, ...rest }, children as never),
|
||||
}));
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact(cycles = 4) {
|
||||
// Multiple cycles let dependent query waves settle (connections → per-connection catalog).
|
||||
for (let i = 0; i < cycles; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function makeCatalogEntry(overrides: Partial<ToolCatalogEntry>): ToolCatalogEntry {
|
||||
return {
|
||||
id: "cat-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
connectionId: "conn-1",
|
||||
entryKind: "tool",
|
||||
toolName: "github.read_repo",
|
||||
title: "Read repo",
|
||||
description: null,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePolicy(overrides: Partial<ToolPolicy>): ToolPolicy {
|
||||
return {
|
||||
id: "pol-1",
|
||||
companyId: "company-1",
|
||||
name: "Require approval for writes",
|
||||
description: "All write tools need board approval",
|
||||
policyType: "require_approval",
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
selectors: {},
|
||||
conditions: null,
|
||||
config: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentToolsTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockToolsApi.getEffectiveProfilesForAgent.mockReset();
|
||||
mockToolsApi.listConnections.mockReset();
|
||||
mockToolsApi.listPolicies.mockReset();
|
||||
mockToolsApi.listCatalog.mockReset();
|
||||
mockToolsApi.putConnectionInstalls.mockReset();
|
||||
mockToolsApi.putConnectionInstalls.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderTab() {
|
||||
const { AgentToolsTab } = await import("./AgentToolsTab");
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const agent = { id: "agent-1", name: "Coder" } as never;
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(AgentToolsTab, { agent, companyId: "company-1" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("preserves checkbox changes made after the last saved install state", async () => {
|
||||
const { mergeInstallDraft } = await import("./AgentToolsTab");
|
||||
|
||||
expect(
|
||||
mergeInstallDraft(
|
||||
{ "conn-1": true, "conn-2": false },
|
||||
{ "conn-1": true, "conn-2": true },
|
||||
{ "conn-1": false, "conn-2": false },
|
||||
),
|
||||
).toEqual({
|
||||
draft: { "conn-1": true, "conn-2": true },
|
||||
hasPendingChanges: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders effective access, access profiles, governing policy, and unavailable tools", async () => {
|
||||
const allowed = makeCatalogEntry({ id: "cat-allow", toolName: "github.read_repo" });
|
||||
const denied = makeCatalogEntry({
|
||||
id: "cat-deny",
|
||||
toolName: "github.delete_repo",
|
||||
riskLevel: "critical",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: true,
|
||||
});
|
||||
|
||||
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
agentId: "agent-1",
|
||||
profiles: [
|
||||
{
|
||||
id: "prof-1",
|
||||
companyId: "company-1",
|
||||
profileKey: "github-safe",
|
||||
name: "GitHub safe",
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 1,
|
||||
allowedApplicationCount: 1,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 1,
|
||||
assignmentCount: 1,
|
||||
appliesToAgentCount: 1,
|
||||
isCompanyDefault: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
entries: [],
|
||||
bindings: [
|
||||
{
|
||||
id: "bind-1",
|
||||
companyId: "company-1",
|
||||
profileId: "prof-1",
|
||||
targetType: "agent",
|
||||
targetId: "agent-1",
|
||||
priority: 100,
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
allowedTools: [allowed],
|
||||
allowedToolNames: ["github.read_repo"],
|
||||
installedConnections: [],
|
||||
} satisfies ToolProfileEffectiveSummary);
|
||||
|
||||
mockToolsApi.listConnections.mockResolvedValue({
|
||||
connections: [{ id: "conn-1", name: "Production GitHub" }],
|
||||
});
|
||||
mockToolsApi.listPolicies.mockResolvedValue({
|
||||
policies: [
|
||||
makePolicy({ id: "pol-1", name: "Require approval for writes" }),
|
||||
makePolicy({
|
||||
id: "pol-2",
|
||||
name: "Block other agent",
|
||||
enabled: true,
|
||||
selectors: { agentId: "someone-else" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [allowed, denied] });
|
||||
|
||||
await renderTab();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Effective access");
|
||||
expect(text).toContain("github.read_repo");
|
||||
expect(text).toContain("Production GitHub");
|
||||
expect(text).toContain("GitHub safe");
|
||||
expect(text).toContain("Access profiles");
|
||||
expect(text).toContain("Company default");
|
||||
expect(container.querySelector('a[href="/apps/advanced/profiles/prof-1"]')?.textContent).toBe("GitHub safe");
|
||||
expect(container.querySelector('a[href="/apps/advanced/profiles?check=1"]')?.textContent).toBe("Check access");
|
||||
// Governing policy #1 is the company-wide require_approval rule.
|
||||
expect(text).toContain("#1 Require approval for writes");
|
||||
// The policy that targets a different agent must NOT appear.
|
||||
expect(text).not.toContain("Block other agent");
|
||||
// Unavailable tool surfaced from the full tool list minus allowed.
|
||||
expect(text).toContain("github.delete_repo");
|
||||
});
|
||||
|
||||
it("shows the empty allow-list message when no profile applies", async () => {
|
||||
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
agentId: "agent-1",
|
||||
profiles: [],
|
||||
entries: [],
|
||||
bindings: [],
|
||||
allowedTools: [],
|
||||
allowedToolNames: [],
|
||||
installedConnections: [],
|
||||
} satisfies ToolProfileEffectiveSummary);
|
||||
mockToolsApi.listConnections.mockResolvedValue({ connections: [] });
|
||||
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
||||
|
||||
await renderTab();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("No tools are allowed for this agent");
|
||||
expect(text).toContain("No active profile applies");
|
||||
});
|
||||
|
||||
it("autosaves installed apps for the current agent", async () => {
|
||||
const allowed = makeCatalogEntry({ id: "cat-allow", toolName: "github.read_repo" });
|
||||
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
||||
agentId: "agent-1",
|
||||
profiles: [],
|
||||
entries: [],
|
||||
bindings: [],
|
||||
allowedTools: [allowed],
|
||||
allowedToolNames: ["github.read_repo"],
|
||||
installedConnections: [],
|
||||
} satisfies ToolProfileEffectiveSummary);
|
||||
mockToolsApi.listConnections.mockResolvedValue({
|
||||
connections: [{ id: "conn-1", companyId: "company-1", name: "Production GitHub", installs: [] }],
|
||||
});
|
||||
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
||||
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [allowed] });
|
||||
|
||||
await renderTab();
|
||||
|
||||
expect(container.textContent).toContain("Installed apps");
|
||||
expect(container.textContent).toContain("Permitted only");
|
||||
expect(container.textContent).toContain("Permitted but not installed — tools will not appear in runs.");
|
||||
expect(container.querySelector('a[href="/apps/conn-1/permissions"]')?.textContent).toBe("Open permissions");
|
||||
const installCheckbox = container.querySelector<HTMLElement>('[aria-label="Install Production GitHub on Coder"]');
|
||||
expect(installCheckbox).toBeTruthy();
|
||||
await act(async () => {
|
||||
installCheckbox!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockToolsApi.putConnectionInstalls).toHaveBeenCalledWith("conn-1", [
|
||||
{ targetType: "agent", targetId: "agent-1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HelpCircle, PackageCheck } from "lucide-react";
|
||||
import type {
|
||||
AgentDetail as AgentDetailRecord,
|
||||
ToolCatalogEntry,
|
||||
ToolConnection,
|
||||
ToolPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toolsApi } from "../api/tools";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { EnforcementBanner } from "../components/EnforcementBanner";
|
||||
import {
|
||||
RiskBadge,
|
||||
CapabilityBadges,
|
||||
LoadingState as ToolsLoadingState,
|
||||
ErrorState as ToolsErrorState,
|
||||
} from "./tools/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { brandChipBadge } from "../lib/status-colors";
|
||||
import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs";
|
||||
|
||||
/** Normalize a selector value (string or string[]) into a flat string list. */
|
||||
function selectorStringList(value: unknown): string[] {
|
||||
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((v): v is string => typeof v === "string" && v.trim().length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy governs this agent's allow list when it either explicitly names the
|
||||
* agent, or carries no agent/actor restriction that would exclude agents. This
|
||||
* mirrors how the tool gateway evaluates selectors server-side.
|
||||
*/
|
||||
export function policyGovernsAgent(policy: ToolPolicy, agentId: string): boolean {
|
||||
const selectors = (policy.selectors ?? {}) as Record<string, unknown>;
|
||||
const agentIds = [
|
||||
...selectorStringList(selectors.agentId),
|
||||
...selectorStringList(selectors.agentIds),
|
||||
];
|
||||
if (agentIds.length > 0) return agentIds.includes(agentId);
|
||||
const actorTypes = [
|
||||
...selectorStringList(selectors.actorType),
|
||||
...selectorStringList(selectors.actorTypes),
|
||||
];
|
||||
if (actorTypes.length > 0 && !actorTypes.includes("agent")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mergeInstallDraft(
|
||||
serverDraft: Record<string, boolean>,
|
||||
currentDraft: Record<string, boolean>,
|
||||
lastSavedDraft: Record<string, boolean>,
|
||||
): { draft: Record<string, boolean>; hasPendingChanges: boolean } {
|
||||
const draft = { ...serverDraft };
|
||||
let hasPendingChanges = false;
|
||||
for (const [connectionId, installed] of Object.entries(currentDraft)) {
|
||||
if (installed === (lastSavedDraft[connectionId] ?? false)) continue;
|
||||
draft[connectionId] = installed;
|
||||
hasPendingChanges = true;
|
||||
}
|
||||
return { draft, hasPendingChanges };
|
||||
}
|
||||
|
||||
function InstalledAppsSection({
|
||||
agentId,
|
||||
agentName,
|
||||
connections,
|
||||
draft,
|
||||
permittedConnectionIds,
|
||||
pendingConnectionId,
|
||||
saving,
|
||||
unsaved,
|
||||
error,
|
||||
onChange,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
connections: ToolConnection[];
|
||||
draft: Record<string, boolean>;
|
||||
permittedConnectionIds: Set<string>;
|
||||
pendingConnectionId: string | null;
|
||||
saving: boolean;
|
||||
unsaved: boolean;
|
||||
error: boolean;
|
||||
onChange: (connectionId: string, installed: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-3 py-2.5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Installed apps</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Installed apps load tools into {agentName}'s context on every run. Permitted-only apps do not add context cost.
|
||||
</p>
|
||||
</div>
|
||||
<InstallSaveStatusChip pending={saving} unsaved={unsaved} error={error} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-3">
|
||||
<InlineBanner tone="info" compact>
|
||||
Has access means the app is permitted. Installed means its tools are added to this agent's runtime context.
|
||||
</InlineBanner>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<p className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
|
||||
No permitted apps yet. Bind an access profile to make apps available here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{connections.map((connection) => {
|
||||
const installState = installStateFrom(connection.installs);
|
||||
const installedForAll = installState.onAll;
|
||||
const checked = installedForAll || (draft[connection.id] ?? installState.agentIds.has(agentId));
|
||||
const permitted = permittedConnectionIds.has(connection.id);
|
||||
const rowPending = pendingConnectionId === connection.id;
|
||||
return (
|
||||
<div key={connection.id} className="space-y-2 px-3 py-3">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={installedForAll || rowPending}
|
||||
aria-label={`Install ${connection.name} on ${agentName}`}
|
||||
onCheckedChange={(next) => onChange(connection.id, Boolean(next))}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">{connection.name}</span>
|
||||
<InstallBadge installed={checked} installedForAll={installedForAll} permitted={permitted} />
|
||||
{rowPending ? <span className="text-xs text-muted-foreground">Saving...</span> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{installedForAll
|
||||
? "Installed from the app page for every agent. Remove the all-agents install there."
|
||||
: checked
|
||||
? "Loaded into this agent's runtime context."
|
||||
: INSTALLED_HINT}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{permitted && !checked ? (
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
compact
|
||||
className="ml-7"
|
||||
actions={(
|
||||
<Link
|
||||
to={`/apps/${connection.id}/permissions`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Open permissions
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
Permitted but not installed — tools will not appear in runs.
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallBadge({
|
||||
installed,
|
||||
installedForAll,
|
||||
permitted,
|
||||
}: {
|
||||
installed: boolean;
|
||||
installedForAll: boolean;
|
||||
permitted: boolean;
|
||||
}) {
|
||||
const label = installed ? (installedForAll ? "Installed for all" : "Installed") : permitted ? "Permitted only" : "Not permitted";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
installed ? brandChipBadge.green : brandChipBadge.gray,
|
||||
)}
|
||||
>
|
||||
{installed ? <PackageCheck className="h-3 w-3" /> : null}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSaveStatusChip({
|
||||
pending,
|
||||
unsaved,
|
||||
error,
|
||||
}: {
|
||||
pending: boolean;
|
||||
unsaved: boolean;
|
||||
error: boolean;
|
||||
}) {
|
||||
if (pending) return <span className="text-xs text-muted-foreground">Saving...</span>;
|
||||
if (error) return <span className="text-xs text-destructive">Could not save</span>;
|
||||
if (unsaved) return <span className="text-xs text-muted-foreground">Unsaved changes</span>;
|
||||
return <span className="text-xs text-muted-foreground">Saved</span>;
|
||||
}
|
||||
|
||||
const POLICY_EFFECT_LABEL: Record<string, string> = {
|
||||
allow: "allow",
|
||||
block: "block",
|
||||
deny: "deny",
|
||||
require_approval: "require approval",
|
||||
redact: "redact",
|
||||
rate_limit: "rate limit",
|
||||
};
|
||||
|
||||
const DENIED_TOOLS_DISPLAY_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Agent detail · Tools tab (PAP-10788, surface 09 of the PAP-10771 v2 spec).
|
||||
*
|
||||
* Communicates the agent's resolved tool access: the banner makes clear the
|
||||
* prompt can narrow the list but never expand it, and the side panel explains
|
||||
* which access profiles and rules shape the final list.
|
||||
*/
|
||||
export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [installDraft, setInstallDraft] = useState<Record<string, boolean>>({});
|
||||
const lastSavedInstallRef = useRef<Record<string, boolean>>({});
|
||||
const skipNextInstallAutosaveRef = useRef(true);
|
||||
const failedInstallDraftRef = useRef<{ connectionId: string; installed: boolean } | null>(null);
|
||||
|
||||
const effective = useQuery({
|
||||
queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id),
|
||||
queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agent.id),
|
||||
});
|
||||
|
||||
const connectionsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connections(companyId),
|
||||
queryFn: () => toolsApi.listConnections(companyId),
|
||||
});
|
||||
|
||||
const policiesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.policies(companyId),
|
||||
queryFn: () => toolsApi.listPolicies(companyId),
|
||||
});
|
||||
|
||||
const connectionList = connectionsQuery.data?.connections ?? [];
|
||||
const connectionInstallSignature = useMemo(
|
||||
() =>
|
||||
connectionList
|
||||
.map((connection) => {
|
||||
const installKey = (connection.installs ?? [])
|
||||
.map((install) => `${install.targetType}:${install.targetId}`)
|
||||
.sort()
|
||||
.join(",");
|
||||
return `${connection.id}=${installKey}`;
|
||||
})
|
||||
.join("|"),
|
||||
[connectionList],
|
||||
);
|
||||
|
||||
const syncInstall = useMutation({
|
||||
mutationFn: ({ connection, installed }: { connection: ToolConnection; installed: boolean }) => {
|
||||
const nextState = installStateFrom(connection.installs);
|
||||
if (!nextState.onAll) {
|
||||
if (installed) nextState.agentIds.add(agent.id);
|
||||
else nextState.agentIds.delete(agent.id);
|
||||
}
|
||||
return toolsApi.putConnectionInstalls(
|
||||
connection.id,
|
||||
installPayload(connection.companyId ?? companyId, nextState),
|
||||
);
|
||||
},
|
||||
onSuccess: async (_snapshot, variables) => {
|
||||
failedInstallDraftRef.current = null;
|
||||
lastSavedInstallRef.current = {
|
||||
...lastSavedInstallRef.current,
|
||||
[variables.connection.id]: variables.installed,
|
||||
};
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }),
|
||||
]);
|
||||
},
|
||||
onError: (_error, variables) => {
|
||||
failedInstallDraftRef.current = {
|
||||
connectionId: variables.connection.id,
|
||||
installed: variables.installed,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setInstallDraft({});
|
||||
lastSavedInstallRef.current = {};
|
||||
skipNextInstallAutosaveRef.current = true;
|
||||
failedInstallDraftRef.current = null;
|
||||
}, [agent.id, companyId]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = Object.fromEntries(
|
||||
connectionList.map((connection) => [
|
||||
connection.id,
|
||||
isAgentInstalled(installStateFrom(connection.installs), agent.id),
|
||||
]),
|
||||
);
|
||||
failedInstallDraftRef.current = null;
|
||||
setInstallDraft((current) => {
|
||||
const merged = mergeInstallDraft(next, current, lastSavedInstallRef.current);
|
||||
lastSavedInstallRef.current = next;
|
||||
skipNextInstallAutosaveRef.current = !merged.hasPendingChanges;
|
||||
return merged.draft;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agent.id, connectionInstallSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextInstallAutosaveRef.current) {
|
||||
skipNextInstallAutosaveRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (syncInstall.isPending) return;
|
||||
|
||||
const changedConnection = connectionList.find((connection) => {
|
||||
const saved = lastSavedInstallRef.current[connection.id] ?? false;
|
||||
const draft = installDraft[connection.id] ?? false;
|
||||
const failed = failedInstallDraftRef.current;
|
||||
return saved !== draft && !(failed?.connectionId === connection.id && failed.installed === draft);
|
||||
});
|
||||
if (!changedConnection) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
const installed = installDraft[changedConnection.id] ?? false;
|
||||
const saved = lastSavedInstallRef.current[changedConnection.id] ?? false;
|
||||
const failed = failedInstallDraftRef.current;
|
||||
if (saved !== installed && !(failed?.connectionId === changedConnection.id && failed.installed === installed)) {
|
||||
syncInstall.mutate({ connection: changedConnection, installed });
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [connectionList, installDraft, syncInstall.isPending, syncInstall.mutate]);
|
||||
|
||||
// The effective endpoint returns the *allowed* slice of the catalog. To show
|
||||
// "Denied tools (suppressed)" we need the full company catalog, which is only
|
||||
// exposed per-connection — same aggregation the Applications tab uses.
|
||||
const catalogQueries = useQueries({
|
||||
queries: connectionList.map((connection) => ({
|
||||
queryKey: queryKeys.tools.catalog(connection.id),
|
||||
queryFn: () => toolsApi.listCatalog(connection.id),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const connectionNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const connection of connectionList) map.set(connection.id, connection.name);
|
||||
return map;
|
||||
}, [connectionList]);
|
||||
|
||||
const allowedTools = useMemo(
|
||||
() =>
|
||||
[...(effective.data?.allowedTools ?? [])].sort((a, b) =>
|
||||
a.toolName.localeCompare(b.toolName),
|
||||
),
|
||||
[effective.data?.allowedTools],
|
||||
);
|
||||
|
||||
const permittedConnectionIds = useMemo(
|
||||
() => new Set([
|
||||
...(effective.data?.entries ?? [])
|
||||
.filter((entry) => entry.effect === "include" && entry.connectionId)
|
||||
.map((entry) => entry.connectionId!),
|
||||
...allowedTools.map((tool) => tool.connectionId),
|
||||
]),
|
||||
[allowedTools, effective.data?.entries],
|
||||
);
|
||||
const installedAppConnections = useMemo(
|
||||
() =>
|
||||
connectionList
|
||||
.filter((connection) => permittedConnectionIds.has(connection.id) || (installDraft[connection.id] ?? false))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[connectionList, installDraft, permittedConnectionIds],
|
||||
);
|
||||
const hasInstallUnsavedChanges = installedAppConnections.some(
|
||||
(connection) => (installDraft[connection.id] ?? false) !== (lastSavedInstallRef.current[connection.id] ?? false),
|
||||
);
|
||||
|
||||
const catalogStamp = catalogQueries.map((q) => q.dataUpdatedAt).join(",");
|
||||
const deniedTools = useMemo(() => {
|
||||
const allowedIds = new Set((effective.data?.allowedTools ?? []).map((tool) => tool.id));
|
||||
const seen = new Set<string>();
|
||||
const denied: ToolCatalogEntry[] = [];
|
||||
for (const result of catalogQueries) {
|
||||
for (const entry of result.data?.catalog ?? []) {
|
||||
if (entry.status !== "active") continue;
|
||||
if (allowedIds.has(entry.id) || seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
denied.push(entry);
|
||||
}
|
||||
}
|
||||
return denied.sort((a, b) => a.toolName.localeCompare(b.toolName));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effective.data?.allowedTools, catalogStamp]);
|
||||
|
||||
const governingPolicies = useMemo(() => {
|
||||
const policies = policiesQuery.data?.policies ?? [];
|
||||
return policies
|
||||
.map((policy, index) => ({ policy, order: index + 1 }))
|
||||
.filter(({ policy }) => policy.enabled && policyGovernsAgent(policy, agent.id));
|
||||
}, [policiesQuery.data?.policies, agent.id]);
|
||||
|
||||
const profiles = effective.data?.profiles ?? [];
|
||||
const catalogLoading = catalogQueries.some((q) => q.isLoading);
|
||||
|
||||
if (effective.isLoading) return <ToolsLoadingState label="Resolving effective access…" />;
|
||||
if (effective.error) {
|
||||
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
|
||||
}
|
||||
|
||||
const policiesHref = "/apps/advanced/policies";
|
||||
const profilesHref = "/apps/advanced/profiles";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<EnforcementBanner
|
||||
tone="info"
|
||||
title="Effective access"
|
||||
body={
|
||||
<>
|
||||
This is exactly the tool set Paperclip will accept for{" "}
|
||||
<span className="font-medium">{agent.name}</span>. Profile and policy edits are
|
||||
reflected within ~5 seconds. The agent's prompt can narrow this list but{" "}
|
||||
<span className="font-medium">cannot expand it</span> — everything else is blocked by
|
||||
default.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<InstalledAppsSection
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
connections={installedAppConnections}
|
||||
draft={installDraft}
|
||||
permittedConnectionIds={permittedConnectionIds}
|
||||
pendingConnectionId={syncInstall.isPending ? syncInstall.variables?.connection.id ?? null : null}
|
||||
saving={syncInstall.isPending}
|
||||
unsaved={hasInstallUnsavedChanges}
|
||||
error={syncInstall.isError && hasInstallUnsavedChanges}
|
||||
onChange={(connectionId, installed) => {
|
||||
failedInstallDraftRef.current = null;
|
||||
setInstallDraft((current) => ({ ...current, [connectionId]: installed }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{/* Allowed tools table */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
|
||||
<h3 className="text-sm font-semibold text-foreground">Allowed tools</h3>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{allowedTools.length} {allowedTools.length === 1 ? "tool" : "tools"}
|
||||
</span>
|
||||
</div>
|
||||
{allowedTools.length === 0 ? (
|
||||
<p className="px-3 py-6 text-sm text-muted-foreground">
|
||||
No tools are allowed for this agent. Bind a tool profile to grant access.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">Tool</th>
|
||||
<th className="px-3 py-2 font-medium">Capability</th>
|
||||
<th className="px-3 py-2 font-medium">Risk</th>
|
||||
<th className="px-3 py-2 font-medium">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{allowedTools.map((tool) => (
|
||||
<tr key={tool.id} className="align-top">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-mono text-xs text-foreground">{tool.toolName}</div>
|
||||
{tool.title ? (
|
||||
<div className="text-(length:--text-micro) text-muted-foreground">{tool.title}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<CapabilityBadges
|
||||
isReadOnly={tool.isReadOnly}
|
||||
isWrite={tool.isWrite}
|
||||
isDestructive={tool.isDestructive}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<RiskBadge risk={tool.riskLevel} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{connectionNameById.get(tool.connectionId) ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why these tools? side panel */}
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-background/60 p-3">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Why these tools?
|
||||
</h3>
|
||||
|
||||
{/* Access profiles */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Access profiles
|
||||
</div>
|
||||
<Link
|
||||
to={`${profilesHref}?check=1`}
|
||||
className="text-(length:--text-micro) font-medium text-primary hover:underline"
|
||||
>
|
||||
Check access
|
||||
</Link>
|
||||
</div>
|
||||
{profiles.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No active profile applies to this agent, so it has no allowed tools.
|
||||
</p>
|
||||
) : (
|
||||
profiles.map((profile) => {
|
||||
return (
|
||||
<div key={profile.id} className="rounded-md border border-border/70 px-2.5 py-2">
|
||||
<Link
|
||||
to={`${profilesHref}/${profile.id}`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{profile.name}
|
||||
</Link>
|
||||
{profile.summary.isCompanyDefault ? (
|
||||
<div className="mt-1">
|
||||
<span className="rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
|
||||
Company default
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Policies mutating the allow list */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Active policies
|
||||
</div>
|
||||
{policiesQuery.isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading policies…</p>
|
||||
) : governingPolicies.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No enabled policy currently mutates this agent's allow list.
|
||||
</p>
|
||||
) : (
|
||||
governingPolicies.map(({ policy, order }) => (
|
||||
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link
|
||||
to={policiesHref}
|
||||
className="truncate text-xs font-medium text-primary hover:underline"
|
||||
title={`Policy #${order}: ${policy.name}`}
|
||||
>
|
||||
#{order} {policy.name}
|
||||
</Link>
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
|
||||
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
|
||||
</span>
|
||||
</div>
|
||||
{policy.description ? (
|
||||
<div className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground">
|
||||
{policy.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unavailable tools */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Unavailable tools
|
||||
</div>
|
||||
{catalogLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Checking tools…</p>
|
||||
) : deniedTools.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Every known tool this agent could name is allowed.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
Tools the agent could name but Paperclip would block:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{deniedTools.slice(0, DENIED_TOOLS_DISPLAY_LIMIT).map((tool) => (
|
||||
<span
|
||||
key={tool.id}
|
||||
className="inline-flex items-center gap-1 rounded border border-border/70 bg-muted/40 px-1.5 py-0.5 font-mono text-(length:--text-micro) text-muted-foreground"
|
||||
title={connectionNameById.get(tool.connectionId) ?? undefined}
|
||||
>
|
||||
{tool.toolName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{deniedTools.length > DENIED_TOOLS_DISPLAY_LIMIT ? (
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
+{deniedTools.length - DENIED_TOOLS_DISPLAY_LIMIT} more
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+49
-132
@@ -34,6 +34,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { AgentMultiSelect } from "../components/AgentMultiSelect";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -54,11 +55,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { buildLineDiff, type DiffRow } from "../lib/line-diff";
|
||||
@@ -1897,140 +1893,61 @@ function AttachAgentsPopover({
|
||||
onSubmit: (nextIds: string[], versionId: string | null) => void;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
// Each popover instance owns its open state. The detail page renders two of
|
||||
// these (agents tab + sidebar); sharing a single controlled flag made both
|
||||
// open at once and swallowed clicks, so "Add to agent" appeared dead (PAP-10907 H).
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [draft, setDraft] = useState<Set<string>>(new Set(attachedAgentIds));
|
||||
const [draftVersionId, setDraftVersionId] = useState<string | null>(selectedVersionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(new Set(attachedAgentIds));
|
||||
setDraftVersionId(selectedVersionId);
|
||||
setFilter("");
|
||||
}
|
||||
}, [open, attachedAgentIds, selectedVersionId]);
|
||||
|
||||
// Checked agents float to the top of the list (PAP-10907); within each group
|
||||
// we keep a stable alphabetical order.
|
||||
const filtered = agents
|
||||
.filter((agent) => agent.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const aChecked = draft.has(a.id);
|
||||
const bChecked = draft.has(b.id);
|
||||
if (aChecked !== bChecked) return aChecked ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const attachedIds = useMemo(() => new Set(attachedAgentIds), [attachedAgentIds]);
|
||||
const eligible = agents.filter((agent) => agent.supportsSkills);
|
||||
const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" className={cn(fullWidth && "w-full")}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add to agent
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Filter agents"
|
||||
className="h-8"
|
||||
/>
|
||||
{sortedVersions.length > 0 ? (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span className="shrink-0 text-muted-foreground">Version</span>
|
||||
<select
|
||||
value={draftVersionId ?? "__latest__"}
|
||||
onChange={(event) => setDraftVersionId(event.target.value === "__latest__" ? null : event.target.value)}
|
||||
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
>
|
||||
<option value="__latest__">Latest</option>
|
||||
{sortedVersions.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
v{version.revisionNumber}{version.label ? ` · ${version.label}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{eligible.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No agents in this company support skills yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{filtered.map((agent) => {
|
||||
const disabled = agent.required || !agent.supportsSkills;
|
||||
const checked = draft.has(agent.id);
|
||||
return (
|
||||
<label
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2 px-3 py-1.5 text-sm hover:bg-accent/30",
|
||||
disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(value) => {
|
||||
setDraft((current) => {
|
||||
const next = new Set(current);
|
||||
if (value) next.add(agent.id);
|
||||
else next.delete(agent.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<AgentIcon icon={agent.icon} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
{agent.paused ? (
|
||||
<Badge variant="outline" className="[&>svg]:size-2.5 border-amber-500/30 bg-amber-500/10 px-1.5 text-(length:--text-nano) uppercase tracking-wide text-amber-500">
|
||||
<Pause className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Paused
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
||||
{agent.adapterType}
|
||||
{agent.required ? " · required" : ""}
|
||||
{!agent.supportsSkills ? " · skills not supported" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">No matches.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-3 py-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSubmit(Array.from(draft), draftVersionId);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={pending}
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={attachedIds}
|
||||
onSave={(nextIds) => onSubmit(Array.from(nextIds), draftVersionId)}
|
||||
pending={pending}
|
||||
triggerLabel="Add to agent"
|
||||
triggerIcon={<Plus className="mr-1.5 h-3.5 w-3.5" />}
|
||||
triggerVariant="default"
|
||||
triggerSize="sm"
|
||||
triggerFullWidth={fullWidth}
|
||||
triggerClassName={cn(fullWidth && "w-full")}
|
||||
contentAlign="end"
|
||||
showSelectionPreview={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setDraftVersionId(selectedVersionId);
|
||||
}}
|
||||
headerContent={sortedVersions.length > 0 ? (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span className="shrink-0 text-muted-foreground">Version</span>
|
||||
<select
|
||||
value={draftVersionId ?? "__latest__"}
|
||||
onChange={(event) => setDraftVersionId(event.target.value === "__latest__" ? null : event.target.value)}
|
||||
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
>
|
||||
{pending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
<option value="__latest__">Latest</option>
|
||||
{sortedVersions.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
v{version.revisionNumber}{version.label ? ` · ${version.label}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
emptyMessage={eligible.length === 0 ? "No agents in this company support skills yet." : "No agents yet."}
|
||||
isAgentDisabled={(agent) => {
|
||||
const option = agent as AttachAgentOption;
|
||||
return option.required || !option.supportsSkills;
|
||||
}}
|
||||
getDescription={(agent) => {
|
||||
const option = agent as AttachAgentOption;
|
||||
return `${option.adapterType}${option.required ? " · required" : ""}${!option.supportsSkills ? " · skills not supported" : ""}`;
|
||||
}}
|
||||
renderNameSuffix={(agent) => (agent as AttachAgentOption).paused ? (
|
||||
<Badge variant="outline" className="[&>svg]:size-2.5 border-amber-500/30 bg-amber-500/10 px-1.5 text-(length:--text-nano) uppercase tracking-wide text-amber-500">
|
||||
<Pause className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Paused
|
||||
</Badge>
|
||||
) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { SmokeLabDashboardCard } from "../components/SmokeLabDashboardCard";
|
||||
|
||||
const DASHBOARD_ACTIVITY_LIMIT = 10;
|
||||
|
||||
@@ -309,6 +310,8 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SmokeLabDashboardCard companyId={selectedCompanyId!} />
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart activity={data.runActivity} />
|
||||
|
||||
@@ -122,6 +122,8 @@ import {
|
||||
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
|
||||
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
@@ -1778,6 +1780,158 @@ export function DesignGuide() {
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TOOLS & ACCESS (PAP-10389) */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Tools & Access">
|
||||
<SubSection title="EnforcementBanner — default / denied-detected">
|
||||
<div className="space-y-3">
|
||||
<EnforcementBanner companyId="" forceVariant="default" recentDenialCount={0} />
|
||||
<EnforcementBanner companyId="" forceVariant="denied-detected" recentDenialCount={3} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Persistent at the top of the Tools & Access surface. Tints to <code>denied-detected</code> when
|
||||
governed tool calls were denied or failed in the last hour. Observability only — enforcement lives
|
||||
in the tool gateway.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="EnforcementBanner — presentational tones (info / warning / error)">
|
||||
<div className="space-y-3">
|
||||
<EnforcementBanner
|
||||
tone="info"
|
||||
title="Effective access — server resolved."
|
||||
body="This is exactly what the tool gateway will accept. Profile and policy edits reflect within ~5s; the prompt cannot expand it."
|
||||
/>
|
||||
<EnforcementBanner
|
||||
tone="warning"
|
||||
title="Local stdio is local code execution, not a security sandbox."
|
||||
body="A local-stdio slot runs with the orchestrator's privileges. Only bind trusted commands; quarantine anything you would not run yourself."
|
||||
/>
|
||||
<EnforcementBanner
|
||||
tone="error"
|
||||
title="Runtime failed closed."
|
||||
body="The supervisor is restarting (attempt 2/3). The gateway returns runtime-error and the agent does not see partial output."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Static governance copy with a tone. Used for the PAP-10400 trust-tier banner on Runtime and the
|
||||
effective-access banner on Agent → Tools. Pass <code>title</code>/<code>body</code> and an optional{" "}
|
||||
<code>icon</code>.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Action approval card — pending / stale (surfaces 11/12)">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ActionCard
|
||||
toolName="slack.post_message"
|
||||
risk="medium"
|
||||
isWrite
|
||||
binding={{
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
}}
|
||||
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
|
||||
reason="This tool can write to your workspace, so a human signs off before the agent posts."
|
||||
policyNumber={7}
|
||||
expiresInLabel="expires in 23h 51m"
|
||||
/>
|
||||
<ActionCard
|
||||
variant="stale"
|
||||
toolName="slack.post_message"
|
||||
risk="medium"
|
||||
isWrite
|
||||
binding={{
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
|
||||
previousCatalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
}}
|
||||
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
|
||||
reason="This tool can write to your workspace, so a human signs off before the agent posts."
|
||||
policyNumber={7}
|
||||
expiresInLabel="expires in 18h 02m"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Signed payload sha256 + expiry surface on every variant (PAP-10400). The{" "}
|
||||
<code>stale</code> variant tints the border amber, banners the catalog-hash mismatch, strikes through
|
||||
the previous hash next to the current one, and renders <code>Approve</code> disabled until the request
|
||||
is re-issued.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Action approval card — mobile (390×844, surface 99)">
|
||||
<div className="w-(--sz-390px) max-w-full rounded-xl border border-border bg-background p-3">
|
||||
<ActionCardMobile
|
||||
toolName="slack.post_message"
|
||||
risk="medium"
|
||||
isWrite
|
||||
binding={{
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
}}
|
||||
input={{ channel: "#launch", text: "Deploy v2 is live 🎉" }}
|
||||
reason="This tool can write to your workspace, so a human signs off before the agent posts."
|
||||
policyNumber={7}
|
||||
expiresInLabel="expires in 23h 51m"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Identical content; the three buttons stack full-width in the order Approve / Deny / Edit & re-sign,
|
||||
and the bindings table uses a 70px label column.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="BindingsTable (reused in the audit row drilldown)">
|
||||
<BindingsTable
|
||||
rows={[
|
||||
{ label: "Application", value: "Slack · manifest v2.4.1" },
|
||||
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
|
||||
{ label: "Catalog", value: "sha256:9f86d081…f00a08", mono: true },
|
||||
{ label: "Payload", value: "sha256:2c26b46b…66e7ae", mono: true },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Two-column key/value block with mono values. Lives inside <code>ActionCard</code> and is reused
|
||||
standalone in the audit row drilldown.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Tool-access status keys (StatusBadge)">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
"allowed", "denied", "block", "require-approval", "redacted", "rate-limit",
|
||||
"deferred", "hidden", "quarantined", "healthy", "degraded", "runtime-error", "unchecked",
|
||||
].map((s) => (
|
||||
<StatusBadge key={s} status={s} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Policy decisions, connection/runtime health, and catalog quarantine all route through the canonical{" "}
|
||||
<code>StatusBadge</code> keys defined in <code>lib/status-colors</code>.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="EmptyState (canonical, with description + action)">
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
message="No connections yet"
|
||||
description="Add a connection to an application to configure credentials and discover its tools."
|
||||
action="New connection"
|
||||
onAction={() => {}}
|
||||
/>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
<Section title="Environment Variables Editor">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reusable env-var editor (agents, projects, environments, routines). One shared grid, an
|
||||
|
||||
@@ -55,6 +55,7 @@ const SERVER_INFO_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle server info debug view experimental setting"]';
|
||||
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle built-in agents experimental setting"]';
|
||||
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
|
||||
const AUTO_RECOVERY_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle task graph liveness auto-recovery"]';
|
||||
|
||||
@@ -187,6 +188,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
expect(warning?.textContent).toContain("no compatibility guarantees");
|
||||
});
|
||||
|
||||
it("enables the Apps UI from experimental settings", async () => {
|
||||
await renderPage();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(() => toggle?.click());
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
|
||||
expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not render the Conference Room Chat experimental setting for now", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ export function InstanceExperimentalSettings() {
|
||||
);
|
||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const enableApps = experimentalQuery.data?.enableApps === true;
|
||||
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
|
||||
// experimental opt-out was retired, so it no longer surfaces a toggle here.
|
||||
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
|
||||
@@ -299,6 +300,7 @@ export function InstanceExperimentalSettings() {
|
||||
const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true;
|
||||
const enableCases = experimentalQuery.data?.enableCases === true;
|
||||
const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true;
|
||||
const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
const enableIssueGraphLivenessAutoRecovery =
|
||||
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
||||
@@ -426,6 +428,26 @@ export function InstanceExperimentalSettings() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold">Apps</h2>
|
||||
<Badge variant="secondary">Experimental</Badge>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableApps}
|
||||
onCheckedChange={() => toggleMutation.mutate({ enableApps: !enableApps })}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle apps experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
@@ -686,6 +708,25 @@ export function InstanceExperimentalSettings() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Smoke Lab</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Add a "Smoke Lab" tab under Apps → Developer and an "Integration smoke" card on the
|
||||
dashboard for exercising every integration path against deterministic local fixtures
|
||||
(fake OAuth provider + loopback MCP servers). Private (non-public) deployments only.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableSmokeLab}
|
||||
onCheckedChange={() => toggleMutation.mutate({ enableSmokeLab: !enableSmokeLab })}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle smoke lab experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDetail } from "./AppDetail";
|
||||
|
||||
const getConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesMock = vi.hoisted(() => vi.fn());
|
||||
const listPoliciesMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const refreshCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "setup" as string | undefined }));
|
||||
const navigateComponentMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
getConnection: (connectionId: string) => getConnectionMock(connectionId),
|
||||
getConnectionInstalls: (connectionId: string) => getConnectionInstallsMock(connectionId),
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
listCatalog: (connectionId: string) => listCatalogMock(connectionId),
|
||||
listProfiles: (companyId: string) => listProfilesMock(companyId),
|
||||
listPolicies: (companyId: string) => listPoliciesMock(companyId),
|
||||
listConnectionActivity: (connectionId: string, limit: number) =>
|
||||
listConnectionActivityMock(connectionId, limit),
|
||||
listActionRequests: (companyId: string, status: string) =>
|
||||
listActionRequestsMock(companyId, status),
|
||||
updateConnection: (connectionId: string, input: unknown) =>
|
||||
updateConnectionMock(connectionId, input),
|
||||
finishApp: (companyId: string, connectionId: string, input: unknown) =>
|
||||
finishAppMock(companyId, connectionId, input),
|
||||
putConnectionInstalls: (connectionId: string, installs: unknown) =>
|
||||
putConnectionInstallsMock(connectionId, installs),
|
||||
archiveConnection: vi.fn(),
|
||||
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
|
||||
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
|
||||
reconnectConnection: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: vi.fn().mockResolvedValue([
|
||||
{ id: "agent-1", name: "Coder", title: "Engineer", status: "active" },
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useParams: () => mockParams,
|
||||
useNavigate: () => mockNavigate,
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
|
||||
navigateComponentMock({ to, replace });
|
||||
return <div data-navigate-to={to} />;
|
||||
},
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conn-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
name: "GitHub",
|
||||
connectionKind: "managed",
|
||||
transport: "remote_http",
|
||||
status: "active",
|
||||
transportConfig: { url: "https://github.example/mcp" },
|
||||
config: { url: "https://github.example/mcp" },
|
||||
credentialSecretRefs: [],
|
||||
credentialRefs: [],
|
||||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
lastUsedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogEntry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "catalog-read",
|
||||
companyId: "company-1",
|
||||
connectionId: "conn-1",
|
||||
toolName: "read_repo",
|
||||
title: "Read repo",
|
||||
description: "Read repository metadata",
|
||||
status: "active",
|
||||
isReadOnly: true,
|
||||
riskLevel: "read",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("AppDetail", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.connectionId = "conn-1";
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(connection());
|
||||
getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
logoUrl: "https://example.com/github.png",
|
||||
tagline: "GitHub tagline",
|
||||
description: "Give agents a governed way to inspect repositories and pull requests.",
|
||||
authKind: "api_key",
|
||||
transportTemplate: { transport: "remote_http", url: "https://github.example/mcp" },
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {},
|
||||
urlPatterns: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
listCatalogMock.mockResolvedValue({
|
||||
catalog: [
|
||||
catalogEntry(),
|
||||
catalogEntry({
|
||||
id: "catalog-write",
|
||||
toolName: "write_issue",
|
||||
title: "Write issue",
|
||||
description: "Create or update an issue",
|
||||
isReadOnly: false,
|
||||
}),
|
||||
catalogEntry({
|
||||
id: "catalog-quarantined",
|
||||
toolName: "delete_repo",
|
||||
title: "Delete repo",
|
||||
status: "quarantined",
|
||||
isReadOnly: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
listProfilesMock.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
profileKey: "app:conn-1",
|
||||
entries: [
|
||||
{ effect: "include", catalogEntryId: "catalog-read" },
|
||||
{ effect: "include", catalogEntryId: "catalog-write" },
|
||||
],
|
||||
bindings: [{ targetType: "company" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
listPoliciesMock.mockResolvedValue({
|
||||
policies: [
|
||||
{
|
||||
policyType: "require_approval",
|
||||
enabled: true,
|
||||
config: {
|
||||
source: "app_gallery_finish",
|
||||
connectionId: "conn-1",
|
||||
catalogEntryId: "catalog-write",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
|
||||
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
|
||||
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
|
||||
finishAppMock.mockResolvedValue({});
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] });
|
||||
startOAuthMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
provider: "smoke_lab",
|
||||
authorizationUrl: "http://example.test/oauth",
|
||||
expiresAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderAppDetail() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("pauses the app by flipping the connection enabled flag", async () => {
|
||||
await renderAppDetail();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[role="switch"][aria-label="Pause this app"]',
|
||||
);
|
||||
expect(toggle).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
toggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", { enabled: false });
|
||||
});
|
||||
|
||||
it("redirects a missing tab to setup", async () => {
|
||||
mockParams.tab = undefined;
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/setup", replace: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["setup", "Agents can use this app"],
|
||||
["review", "1 new action to review"],
|
||||
["permissions", "Action permissions"],
|
||||
["activity", "No activity yet."],
|
||||
["advanced", "Technical details"],
|
||||
])("renders the %s tab panel", async (tab, expectedText) => {
|
||||
mockParams.tab = tab;
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("2 actions available");
|
||||
expect(container.textContent).toContain(expectedText);
|
||||
});
|
||||
|
||||
it("hides secret URL parameters in advanced technical details", async () => {
|
||||
mockParams.tab = "advanced";
|
||||
getConnectionMock.mockResolvedValue(
|
||||
connection({
|
||||
config: {
|
||||
url: "https://mcp.zapier.com/api/v1/connect?token=zapier-secret®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<HTMLInputElement>(
|
||||
'input[placeholder="https://docs.google.com/spreadsheets/d/..."]',
|
||||
);
|
||||
expect(input).toBeTruthy();
|
||||
await act(async () => setInputValue(input!, "https://docs.google.com/spreadsheets/d/sheet_new/edit"));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Add sheet")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(updateConnectionMock).toHaveBeenCalledWith("conn-1", {
|
||||
config: expect.objectContaining({
|
||||
allowedSpreadsheetIds: ["sheet_existing", "sheet_new"],
|
||||
env: expect.objectContaining({ GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "sheet_existing,sheet_new" }),
|
||||
}),
|
||||
transportConfig: { url: "https://github.example/mcp" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unified action permission dropdowns in the permissions tab", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Read only");
|
||||
expect(container.textContent).toContain("Can make changes");
|
||||
expect(container.textContent).toContain("Read repo");
|
||||
expect(container.textContent).toContain("Write issue");
|
||||
expect(container.textContent).toContain("1 new action to review");
|
||||
const readSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Read repo permission"]');
|
||||
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
|
||||
expect(readSelect?.value).toBe("allowed");
|
||||
expect(writeSelect?.value).toBe("ask");
|
||||
});
|
||||
|
||||
it("persists ask-first for read-only actions from the unified dropdown", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
const readSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Read repo permission"]');
|
||||
expect(readSelect).toBeTruthy();
|
||||
await act(async () => {
|
||||
readSelect!.value = "ask";
|
||||
readSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]),
|
||||
askFirstCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]),
|
||||
access: "all_agents",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists off by removing an action from enabled and ask-first sets", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
const writeSelect = container.querySelector<HTMLSelectElement>('select[aria-label="Write issue permission"]');
|
||||
expect(writeSelect).toBeTruthy();
|
||||
await act(async () => {
|
||||
writeSelect!.value = "off";
|
||||
writeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["catalog-read"],
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: "all_agents",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists installed agents from the permissions tab", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Installed on agents");
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Choose agents to install on"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const coderCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Coder"]');
|
||||
expect(coderCheckbox).toBeTruthy();
|
||||
await act(async () => {
|
||||
coderCheckbox!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
|
||||
{ targetType: "agent", targetId: "agent-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders activity attribution with issue context and human resolver names", async () => {
|
||||
mockParams.tab = "activity";
|
||||
listConnectionActivityMock.mockResolvedValue({
|
||||
events: [
|
||||
{
|
||||
id: "evt-1",
|
||||
eventType: "call_completed",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
actionRequestId: null,
|
||||
toolName: "Get value",
|
||||
outcome: "success",
|
||||
createdAt: new Date("2026-06-12T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
eventType: "approval_resolved",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
actionRequestId: "request-1",
|
||||
toolName: "Mark done",
|
||||
outcome: "success",
|
||||
createdAt: new Date("2026-06-12T10:01:00Z"),
|
||||
},
|
||||
],
|
||||
issues: {
|
||||
"issue-1": { identifier: "PAP-10912", title: "Fix app connection copy" },
|
||||
},
|
||||
actionRequests: {
|
||||
"request-1": {
|
||||
status: "approved",
|
||||
resolverDisplayName: "Dotta",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: "board-user",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Coder used Get value");
|
||||
expect(container.textContent).toContain("while working on PAP-10912");
|
||||
expect(container.textContent).toContain("Dotta approved Mark done");
|
||||
expect(container.querySelector('a[href="/issues/PAP-10912"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("You reviewed");
|
||||
});
|
||||
|
||||
it("humanizes the raw gateway-prefixed tool name in activity", async () => {
|
||||
mockParams.tab = "activity";
|
||||
listConnectionActivityMock.mockResolvedValue({
|
||||
events: [
|
||||
{
|
||||
id: "evt-1",
|
||||
eventType: "call_completed",
|
||||
agentId: "agent-1",
|
||||
issueId: null,
|
||||
actionRequestId: null,
|
||||
toolName: "mcp.app-gallery-link-ccad39e8-6798a369:kv-set",
|
||||
outcome: "success",
|
||||
createdAt: new Date("2026-06-12T10:00:00Z"),
|
||||
},
|
||||
],
|
||||
issues: {},
|
||||
actionRequests: {},
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Coder used Kv Set");
|
||||
expect(container.textContent).not.toContain("mcp.app-gallery-link");
|
||||
});
|
||||
|
||||
it("renders connection lifecycle events humanized on the timeline", async () => {
|
||||
mockParams.tab = "activity";
|
||||
listConnectionActivityMock.mockResolvedValue({
|
||||
events: [
|
||||
{
|
||||
id: "evt-1",
|
||||
eventType: "call_completed",
|
||||
agentId: "agent-1",
|
||||
issueId: null,
|
||||
actionRequestId: null,
|
||||
toolName: "Get value",
|
||||
outcome: "success",
|
||||
createdAt: new Date("2026-06-12T10:30:00Z"),
|
||||
},
|
||||
],
|
||||
lifecycleEvents: [
|
||||
{
|
||||
id: "life-connected",
|
||||
connectionId: "conn-1",
|
||||
type: "app_connected",
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
actorDisplayName: "Dotta",
|
||||
details: null,
|
||||
createdAt: new Date("2026-06-12T09:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "life-paused",
|
||||
connectionId: "conn-1",
|
||||
type: "app_paused",
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
actorDisplayName: "Dotta",
|
||||
details: { enabled: false },
|
||||
createdAt: new Date("2026-06-12T11:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "life-allowlist",
|
||||
connectionId: "conn-1",
|
||||
type: "allowlist_changed",
|
||||
actorType: "user",
|
||||
actorId: "board-user",
|
||||
agentId: null,
|
||||
actorDisplayName: "Dotta",
|
||||
details: { added: 1, removed: 0, total: 2 },
|
||||
createdAt: new Date("2026-06-12T10:45:00Z"),
|
||||
},
|
||||
{
|
||||
id: "life-quarantine",
|
||||
connectionId: "conn-1",
|
||||
type: "actions_quarantined",
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
agentId: null,
|
||||
actorDisplayName: null,
|
||||
details: { count: 2 },
|
||||
createdAt: new Date("2026-06-12T10:50:00Z"),
|
||||
},
|
||||
],
|
||||
issues: {},
|
||||
actionRequests: {},
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Dotta connected GitHub");
|
||||
expect(container.textContent).toContain("Dotta paused this app");
|
||||
expect(container.textContent).toContain("Dotta added 1 sheet to the allowlist");
|
||||
expect(container.textContent).toContain("2 new actions need review");
|
||||
// Lifecycle rows deep-link to the Setup tab; quarantine uses the review label.
|
||||
expect(container.querySelector('a[href="/apps/conn-1/setup"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain("Review in Setup");
|
||||
|
||||
// Merged timeline: the newest event (the pause at 11:00) renders before the
|
||||
// tool call at 10:30, which renders before the connect at 09:00.
|
||||
const pausedAt = container.textContent?.indexOf("Dotta paused this app") ?? -1;
|
||||
const usedAt = container.textContent?.indexOf("Coder used Get value") ?? -1;
|
||||
const connectedAt = container.textContent?.indexOf("Dotta connected GitHub") ?? -1;
|
||||
expect(pausedAt).toBeGreaterThanOrEqual(0);
|
||||
expect(pausedAt).toBeLessThan(usedAt);
|
||||
expect(usedAt).toBeLessThan(connectedAt);
|
||||
});
|
||||
|
||||
it("keeps the header and reconnect banner across tabs", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
healthStatus: "degraded",
|
||||
healthMessage: "Token expired.",
|
||||
}));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("Needs attention");
|
||||
expect(container.textContent).toContain("This app needs reconnecting");
|
||||
expect(container.textContent).toContain("Token expired.");
|
||||
expect(container.textContent).toContain("Who can use it");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Pencil } from "lucide-react";
|
||||
import type {
|
||||
AppGalleryEntry,
|
||||
ToolConnection,
|
||||
ToolPolicy,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
connectionDisplaySecondaryHint,
|
||||
humanizeConnectionDisplayName,
|
||||
isToolConnectionAttentionHealth as isAttentionHealthStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { Navigate, useParams, useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { buildCompanyUserLabelMap } from "@/lib/company-members";
|
||||
import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
|
||||
import { SetupPanel } from "./app-detail/SetupPanel";
|
||||
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
|
||||
import { TestPanel } from "./app-detail/TestPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
import { ActivityPanel } from "./app-detail/ActivityPanel";
|
||||
import {
|
||||
AdvancedPanel,
|
||||
ReconnectCard,
|
||||
DangerZone,
|
||||
connectionAddress,
|
||||
connectionTransportLabel,
|
||||
} from "./app-detail/AdvancedPanel";
|
||||
import type { AccessDraft } from "./app-detail/types";
|
||||
|
||||
export { DangerZone, connectionAddress, connectionTransportLabel };
|
||||
|
||||
export function AppDetail() {
|
||||
const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connection(connectionId),
|
||||
queryFn: () => toolsApi.getConnection(connectionId),
|
||||
enabled: !!connectionId && !!activeTab,
|
||||
});
|
||||
const installsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionInstalls(connectionId),
|
||||
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
|
||||
enabled: !!connectionId && activeTab === "permissions",
|
||||
});
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: queryKeys.tools.catalog(connectionId),
|
||||
queryFn: () => toolsApi.listCatalog(connectionId),
|
||||
enabled: !!connectionId && !!activeTab,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const policiesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(connectionId),
|
||||
queryFn: () => toolsApi.listConnectionActivity(connectionId, 20),
|
||||
enabled: !!connectionId && activeTab === "activity",
|
||||
});
|
||||
// Resolve who ran Test-tab calls ("<User> tested as <Agent>") in the Activity feed (PAP-11415).
|
||||
const userDirectoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && activeTab === "activity",
|
||||
});
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: activeTab === "activity",
|
||||
});
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const appName = connection ? humanizeConnectionDisplayName(connection) : "App";
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: appName, href: appTabHref(connectionId, "setup") },
|
||||
{ label: appTabLabel(activeTab) },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, appName, connectionId, activeTab]);
|
||||
|
||||
const catalog = catalogQuery.data?.catalog ?? [];
|
||||
const profile = useMemo(
|
||||
() => (profilesQuery.data?.profiles ?? []).find((p) => p.profileKey === `app:${connectionId}`),
|
||||
[profilesQuery.data, connectionId],
|
||||
);
|
||||
const enabledIds = useMemo(() => enabledCatalogIds(profile), [profile]);
|
||||
const askFirstIds = useMemo(
|
||||
() => askFirstCatalogIds(policiesQuery.data?.policies ?? [], connectionId),
|
||||
[policiesQuery.data, connectionId],
|
||||
);
|
||||
const access = useMemo(() => accessFrom(profile), [profile]);
|
||||
const install = useMemo(
|
||||
() => installStateFrom(installsQuery.data?.installs ?? connection?.installs),
|
||||
[connection?.installs, installsQuery.data?.installs],
|
||||
);
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const userLabelById = useMemo(() => {
|
||||
const labels = buildCompanyUserLabelMap(userDirectoryQuery.data?.users);
|
||||
const session = sessionQuery.data;
|
||||
// Prefer the viewer's own profile name for their own test runs ("Dotta", not a fallback).
|
||||
if (session?.user?.id && session.user.name?.trim()) {
|
||||
labels.set(session.user.id, session.user.name.trim());
|
||||
}
|
||||
return labels;
|
||||
}, [userDirectoryQuery.data, sessionQuery.data]);
|
||||
const logoEntry = useMemo(
|
||||
() => galleryEntryFor(galleryQuery.data?.apps ?? [], connection),
|
||||
[galleryQuery.data, connection],
|
||||
);
|
||||
|
||||
const [pending, setPending] = useState(false);
|
||||
const persist = useMutation({
|
||||
mutationFn: (next: { enabled: Set<string>; askFirst: Set<string>; access: AccessDraft }) =>
|
||||
toolsApi.finishApp(selectedCompanyId!, connectionId, {
|
||||
enabledCatalogEntryIds: [...next.enabled],
|
||||
askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)),
|
||||
access: next.access.mode === "all" ? "all_agents" : { agentIds: [...next.access.agentIds] },
|
||||
}),
|
||||
onMutate: () => setPending(true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't save that",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
onSettled: () => setPending(false),
|
||||
});
|
||||
|
||||
const persistInstall = useMutation({
|
||||
mutationFn: (next: InstallState) =>
|
||||
toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, next)),
|
||||
onSuccess: (snapshot) => {
|
||||
queryClient.setQueryData(queryKeys.tools.connectionInstalls(connectionId), snapshot);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't save installs",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const rename = useMutation({
|
||||
mutationFn: (name: string) => toolsApi.updateConnection(connectionId, { name }),
|
||||
onSuccess: () => {
|
||||
setRenaming(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't rename the app",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const updateConfig = useMutation({
|
||||
mutationFn: (config: Record<string, unknown>) => toolsApi.updateConnection(connectionId, {
|
||||
config,
|
||||
transportConfig: connection?.transportConfig ?? {},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't save that",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const startOAuth = useMutation({
|
||||
mutationFn: () => toolsApi.startOAuth(connectionId),
|
||||
onSuccess: ({ authorizationUrl }) => {
|
||||
window.location.assign(authorizationUrl);
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't start sign-in",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const removeApp = useMutation({
|
||||
mutationFn: () => toolsApi.archiveConnection(connectionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
pushToast({
|
||||
title: "App removed",
|
||||
body: `${appName} no longer has access. You can connect it again any time.`,
|
||||
tone: "success",
|
||||
});
|
||||
navigate("/apps");
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't remove the app",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const toggleEnabled = useMutation({
|
||||
mutationFn: () => toolsApi.updateConnection(connectionId, { enabled: !connection?.enabled }),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
pushToast({
|
||||
title: updated.enabled ? "App resumed" : "App paused",
|
||||
body: updated.enabled
|
||||
? `${humanizeConnectionDisplayName(updated)} is available to agents again.`
|
||||
: `${humanizeConnectionDisplayName(updated)} is paused for agents.`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't update the app",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const refreshTools = useMutation({
|
||||
mutationFn: () => toolsApi.refreshCatalog(connectionId),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
pushToast({
|
||||
title: `Found ${result.discoveredCount} ${result.discoveredCount === 1 ? "action" : "actions"}`,
|
||||
body: result.quarantinedCount > 0
|
||||
? `${result.quarantinedCount} new ${result.quarantinedCount === 1 ? "action needs" : "actions need"} your OK.`
|
||||
: undefined,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't refresh actions",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const apply = (mutate: { enabled?: Set<string>; askFirst?: Set<string>; access?: AccessDraft }) =>
|
||||
persist.mutate({
|
||||
enabled: mutate.enabled ?? new Set(enabledIds),
|
||||
askFirst: mutate.askFirst ?? new Set(askFirstIds),
|
||||
access: mutate.access ?? access,
|
||||
});
|
||||
|
||||
if (!connectionId || !activeTab) {
|
||||
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps"} />;
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
|
||||
}
|
||||
if (connectionQuery.isLoading || catalogQuery.isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!connection) {
|
||||
return (
|
||||
<div className="max-w-3xl p-6">
|
||||
<p className="text-sm text-muted-foreground">We couldn't find that app.</p>
|
||||
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps")}>
|
||||
Back to apps
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = statusFor(connection);
|
||||
const needsReconnect = status.tone === "attention" && connection.healthStatus !== "unknown";
|
||||
const quarantined = catalog.filter((e) => e.status === "quarantined");
|
||||
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
|
||||
const readOnly = active.filter((e) => e.isReadOnly);
|
||||
const canChange = active.filter((e) => !e.isReadOnly);
|
||||
const actionCount = active.length;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 pb-12">
|
||||
<AppDetailHeader
|
||||
appName={appName}
|
||||
connection={connection}
|
||||
logoEntry={logoEntry}
|
||||
status={status}
|
||||
actionCount={actionCount}
|
||||
renaming={renaming}
|
||||
nameDraft={nameDraft}
|
||||
renamePending={rename.isPending}
|
||||
onNameDraftChange={setNameDraft}
|
||||
onRenameStart={() => {
|
||||
setNameDraft(appName);
|
||||
setRenaming(true);
|
||||
}}
|
||||
onRenameCancel={() => setRenaming(false)}
|
||||
onRenameSubmit={(next) => {
|
||||
if (next && next !== appName) rename.mutate(next);
|
||||
else setRenaming(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{needsReconnect && (
|
||||
<ReconnectCard
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
onReconnected={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "setup" && (
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
|
||||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
oauthStartDisabled={startOAuth.isPending}
|
||||
onStartOAuth={() => startOAuth.mutate()}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "review" && (
|
||||
<ReviewPanel
|
||||
connectionId={connectionId}
|
||||
quarantined={quarantined}
|
||||
pending={pending}
|
||||
onTurnOnQuarantined={(ids) => apply({ enabled: addAll(new Set(enabledIds), ids) })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "permissions" && (
|
||||
<PermissionsPanel
|
||||
appName={appName}
|
||||
access={access}
|
||||
agents={agents}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
pending={pending}
|
||||
installPending={persistInstall.isPending || installsQuery.isLoading}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: next })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onTurnOnQuarantined={(ids) => apply({ enabled: addAll(new Set(enabledIds), ids) })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "test" && (
|
||||
<TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
<ActivityPanel
|
||||
events={activityQuery.data?.events ?? []}
|
||||
lifecycleEvents={activityQuery.data?.lifecycleEvents ?? []}
|
||||
issues={activityQuery.data?.issues ?? {}}
|
||||
actionRequests={activityQuery.data?.actionRequests ?? {}}
|
||||
loading={activityQuery.isLoading}
|
||||
agents={agents}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
userLabelById={userLabelById}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "advanced" && (
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName={appName}
|
||||
galleryEntry={logoEntry}
|
||||
removing={removeApp.isPending}
|
||||
onRemove={() => removeApp.mutate()}
|
||||
onReplaced={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppDetailHeader({
|
||||
appName,
|
||||
connection,
|
||||
logoEntry,
|
||||
status,
|
||||
actionCount,
|
||||
renaming,
|
||||
nameDraft,
|
||||
renamePending,
|
||||
onNameDraftChange,
|
||||
onRenameStart,
|
||||
onRenameCancel,
|
||||
onRenameSubmit,
|
||||
}: {
|
||||
appName: string;
|
||||
connection: ToolConnection;
|
||||
logoEntry: AppGalleryEntry | null;
|
||||
status: StatusInfo;
|
||||
actionCount: number;
|
||||
renaming: boolean;
|
||||
nameDraft: string;
|
||||
renamePending: boolean;
|
||||
onNameDraftChange: (value: string) => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameCancel: () => void;
|
||||
onRenameSubmit: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={44} />
|
||||
<div>
|
||||
{renaming ? (
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onRenameSubmit(nameDraft.trim());
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="App name"
|
||||
value={nameDraft}
|
||||
onChange={(event) => onNameDraftChange(event.target.value)}
|
||||
className="h-9 w-64 text-lg font-bold"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={renamePending || !nameDraft.trim()}>
|
||||
{renamePending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onRenameCancel} disabled={renamePending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{appName}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
aria-label="Rename app"
|
||||
onClick={onRenameStart}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{connectionDisplaySecondaryHint(connection) && (
|
||||
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{actionCount} {actionCount === 1 ? "action" : "actions"} available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusInfo = { label: string; tone: "connected" | "attention" | "paused" };
|
||||
|
||||
function statusFor(connection: ToolConnection): StatusInfo {
|
||||
if (connection.enabled === false || connection.status === "disabled") {
|
||||
return { label: "Paused", tone: "paused" };
|
||||
}
|
||||
if (isAttentionHealthStatus(connection.healthStatus)) {
|
||||
return { label: "Needs attention", tone: "attention" };
|
||||
}
|
||||
return { label: "Connected", tone: "connected" };
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: StatusInfo }) {
|
||||
const klass: Record<StatusInfo["tone"], string> = {
|
||||
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
paused: "border-border bg-muted text-muted-foreground",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
klass[status.tone],
|
||||
)}
|
||||
>
|
||||
{status.tone === "connected" && <Check className="h-3 w-3" />}
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of profile?.entries ?? []) {
|
||||
if (entry.effect === "include" && entry.catalogEntryId) ids.add(entry.catalogEntryId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const policy of policies) {
|
||||
if (policy.policyType !== "require_approval" || policy.enabled === false) continue;
|
||||
const config = (policy.config ?? {}) as { source?: unknown; connectionId?: unknown; catalogEntryId?: unknown };
|
||||
if (config.source === "app_gallery_finish" && config.connectionId === connectionId && typeof config.catalogEntryId === "string") {
|
||||
ids.add(config.catalogEntryId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function accessFrom(profile: ToolProfileWithDetails | undefined): AccessDraft {
|
||||
const bindings = profile?.bindings ?? [];
|
||||
if (bindings.some((b) => b.targetType === "company")) {
|
||||
return { mode: "all", agentIds: new Set() };
|
||||
}
|
||||
const agentIds = new Set(bindings.filter((b) => b.targetType === "agent").map((b) => b.targetId));
|
||||
if (agentIds.size === 0) return { mode: "all", agentIds: new Set() };
|
||||
return { mode: "specific", agentIds };
|
||||
}
|
||||
|
||||
function galleryEntryFor(apps: AppGalleryEntry[], connection: ToolConnection | undefined): AppGalleryEntry | null {
|
||||
if (!connection) return null;
|
||||
const name = connection.name.toLowerCase();
|
||||
return apps.find((a) => a.name.toLowerCase() === name) ?? apps.find((a) => a.key === name) ?? null;
|
||||
}
|
||||
|
||||
function addAll(set: Set<string>, ids: string[]): Set<string> {
|
||||
const next = new Set(set);
|
||||
for (const id of ids) next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
function actionPermissionMutation(
|
||||
id: string,
|
||||
next: "off" | "allowed" | "ask",
|
||||
enabledIds: Set<string>,
|
||||
askFirstIds: Set<string>,
|
||||
) {
|
||||
const enabled = new Set(enabledIds);
|
||||
const askFirst = new Set(askFirstIds);
|
||||
if (next === "off") {
|
||||
enabled.delete(id);
|
||||
askFirst.delete(id);
|
||||
} else {
|
||||
enabled.add(id);
|
||||
if (next === "ask") askFirst.add(id);
|
||||
else askFirst.delete(id);
|
||||
}
|
||||
return { enabled, askFirst };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TILE_COLORS = [
|
||||
"bg-(--app-logo-tile-1)",
|
||||
"bg-(--app-logo-tile-2)",
|
||||
"bg-(--app-logo-tile-3)",
|
||||
"bg-(--app-logo-tile-4)",
|
||||
"bg-(--app-logo-tile-5)",
|
||||
"bg-(--app-logo-tile-6)",
|
||||
"bg-(--app-logo-tile-7)",
|
||||
"bg-(--app-logo-tile-8)",
|
||||
];
|
||||
|
||||
function colorFor(seed: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return TILE_COLORS[hash % TILE_COLORS.length]!;
|
||||
}
|
||||
|
||||
interface AppLogoProps {
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App icon for the gallery and connected-apps surfaces. Renders the manifest
|
||||
* favicon when available, falling back to a coloured letter tile (deterministic
|
||||
* colour per app name) when the image is missing or fails to load.
|
||||
*/
|
||||
export function AppLogo({ name, logoUrl, size = 36, className }: AppLogoProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const letter = (name.trim()[0] ?? "?").toUpperCase();
|
||||
const dimension = { width: size, height: size };
|
||||
|
||||
if (logoUrl && !failed) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted", className)}
|
||||
style={dimension}
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg font-bold text-white",
|
||||
colorFor(name),
|
||||
className,
|
||||
)}
|
||||
style={{ ...dimension, fontSize: Math.round(size * 0.42) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppNotConnected } from "./AppNotConnected";
|
||||
|
||||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateApplicationMock = vi.hoisted(() => vi.fn());
|
||||
const mockAgentsList = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const navigateComponentMock = vi.hoisted(() => vi.fn());
|
||||
const mockParams = vi.hoisted(() => ({ applicationId: "app-1", tab: "setup" as string | undefined }));
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listApplications: (companyId: string) => listApplicationsMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
listConnectionActivity: (connectionId: string, limit: number) =>
|
||||
listConnectionActivityMock(connectionId, limit),
|
||||
listActionRequests: (companyId: string, status: string) =>
|
||||
listActionRequestsMock(companyId, status),
|
||||
updateApplication: (applicationId: string, input: unknown) =>
|
||||
updateApplicationMock(applicationId, input),
|
||||
approveActionRequest: vi.fn(),
|
||||
declineActionRequest: vi.fn(),
|
||||
createTrustRuleFromActionRequest: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: (companyId: string) => mockAgentsList(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useParams: () => mockParams,
|
||||
useNavigate: () => mockNavigate,
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
|
||||
navigateComponentMock({ to, replace });
|
||||
return <div data-navigate-to={to} />;
|
||||
},
|
||||
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function application(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "app-1",
|
||||
companyId: "company-1",
|
||||
applicationKey: "github",
|
||||
name: "GitHub",
|
||||
description: "Repository app",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
pluginId: null,
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
metadata: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conn-old",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
name: "GitHub",
|
||||
connectionKind: "managed",
|
||||
transport: "remote_http",
|
||||
status: "archived",
|
||||
transportConfig: { url: "https://github.example/mcp" },
|
||||
config: { url: "https://github.example/mcp" },
|
||||
credentialSecretRefs: [],
|
||||
credentialRefs: [],
|
||||
healthStatus: "error",
|
||||
healthMessage: "Token expired.",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
lastUsedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-11T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AppNotConnected", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.applicationId = "app-1";
|
||||
mockParams.tab = "setup";
|
||||
listApplicationsMock.mockResolvedValue({ applications: [application()] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }],
|
||||
});
|
||||
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
|
||||
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
|
||||
mockAgentsList.mockResolvedValue([]);
|
||||
updateApplicationMock.mockResolvedValue(application({ status: "archived" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppNotConnected />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("redirects the application root route to setup", async () => {
|
||||
mockParams.tab = undefined;
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/app/app-1/setup", replace: true });
|
||||
expect(listApplicationsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to the connected app tab when a live connection exists", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [connection({ id: "conn-live", status: "active" })],
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-live/permissions", replace: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["setup", "Reconnect this app"],
|
||||
["review", "Nothing is waiting for your OK right now."],
|
||||
["permissions", "Permissions paused"],
|
||||
["test", "Reconnect to test this app."],
|
||||
["activity", "No activity yet."],
|
||||
["advanced", "Danger zone"],
|
||||
])("renders the %s tab with persistent app identity", async (tab, expectedText) => {
|
||||
mockParams.tab = tab;
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("GitHub");
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(container.textContent).toContain(expectedText);
|
||||
});
|
||||
|
||||
it("keeps previous setup context on reconnect tabs", async () => {
|
||||
mockParams.tab = "setup";
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Previous setup");
|
||||
expect(container.textContent).toContain("Last error: Token expired.");
|
||||
expect(container.textContent).toContain("https://github.example/mcp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import { Navigate, useNavigate, useParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail";
|
||||
import { ActivityPanel } from "./app-detail/ActivityPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
import { appApplicationTabHref, appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
|
||||
|
||||
export function AppNotConnected() {
|
||||
const { applicationId = "", tab } = useParams<{ applicationId: string; tab?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
|
||||
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const connectionsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
|
||||
const application = useMemo(
|
||||
() => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId),
|
||||
[applicationsQuery.data, applicationId],
|
||||
);
|
||||
const appConnections = useMemo(
|
||||
() => (connectionsQuery.data?.connections ?? []).filter((c) => c.applicationId === applicationId),
|
||||
[connectionsQuery.data, applicationId],
|
||||
);
|
||||
const activeConnection = appConnections.find((c) => c.status !== "archived") ?? null;
|
||||
const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]);
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(previousConnection?.id ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnectionActivity(previousConnection!.id, 20),
|
||||
enabled: !!previousConnection && activeTab === "activity",
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && activeTab === "activity",
|
||||
});
|
||||
|
||||
const appName = application?.name ?? "App";
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: appName, href: appApplicationTabHref(applicationId, "setup") },
|
||||
{ label: appTabLabel(activeTab) },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, appName, applicationId, activeTab]);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => toolsApi.updateApplication(applicationId, { status: "archived" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__") });
|
||||
pushToast({
|
||||
title: "App removed",
|
||||
body: `${appName} no longer shows in your apps. You can connect it again any time.`,
|
||||
tone: "success",
|
||||
});
|
||||
navigate("/apps");
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Couldn’t remove the app",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
|
||||
}
|
||||
if (!applicationId || !activeTab) {
|
||||
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps"} replace />;
|
||||
}
|
||||
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!application) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-3 p-6 text-sm text-muted-foreground">
|
||||
<p>This app doesn’t exist anymore.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate("/apps")}>Back to apps</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activeConnection) {
|
||||
return <Navigate to={appTabHref(activeConnection.id, activeTab)} replace />;
|
||||
}
|
||||
|
||||
const gallery = galleryQuery.data?.apps ?? [];
|
||||
const logoUrl =
|
||||
(application.applicationKey ? gallery.find((entry) => entry.key === application.applicationKey)?.logoUrl : undefined) ??
|
||||
gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.logoUrl;
|
||||
|
||||
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
|
||||
const connectHref = reconnectHref({
|
||||
applicationId,
|
||||
appName: application.name,
|
||||
previousAddress,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 pb-12">
|
||||
<ApplicationHeader applicationName={application.name} description={application.description} logoUrl={logoUrl} />
|
||||
|
||||
{activeTab === "setup" && (
|
||||
<SetupTab
|
||||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
onConnect={() => navigate(connectHref)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "review" && (
|
||||
previousConnection ? (
|
||||
<ReviewPanel connectionId={previousConnection.id} />
|
||||
) : (
|
||||
<EmptyTab
|
||||
title="Nothing is waiting for your OK right now."
|
||||
body="Review requests will appear here after this app is connected."
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === "permissions" && (
|
||||
<PermissionsTab previousConnection={previousConnection} />
|
||||
)}
|
||||
{activeTab === "test" && (
|
||||
<EmptyTab
|
||||
title="Reconnect to test this app."
|
||||
body="Testing becomes available after this app is connected again."
|
||||
/>
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
previousConnection ? (
|
||||
<ActivityPanel
|
||||
events={activityQuery.data?.events ?? []}
|
||||
lifecycleEvents={activityQuery.data?.lifecycleEvents ?? []}
|
||||
issues={activityQuery.data?.issues ?? {}}
|
||||
actionRequests={activityQuery.data?.actionRequests ?? {}}
|
||||
loading={activityQuery.isLoading}
|
||||
agents={agentsQuery.data ?? []}
|
||||
connectionId={previousConnection.id}
|
||||
appName={appName}
|
||||
/>
|
||||
) : (
|
||||
<ActivityPanel
|
||||
events={[]}
|
||||
lifecycleEvents={[]}
|
||||
issues={{}}
|
||||
actionRequests={{}}
|
||||
loading={false}
|
||||
agents={[]}
|
||||
connectionId=""
|
||||
appName={appName}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === "advanced" && (
|
||||
<AdvancedTab
|
||||
appName={application.name}
|
||||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
removing={remove.isPending}
|
||||
onRemove={() => remove.mutate()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplicationHeader({
|
||||
applicationName,
|
||||
description,
|
||||
logoUrl,
|
||||
}: {
|
||||
applicationName: string;
|
||||
description: string | null;
|
||||
logoUrl: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-center gap-4">
|
||||
<AppLogo name={applicationName} logoUrl={logoUrl} size={48} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-2xl font-bold tracking-tight">{applicationName}</h1>
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Not connected
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupTab({
|
||||
previousConnection,
|
||||
previousAddress,
|
||||
onConnect,
|
||||
}: {
|
||||
previousConnection: ToolConnection | null;
|
||||
previousAddress: string | null;
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
{previousConnection ? "Reconnect this app" : "Connect this app"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{previousConnection
|
||||
? "We kept the previous setup. Add a working key to bring it back online."
|
||||
: "Agents can't use it until it's connected."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onConnect}>
|
||||
{previousConnection ? "Reconnect" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{previousConnection && (
|
||||
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviousSetup({
|
||||
connection,
|
||||
previousAddress,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
previousAddress: string | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<h2 className="text-sm font-bold text-foreground">Previous setup</h2>
|
||||
{connection.healthMessage && (
|
||||
<p className="mt-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Last error: {connection.healthMessage}
|
||||
</p>
|
||||
)}
|
||||
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
<dd className="break-all font-mono text-foreground">{previousAddress}</dd>
|
||||
<dt className="text-muted-foreground">Connection type</dt>
|
||||
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
|
||||
<dt className="text-muted-foreground">Last used</dt>
|
||||
<dd className="text-foreground">
|
||||
{connection.lastUsedAt ? timeAgo(connection.lastUsedAt) : "Never"}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<h2 className="text-sm font-bold text-foreground">Permissions paused</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Reconnect this app to edit who can use it and which actions need a human first.
|
||||
</p>
|
||||
{previousConnection && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Previous setup is retained for reconnect, but access controls stay read-only until the app is online.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedTab({
|
||||
appName,
|
||||
previousConnection,
|
||||
previousAddress,
|
||||
removing,
|
||||
onRemove,
|
||||
}: {
|
||||
appName: string;
|
||||
previousConnection: ToolConnection | null;
|
||||
previousAddress: string | null;
|
||||
removing: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{previousConnection ? (
|
||||
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
|
||||
) : (
|
||||
<EmptyTab
|
||||
title="No previous connection details"
|
||||
body="Technical details will appear here after this app is connected."
|
||||
/>
|
||||
)}
|
||||
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTab({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<h2 className="text-sm font-bold text-foreground">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
|
||||
const archived = connections.filter((c) => c.status === "archived");
|
||||
if (archived.length === 0) return null;
|
||||
return archived.reduce((latest, connection) => {
|
||||
const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime();
|
||||
const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime();
|
||||
return connectionTime > latestTime ? connection : latest;
|
||||
});
|
||||
}
|
||||
|
||||
function reconnectHref({
|
||||
applicationId,
|
||||
appName,
|
||||
previousAddress,
|
||||
}: {
|
||||
applicationId: string;
|
||||
appName: string;
|
||||
previousAddress: string | null;
|
||||
}): string {
|
||||
const params = new URLSearchParams({ byo: "1", applicationId, name: appName });
|
||||
if (previousAddress && /^https?:\/\//i.test(previousAddress)) params.set("link", previousAddress);
|
||||
return `/apps/connect?${params.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppsConnect } from "./AppsConnect";
|
||||
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const connectAppMock = vi.hoisted(() => vi.fn());
|
||||
const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSearch = vi.hoisted(() => ({ value: "" }));
|
||||
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
|
||||
finishApp: (companyId: string, connectionId: string, input: unknown) =>
|
||||
finishAppMock(companyId, connectionId, input),
|
||||
putConnectionInstalls: (connectionId: string, installs: unknown) =>
|
||||
putConnectionInstallsMock(connectionId, installs),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => mockParams,
|
||||
useSearchParams: () => [new URLSearchParams(mockSearch.value), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.body.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === text,
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function buttonContaining(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.body.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes(text),
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
async function gotoLinkFrame(container: HTMLDivElement, url: string) {
|
||||
const linkInput = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).find((i) => i.getAttribute("placeholder")?.startsWith("https://"));
|
||||
expect(linkInput).toBeTruthy();
|
||||
await act(async () => setInputValue(linkInput!, url));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearch.value = "";
|
||||
mockParams.appKey = undefined;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Automate things",
|
||||
authKind: "api_key",
|
||||
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
|
||||
logoUrl: null,
|
||||
credentialFields: [{ configPath: "credentials.authorization", label: "API key", required: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
finishAppMock.mockResolvedValue({});
|
||||
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
connectAppMock.mockResolvedValue({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "example.com" },
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
});
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{ id: "agent-1", name: "Ada", title: "CTO", status: "active", icon: "Bot" },
|
||||
{ id: "agent-2", name: "Grace", title: "Engineer", status: "active", icon: "Code" },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppsConnect />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
expect(container.textContent).toContain("Does it need a key?");
|
||||
expect(buttonByText("No")).toBeTruthy();
|
||||
expect(buttonByText("Yes")).toBeTruthy();
|
||||
|
||||
// Name is auto-filled from the host with www. stripped.
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
expect(nameInput?.value).toBe("example.com");
|
||||
});
|
||||
|
||||
it("opens the selected app directly on its setup route", async () => {
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
});
|
||||
|
||||
it("choosing No and clicking Check link connects with no credentials", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({ link: "https://www.example.com/actions", name: "example.com" });
|
||||
expect(input.credentialValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("choosing Yes reveals one masked key field plus the lock reassurance", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
// No key field while No is selected.
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLInputElement>("input")).some(
|
||||
(i) => i.type === "password",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Yes")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const passwordInputs = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).filter((i) => i.type === "password");
|
||||
expect(passwordInputs).toHaveLength(1);
|
||||
expect(container.textContent).toContain("Your key is stored securely.");
|
||||
|
||||
await act(async () => setInputValue(passwordInputs[0], "secret-key"));
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input.credentialValues).toEqual({ "credentials.authorization": "secret-key" });
|
||||
});
|
||||
|
||||
it("a Zapier MCP URL stays in the URL flow and includes its token in the submitted link", async () => {
|
||||
await render();
|
||||
|
||||
const linkInput = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
).find((i) => i.getAttribute("placeholder")?.startsWith("https://"));
|
||||
const zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=secret-token";
|
||||
await act(async () => setInputValue(linkInput!, zapierUrl));
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This looks like Zapier.");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain(zapierUrl);
|
||||
expect(nameInputFrom(container)?.value).toBe("Zapier");
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
|
||||
expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Zapier visible and uses the compact agent multi-selector throughout its wizard", async () => {
|
||||
mockSearch.value = "byo=1&source=zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Automate things",
|
||||
authKind: "api_key",
|
||||
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
|
||||
logoUrl: "https://example.com/zapier.png",
|
||||
credentialFields: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
connectAppMock.mockResolvedValueOnce({
|
||||
connectionId: "conn-1",
|
||||
application: { id: "app-1", name: "Zapier" },
|
||||
actions: {
|
||||
readOnly: [
|
||||
{
|
||||
catalogEntryId: "action-1",
|
||||
toolName: "find_record",
|
||||
title: "Find record",
|
||||
description: "Find a record.",
|
||||
riskLevel: "read",
|
||||
},
|
||||
],
|
||||
canMakeChanges: [],
|
||||
},
|
||||
catalog: [],
|
||||
suggestedDefaults: {},
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Step 1 of 4");
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(container.textContent).not.toContain("More ways to connect");
|
||||
|
||||
const linkInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder^="https://mcp.zapier.com"]',
|
||||
);
|
||||
const zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=secret-token";
|
||||
await act(async () => setInputValue(linkInput!, zapierUrl));
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
|
||||
expect(container.textContent).toContain("Step 2 of 4");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue with 1 action on")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 3 of 4");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Only specific agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Select agents");
|
||||
expect(container.textContent).not.toContain("Ada");
|
||||
expect(container.textContent).not.toContain("Grace");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Ada");
|
||||
expect(document.body.textContent).toContain("Grace");
|
||||
|
||||
const adaCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]');
|
||||
await act(async () => {
|
||||
adaCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Done")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("1 agent selected");
|
||||
expect(container.textContent).not.toContain("Grace");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue to install")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 4 of 4");
|
||||
expect(container.textContent).toContain("Install Zapier tools?");
|
||||
expect(container.textContent).toContain("Not yet");
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Specific agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("1 agent selected");
|
||||
await act(async () => {
|
||||
buttonByText("Finish setup")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["action-1"],
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: { agentIds: ["agent-1"] },
|
||||
});
|
||||
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
|
||||
{ targetType: "agent", targetId: "agent-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
// PAP-10922: "Run your own" / "Paste a config" moved from the sidebar to rows
|
||||
// under "Connect with a link" on the gallery step.
|
||||
it("offers 'Run your own' and 'Paste a config' rows that route into the Advanced door", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("More ways to connect");
|
||||
|
||||
const buttonContaining = (text: string) =>
|
||||
Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Run your own")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced");
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Paste a config")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced/paste-config");
|
||||
});
|
||||
|
||||
// PAP-11091: discoverability copy for remote MCP URLs — the link field
|
||||
// advertises that any remote tool URL (incl. a local MCP server) works. Per
|
||||
// the UX re-review, the localhost example lives in the body copy (legible at
|
||||
// every viewport) rather than the placeholder, which truncated on mobile.
|
||||
it("advertises that remote/local MCP URLs work under 'Connect with a link'", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Any remote tool URL works here — including a local MCP server like",
|
||||
);
|
||||
expect(container.textContent).toContain("http://127.0.0.1:8848/mcp");
|
||||
const linkInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find((i) =>
|
||||
i.getAttribute("placeholder")?.startsWith("https://example.com/actions"),
|
||||
);
|
||||
// Placeholder stays a single, short example so it never truncates.
|
||||
expect(linkInput?.getAttribute("placeholder")).toBe("https://example.com/actions");
|
||||
});
|
||||
|
||||
// Reconnect from the app page: ?link/?name/?applicationId prefill skips the
|
||||
// gallery and re-attaches the connection to the existing application.
|
||||
it("prefills the link frame from search params and passes applicationId to connect", async () => {
|
||||
mockSearch.value =
|
||||
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Connect with a link");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
const nameInput = Array.from(container.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
expect(nameInput?.value).toBe("Bla");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
link: "https://www.example.com/actions",
|
||||
name: "Bla",
|
||||
applicationId: "app-77",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Share each sheet with this email");
|
||||
expect(container.textContent).toContain("robot@paperclip.iam.gserviceaccount.com");
|
||||
expect(container.textContent).toContain(
|
||||
"In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.",
|
||||
);
|
||||
expect(buttonByText("Connect")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows inline validation for invalid Google Sheets links", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
await act(async () => setTextareaValue(textarea!, "https://example.com/not-a-sheet"));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("That doesn't look like a Google Sheets link.");
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// PAP-11283: the gallery step exposes a Name field (default = app name) so a
|
||||
// connection can be named at create time, matching the link flow.
|
||||
function nameInputFrom(root: HTMLDivElement): HTMLInputElement | undefined {
|
||||
return Array.from(root.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
}
|
||||
|
||||
it("gallery key step defaults the Name field to the app name", async () => {
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(nameInputFrom(container)?.value).toBe("Zapier");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=setup");
|
||||
});
|
||||
|
||||
it("returns from an app key step to the BYO gallery", async () => {
|
||||
mockSearch.value = "byo=1";
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
});
|
||||
|
||||
it("leaving the default name connects with the app name", async () => {
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
await act(async () => setInputValue(keyField!, "secret-key"));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=actions");
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier" });
|
||||
});
|
||||
|
||||
it("a custom name in the gallery step is sent to the connect mutation", async () => {
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => setInputValue(nameInputFrom(container)!, "Zapier (stdio smoke)"));
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
await act(async () => setInputValue(keyField!, "secret-key"));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({ galleryKey: "zapier", name: "Zapier (stdio smoke)" });
|
||||
});
|
||||
|
||||
it("a custom name on the Google Sheets step is sent to the connect mutation", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Default is the app name.
|
||||
expect(nameInputFrom(container)?.value).toBe("Google Sheets");
|
||||
await act(async () => setInputValue(nameInputFrom(container)!, "Google Sheets (stdio smoke)"));
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
await act(async () =>
|
||||
setTextareaValue(textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit"),
|
||||
);
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
galleryKey: "google-sheets",
|
||||
name: "Google Sheets (stdio smoke)",
|
||||
configValues: { allowedSpreadsheetIds: ["sheet_123"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes parsed Google Sheets IDs as connection config values", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
await act(async () =>
|
||||
setTextareaValue(
|
||||
textarea!,
|
||||
"https://docs.google.com/spreadsheets/d/sheet_123/edit\nhttps://docs.google.com/spreadsheets/d/sheet_456",
|
||||
)
|
||||
);
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
const [, input] = connectAppMock.mock.calls[0];
|
||||
expect(input).toMatchObject({
|
||||
galleryKey: "google-sheets",
|
||||
configValues: { allowedSpreadsheetIds: ["sheet_123", "sheet_456"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from "react";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { ReviewQueueCard } from "./ReviewQueueCard";
|
||||
|
||||
/**
|
||||
* Review — the "decisions waiting on you" inbox (PAP-12371, Finding B).
|
||||
*
|
||||
* Ask-first approvals used to live only inside the "Needs attention" page,
|
||||
* folded together with health/error triage. That buried the one thing a user
|
||||
* must act on for their agents to proceed. This is the explicit, top-level
|
||||
* home for those approvals, aligned with the Inbox "waiting for your OK"
|
||||
* language from the approved PAP-11178 gateway UX. Health issues stay on
|
||||
* "Needs attention"; decisions live here.
|
||||
*/
|
||||
export function AppsReview() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Review" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to review approvals.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 pb-12">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Review</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Actions your agents want to run that need your OK first. Approve, always-allow, or decline.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ReviewQueueCard emptyState="reassure" heading="Waiting for your OK" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Browse } from "./Browse";
|
||||
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function galleryEntry(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
logoUrl: "https://example.com/github.png",
|
||||
tagline: "Let agents open PRs and issues.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: { transport: "remote_http", url: "https://api.github.com/mcp" },
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {},
|
||||
urlPatterns: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Browse store door (PAP-13254 door 1)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
galleryEntry({ key: "zapier", name: "Zapier", tagline: "Connect automations." }),
|
||||
galleryEntry({ key: "github", name: "GitHub", tagline: "Open PRs and issues." }),
|
||||
galleryEntry({ key: "slack", name: "Slack", tagline: "Post messages to channels." }),
|
||||
galleryEntry({ key: "acme", name: "Acme CRM", tagline: "Sync deals and contacts." }),
|
||||
],
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderBrowse() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Browse />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders the store header, popular grid, gallery, and BYO card", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Browse");
|
||||
expect(text).toContain("Connect Zapier or your own MCP server.");
|
||||
expect(text).toContain("Popular");
|
||||
expect(text).toContain("All apps");
|
||||
expect(text).toContain("GitHub");
|
||||
expect(text).toContain("Slack");
|
||||
expect(text).toContain("Acme CRM");
|
||||
// Bring-your-own is a first-class row in the store.
|
||||
expect(text).toContain("Connect your own tool");
|
||||
});
|
||||
|
||||
it("enables Zapier and custom URLs while fading unfinished integrations", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const zapierTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Zapier"),
|
||||
);
|
||||
const githubTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("GitHub"),
|
||||
);
|
||||
const tile = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Acme CRM"),
|
||||
);
|
||||
const byoCard = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Connect your own tool"),
|
||||
);
|
||||
|
||||
expect(zapierTiles).toHaveLength(2);
|
||||
expect(zapierTiles.every((button) => !button.disabled)).toBe(true);
|
||||
expect(githubTiles.every((button) => button.disabled)).toBe(true);
|
||||
expect(tile?.disabled).toBe(true);
|
||||
expect(byoCard?.disabled).toBe(false);
|
||||
expect(tile?.textContent).toContain("Coming soon");
|
||||
expect(zapierTiles[0]?.textContent).toContain("Connect");
|
||||
|
||||
await act(async () => {
|
||||
zapierTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&source=zapier");
|
||||
|
||||
await act(async () => {
|
||||
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
});
|
||||
|
||||
it("filters the gallery by the search query", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, "slack");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Results (1)");
|
||||
expect(text).toContain("Slack");
|
||||
expect(text).not.toContain("Acme CRM");
|
||||
// Popular grid is hidden while searching.
|
||||
expect(text).not.toContain("Popular");
|
||||
});
|
||||
|
||||
it("keeps the custom URL option available when gallery search has no matches", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
await act(async () => {
|
||||
setter?.call(input, "missing app");
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("No planned apps match");
|
||||
expect(container.textContent).toContain("Connect your own tool");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link2, Search } from "lucide-react";
|
||||
import type { AppGalleryEntry } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
AdvancedToolsLink,
|
||||
BYO_CONNECT_HREF,
|
||||
ByoConnectCard,
|
||||
POPULAR_KEYS,
|
||||
ZAPIER_CONNECT_HREF,
|
||||
} from "./store-cards";
|
||||
|
||||
/**
|
||||
* Door 1 — Browse (the store) (PAP-13254 / U3 §4).
|
||||
*
|
||||
* A persistent, browsable storefront: search + a Popular grid + the full
|
||||
* gallery + a first-class bring-your-own card + a labelled Developer link.
|
||||
* Browse remains the single discoverability surface. Zapier and bring-your-own
|
||||
* MCP servers use the URL flow; the remaining integrations stay unavailable.
|
||||
*/
|
||||
export function Browse() {
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Browse" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const gallery = galleryQuery.data?.apps ?? [];
|
||||
const popular = useMemo(
|
||||
() =>
|
||||
POPULAR_KEYS.map((key) => gallery.find((entry) => entry.key === key)).filter(
|
||||
(entry): entry is AppGalleryEntry => Boolean(entry),
|
||||
),
|
||||
[gallery],
|
||||
);
|
||||
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
const filtered = useMemo(() => {
|
||||
if (!trimmed) return gallery;
|
||||
return gallery.filter(
|
||||
(entry) =>
|
||||
entry.name.toLowerCase().includes(trimmed) ||
|
||||
entry.tagline.toLowerCase().includes(trimmed) ||
|
||||
(entry.description?.toLowerCase().includes(trimmed) ?? false),
|
||||
);
|
||||
}, [gallery, trimmed]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to browse apps.</div>;
|
||||
}
|
||||
|
||||
const loading = galleryQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-8 pb-12">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Browse</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connect Zapier or your own MCP server. More integrations are coming soon.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search apps…"
|
||||
aria-label="Search apps"
|
||||
className="h-10 w-full rounded-lg border border-border bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!trimmed && popular.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Popular
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{popular.map((entry) => (
|
||||
<AppTile
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{trimmed ? `Results (${filtered.length})` : "All apps"}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="flex items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-4 py-6 text-sm text-muted-foreground">
|
||||
<Link2 className="h-4 w-4" />
|
||||
No planned apps match “{query.trim()}”.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((entry) => (
|
||||
<AppTile
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ByoConnectCard onConnect={() => navigate(BYO_CONNECT_HREF)} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Zapier connects with the MCP URL it gives you. Other listed integrations are previews.
|
||||
</p>
|
||||
<AdvancedToolsLink />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppTile({
|
||||
entry,
|
||||
onConnect,
|
||||
compact = false,
|
||||
}: {
|
||||
entry: AppGalleryEntry;
|
||||
onConnect?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const disabled = !onConnect;
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onConnect}
|
||||
className={disabled
|
||||
? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60"
|
||||
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center transition-colors hover:border-foreground/30 hover:bg-accent/40"}
|
||||
>
|
||||
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
|
||||
<span className="text-xs font-medium text-foreground">{entry.name}</span>
|
||||
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
|
||||
{disabled ? "Coming soon" : "Connect →"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onConnect}
|
||||
className={disabled
|
||||
? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
|
||||
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"}
|
||||
>
|
||||
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">{entry.name}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{entry.tagline}</div>
|
||||
</div>
|
||||
<span className={disabled ? "shrink-0 text-xs font-semibold text-muted-foreground" : "shrink-0 text-xs font-semibold text-primary"}>
|
||||
{disabled ? "Coming soon" : "Connect →"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Connections } from "./Connections";
|
||||
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listAppsAttentionMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
listApplications: (companyId: string) => listApplicationsMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
listAppsAttention: (companyId: string) => listAppsAttentionMock(companyId),
|
||||
listProfiles: (companyId: string) => listProfilesMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// React 19 does not export a usable `act` in this vitest/jsdom setup; use the
|
||||
// flushSync-based helper the sibling apps tests use (PAP-12371 gotcha).
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function application(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "app-x",
|
||||
companyId: "company-1",
|
||||
applicationKey: undefined,
|
||||
name: "GitHub",
|
||||
description: null,
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
pluginId: null,
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
metadata: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "conn-x",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-x",
|
||||
name: "GitHub",
|
||||
connectionKind: "managed",
|
||||
transport: "remote_http",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
config: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
lastUsedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function profile(connectionId: string, includedEntryIds: string[]) {
|
||||
return {
|
||||
id: `profile-${connectionId}`,
|
||||
companyId: "company-1",
|
||||
profileKey: `app:${connectionId}`,
|
||||
name: connectionId,
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
entries: includedEntryIds.map((catalogEntryId, index) => ({
|
||||
id: `entry-${connectionId}-${index}`,
|
||||
companyId: "company-1",
|
||||
profileId: `profile-${connectionId}`,
|
||||
selectorType: "catalog_entry",
|
||||
effect: "include",
|
||||
applicationId: null,
|
||||
connectionId,
|
||||
catalogEntryId,
|
||||
toolName: null,
|
||||
riskLevel: null,
|
||||
conditions: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
})),
|
||||
bindings: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [] });
|
||||
listAppsAttentionMock.mockResolvedValue({ apps: [] });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
listProfilesMock.mockResolvedValue({ profiles: [] });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderApps() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Connections />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders applications without connections as not connected with a Connect action", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [application({ id: "app-github", name: "GitHub" })],
|
||||
});
|
||||
|
||||
await renderApps();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("All (1)");
|
||||
expect(text).toContain("GitHub");
|
||||
expect(text).toContain("Not connected");
|
||||
expect(text).toContain("Connect it so agents can use it.");
|
||||
expect(text).toContain("Connect");
|
||||
|
||||
const row = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
|
||||
tr.textContent?.includes("GitHub"),
|
||||
);
|
||||
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github");
|
||||
|
||||
mockNavigate.mockClear();
|
||||
const connectButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Connect") && !button.textContent.includes("Connect an app"),
|
||||
);
|
||||
connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github");
|
||||
});
|
||||
|
||||
it("rolls up multi-connection status, attention count, actions, and navigation by application", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
application({ id: "app-github", name: "GitHub" }),
|
||||
application({ id: "app-slack", name: "Slack" }),
|
||||
application({ id: "app-notion", name: "Notion" }),
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
connection({ id: "c-connected", applicationId: "app-github", name: "GitHub", healthStatus: "healthy" }),
|
||||
connection({
|
||||
id: "c-attention",
|
||||
applicationId: "app-slack",
|
||||
name: "Slack",
|
||||
healthStatus: "error",
|
||||
lastUsedAt: new Date("2026-06-09T00:00:00Z"),
|
||||
}),
|
||||
connection({ id: "c-attention-2", applicationId: "app-slack", name: "Slack Team", healthStatus: "healthy" }),
|
||||
connection({ id: "c-paused", applicationId: "app-notion", name: "Notion", enabled: false }),
|
||||
],
|
||||
});
|
||||
listAppsAttentionMock.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
connection: connection({ id: "c-attention", applicationId: "app-slack", name: "Slack", healthStatus: "error" }),
|
||||
healthNeedsAttention: true,
|
||||
quarantinedCatalogEntryCount: 0,
|
||||
pendingActionRequestCount: 0,
|
||||
reasons: ["health"],
|
||||
},
|
||||
{
|
||||
connection: connection({ id: "c-attention-2", applicationId: "app-slack", name: "Slack Team", healthStatus: "healthy" }),
|
||||
healthNeedsAttention: false,
|
||||
quarantinedCatalogEntryCount: 1,
|
||||
pendingActionRequestCount: 0,
|
||||
reasons: ["quarantined_catalog_entries"],
|
||||
},
|
||||
],
|
||||
});
|
||||
listProfilesMock.mockResolvedValue({
|
||||
profiles: [
|
||||
profile("c-connected", ["a", "b", "c"]),
|
||||
profile("c-attention", ["a"]),
|
||||
profile("c-attention-2", ["b", "c"]),
|
||||
],
|
||||
});
|
||||
|
||||
await renderApps();
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
// 1. Connected rows lose the repeated hint.
|
||||
expect(text).not.toContain("Connected and ready");
|
||||
// 2. Attention + Paused rows keep their explanatory hint.
|
||||
expect(text).toContain("The key stopped working");
|
||||
expect(text).toContain("Paused — agents can");
|
||||
// 3. Filter chips and attention banner are application-counted, not connection-counted.
|
||||
expect(text).toContain("All (3)");
|
||||
expect(text).toContain("Needs attention (1)");
|
||||
expect(text).toContain("1 app needs attention");
|
||||
// 3. New header columns are present.
|
||||
const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent?.trim());
|
||||
expect(headers).toEqual(["App", "Status", "Actions", "Last used", ""]);
|
||||
// 4. Actions column reflects enabled catalog entries rolled up by application; missing profile => 0 on.
|
||||
expect(text).toContain("3 on");
|
||||
expect(text).toContain("0 on");
|
||||
// 5. Last used renders a relative timestamp when present, dash when absent.
|
||||
expect(text).toContain("—");
|
||||
// 6. Multi-connection app appears once and opens its first connection detail.
|
||||
expect(Array.from(container.querySelectorAll("tbody tr")).filter((tr) => tr.textContent?.includes("Slack"))).toHaveLength(1);
|
||||
const slackRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
|
||||
tr.textContent?.includes("Slack"),
|
||||
);
|
||||
slackRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-attention");
|
||||
// 7. Button labels are honest: broken health says Reconnect, healthy/paused say Open.
|
||||
const rowButtonLabel = (name: string) =>
|
||||
Array.from(container.querySelectorAll("tbody tr"))
|
||||
.find((tr) => tr.textContent?.includes(name))
|
||||
?.querySelector("td:last-child button")?.textContent;
|
||||
expect(rowButtonLabel("GitHub")).toBe("Open");
|
||||
expect(rowButtonLabel("Slack")).toBe("Reconnect");
|
||||
expect(rowButtonLabel("Notion")).toBe("Open");
|
||||
});
|
||||
|
||||
// F6 (PAP-13254 §4): the row highlight and the Status pill derive from ONE
|
||||
// health signal, so they can never disagree. A healthy connection stays
|
||||
// "Healthy" / "Open" and is not amber-highlighted, even if the broader
|
||||
// attention endpoint would once have flagged it (quarantine/new-tools review
|
||||
// now live on the app detail + Review door, not the Connections highlight).
|
||||
it("keeps a healthy app un-highlighted and Open — pill and highlight agree (F6)", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [application({ id: "app-github", name: "GitHub" })],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [connection({ id: "c-healthy", applicationId: "app-github", healthStatus: "healthy" })],
|
||||
});
|
||||
listAppsAttentionMock.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
connection: connection({ id: "c-healthy", applicationId: "app-github", healthStatus: "healthy" }),
|
||||
healthNeedsAttention: false,
|
||||
quarantinedCatalogEntryCount: 2,
|
||||
pendingActionRequestCount: 0,
|
||||
reasons: ["quarantined_catalog_entries"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderApps();
|
||||
|
||||
expect(container.textContent).toContain("Healthy");
|
||||
// No attention: the danger banner and attention filter chip are inert.
|
||||
expect(container.textContent).not.toContain("needs attention");
|
||||
expect(container.textContent).toContain("Needs attention (0)");
|
||||
const row = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
|
||||
tr.textContent?.includes("GitHub"),
|
||||
);
|
||||
expect(row?.className).not.toContain("amber");
|
||||
const button = row?.querySelector("td:last-child button");
|
||||
expect(button?.textContent).toBe("Open");
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react";
|
||||
import type {
|
||||
AppGalleryEntry,
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
humanizeConnectionDisplayName,
|
||||
isToolConnectionAttentionHealth as isAttentionHealthStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { useReviewCount } from "./useReviewCount";
|
||||
import { AdvancedToolsLink } from "./store-cards";
|
||||
|
||||
const BROWSE_HREF = "/apps/browse";
|
||||
|
||||
type StatusFilter = "all" | "attention";
|
||||
|
||||
type AppStatus = {
|
||||
label: "Healthy" | "Needs attention" | "Paused" | "Not connected";
|
||||
tone: "connected" | "attention" | "paused" | "not_connected";
|
||||
};
|
||||
|
||||
type AppRow = {
|
||||
application: ToolApplication;
|
||||
primaryConnection: ToolConnection | null;
|
||||
status: AppStatus;
|
||||
actionCount: number;
|
||||
lastUsedAt: Date | string | null;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* F6 (PAP-13254 / U3 §4): a single health signal is the source of truth for
|
||||
* BOTH the row highlight and the Status pill so they can never disagree. The
|
||||
* pill's `attention` tone and the row highlight are now the *same* predicate.
|
||||
*/
|
||||
function statusFor(application: ToolApplication, connections: ToolConnection[]): AppStatus {
|
||||
if (connections.length === 0) {
|
||||
return { label: "Not connected", tone: "not_connected" };
|
||||
}
|
||||
if (
|
||||
application.status === "disabled" ||
|
||||
application.status === "archived" ||
|
||||
connections.every((connection) => connection.enabled === false || connection.status === "disabled")
|
||||
) {
|
||||
return { label: "Paused", tone: "paused" };
|
||||
}
|
||||
if (connections.some((connection) => isAttentionHealthStatus(connection.healthStatus))) {
|
||||
return { label: "Needs attention", tone: "attention" };
|
||||
}
|
||||
return { label: "Healthy", tone: "connected" };
|
||||
}
|
||||
|
||||
/** The single health-derived predicate that drives highlight, pill, banner, filter (F6). */
|
||||
function rowNeedsAttention(row: AppRow): boolean {
|
||||
return row.status.tone === "attention";
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<AppStatus["tone"], string> = {
|
||||
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
attention: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
paused: "border-border bg-muted text-muted-foreground",
|
||||
not_connected: "border-border bg-background text-muted-foreground",
|
||||
};
|
||||
|
||||
export function Connections() {
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const reviewCount = useReviewCount();
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connections" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const connectionsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const gallery = galleryQuery.data?.apps ?? [];
|
||||
const logoByName = useMemo(() => {
|
||||
const map = new Map<string, AppGalleryEntry>();
|
||||
for (const entry of gallery) map.set(entry.name.toLowerCase(), entry);
|
||||
return map;
|
||||
}, [gallery]);
|
||||
const logoByKey = useMemo(() => {
|
||||
const map = new Map<string, AppGalleryEntry>();
|
||||
for (const entry of gallery) map.set(entry.key, entry);
|
||||
return map;
|
||||
}, [gallery]);
|
||||
|
||||
// "Actions on" = enabled tools in each app's per-connection access profile,
|
||||
// mirroring what App detail shows so the count never disagrees with the page.
|
||||
const actionCountByConnection = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const profile of profilesQuery.data?.profiles ?? []) {
|
||||
map.set(profile.profileKey, enabledActionCount(profile));
|
||||
}
|
||||
return map;
|
||||
}, [profilesQuery.data]);
|
||||
|
||||
const connections = (connectionsQuery.data?.connections ?? []).filter(
|
||||
(c) => c.status !== "archived",
|
||||
);
|
||||
const applications = (applicationsQuery.data?.applications ?? []).filter(
|
||||
(application) => application.status !== "archived",
|
||||
);
|
||||
const connectionsByApplication = useMemo(() => {
|
||||
const map = new Map<string, ToolConnection[]>();
|
||||
for (const connection of connections) {
|
||||
map.set(connection.applicationId, [...(map.get(connection.applicationId) ?? []), connection]);
|
||||
}
|
||||
return map;
|
||||
}, [connections]);
|
||||
|
||||
const rows = useMemo<AppRow[]>(() => {
|
||||
return applications.map((application) => {
|
||||
const appConnections = connectionsByApplication.get(application.id) ?? [];
|
||||
const primaryConnection = appConnections[0] ?? null;
|
||||
const actionCount = appConnections.reduce(
|
||||
(sum, connection) => sum + (actionCountByConnection.get(`app:${connection.id}`) ?? 0),
|
||||
0,
|
||||
);
|
||||
const lastUsedAt = appConnections.reduce<Date | string | null>((latest, connection) => {
|
||||
if (!connection.lastUsedAt) return latest;
|
||||
if (!latest) return connection.lastUsedAt;
|
||||
return new Date(connection.lastUsedAt).getTime() > new Date(latest).getTime()
|
||||
? connection.lastUsedAt
|
||||
: latest;
|
||||
}, null);
|
||||
const galleryEntry = application.applicationKey
|
||||
? logoByKey.get(application.applicationKey)
|
||||
: undefined;
|
||||
return {
|
||||
application,
|
||||
primaryConnection,
|
||||
status: statusFor(application, appConnections),
|
||||
actionCount,
|
||||
lastUsedAt,
|
||||
logoUrl: galleryEntry?.logoUrl ?? logoByName.get(application.name.toLowerCase())?.logoUrl,
|
||||
};
|
||||
});
|
||||
}, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]);
|
||||
|
||||
const rowsNeedingAttention = rows.filter(rowNeedsAttention);
|
||||
const visibleRows = filter === "attention" ? rowsNeedingAttention : rows;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage apps.</div>;
|
||||
}
|
||||
|
||||
const loading = applicationsQuery.isLoading || connectionsQuery.isLoading || galleryQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyConnections onBrowse={() => navigate(BROWSE_HREF)} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Connections</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The tools you’ve connected, and whether they’re working.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate(BROWSE_HREF)}>Connect an app</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterChip active={filter === "all"} onClick={() => setFilter("all")}>
|
||||
All ({rows.length})
|
||||
</FilterChip>
|
||||
<FilterChip
|
||||
active={filter === "attention"}
|
||||
tone="danger"
|
||||
disabled={rowsNeedingAttention.length === 0}
|
||||
onClick={() => setFilter("attention")}
|
||||
>
|
||||
Needs attention ({rowsNeedingAttention.length})
|
||||
</FilterChip>
|
||||
</div>
|
||||
|
||||
{reviewCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/apps/review")}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left transition-colors hover:bg-amber-500/15"
|
||||
>
|
||||
<ShieldQuestion className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
{reviewCount} {reviewCount === 1 ? "action is" : "actions are"} waiting for your OK
|
||||
</div>
|
||||
<div className="truncate text-xs text-amber-700 dark:text-amber-300">
|
||||
Your agents paused to check with you before making a change.
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold text-amber-800 dark:text-amber-200">Review →</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{rowsNeedingAttention.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("attention")}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-left transition-colors hover:bg-red-500/15"
|
||||
>
|
||||
<ShieldAlert className="h-5 w-5 shrink-0 text-red-600 dark:text-red-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-red-900 dark:text-red-100">
|
||||
{rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "app needs" : "apps need"} attention
|
||||
</div>
|
||||
<div className="truncate text-xs text-red-700 dark:text-red-300">
|
||||
{floatSummary(rowsNeedingAttention)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold text-red-800 dark:text-red-200">Fix →</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">App</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Actions</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((row) => {
|
||||
const { application, primaryConnection, status } = row;
|
||||
const attention = rowNeedsAttention(row);
|
||||
const hint =
|
||||
status.tone === "attention"
|
||||
? "The key stopped working — reconnect to fix."
|
||||
: status.tone === "paused"
|
||||
? "Paused — agents can’t use it right now."
|
||||
: status.tone === "not_connected"
|
||||
? "Connect it so agents can use it."
|
||||
: null;
|
||||
const appHref = primaryConnection
|
||||
? `/apps/${primaryConnection.id}`
|
||||
: `/apps/app/${application.id}`;
|
||||
const actionLabel = !primaryConnection
|
||||
? "Connect"
|
||||
: status.tone === "attention"
|
||||
? "Reconnect"
|
||||
: "Open";
|
||||
return (
|
||||
<tr
|
||||
key={application.id}
|
||||
onClick={() => navigate(appHref)}
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30",
|
||||
attention && "bg-amber-500/[0.06]",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo
|
||||
name={application.name}
|
||||
logoUrl={row.logoUrl}
|
||||
size={32}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">
|
||||
{application.name}
|
||||
</div>
|
||||
{hint && (
|
||||
<div className="truncate text-xs text-muted-foreground">{hint}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
STATUS_CLASS[status.tone],
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground">{row.actionCount} on</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.lastUsedAt ? timeAgo(row.lastUsedAt) : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
variant={attention ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
navigate(appHref);
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Apps you connect become available to every agent unless you change “Who can use it”.
|
||||
</p>
|
||||
<AdvancedToolsLink />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
tone = "default",
|
||||
disabled = false,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
tone?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
active
|
||||
? tone === "danger"
|
||||
? "border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300"
|
||||
: "border-foreground/30 bg-foreground/[0.06] text-foreground"
|
||||
: "border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function enabledActionCount(profile: ToolProfileWithDetails): number {
|
||||
let count = 0;
|
||||
for (const entry of profile.entries ?? []) {
|
||||
if (entry.effect === "include" && entry.catalogEntryId) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function floatSummary(rows: AppRow[]): string {
|
||||
const names = rows.map((row) => humanizeConnectionDisplayName(row.application.name));
|
||||
if (names.length <= 2) return names.join(" and ");
|
||||
return `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
|
||||
}
|
||||
|
||||
function EmptyConnections({ onBrowse }: { onBrowse: () => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Connections</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The tools you’ve connected, and whether they’re working.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-border bg-card p-10 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<AppWindow className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-foreground">No connections yet.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Add one from <span className="font-medium text-foreground">Apps</span> to give your agents
|
||||
the tools they need.
|
||||
</p>
|
||||
<Button className="mt-6" onClick={onBrowse}>
|
||||
Browse apps
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReviewQueueCard } from "./ReviewQueueCard";
|
||||
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const approveActionRequestMock = vi.hoisted(() => vi.fn());
|
||||
const createTrustRuleFromActionRequestMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listActionRequests: (companyId: string, status: string) => listActionRequestsMock(companyId, status),
|
||||
approveActionRequest: (companyId: string, actionRequestId: string) =>
|
||||
approveActionRequestMock(companyId, actionRequestId),
|
||||
createTrustRuleFromActionRequest: (companyId: string, actionRequestId: string, input: unknown) =>
|
||||
createTrustRuleFromActionRequestMock(companyId, actionRequestId, input),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
function pendingRequest() {
|
||||
return {
|
||||
request: {
|
||||
id: "request-1",
|
||||
companyId: "company-1",
|
||||
invocationId: "invocation-1",
|
||||
issueId: "issue-1",
|
||||
interactionId: null,
|
||||
approvalId: null,
|
||||
status: "pending",
|
||||
canonicalArgumentsHash: "hash-1",
|
||||
canonicalArgumentsSummary: {},
|
||||
signedArguments: "signed",
|
||||
previewMarkdown: null,
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
decidedByAgentId: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
expiresAt: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-06-16T12:00:00Z"),
|
||||
updatedAt: new Date("2026-06-16T12:00:00Z"),
|
||||
},
|
||||
toolName: "send_email",
|
||||
toolTitle: "Send email",
|
||||
connectionId: "connection-1",
|
||||
connectionName: "Mail",
|
||||
applicationName: "Mail",
|
||||
riskLevel: "write",
|
||||
requestedByAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function buttonContaining(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes(text),
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
describe("ReviewQueueCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listActionRequestsMock.mockResolvedValue({ actionRequests: [pendingRequest()] });
|
||||
approveActionRequestMock.mockResolvedValue({ ...pendingRequest().request, status: "approved" });
|
||||
createTrustRuleFromActionRequestMock.mockResolvedValue({ id: "policy-1" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ReviewQueueCard emptyState="reassure" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("promotes Always allow only after the action request is approved", async () => {
|
||||
const calls: string[] = [];
|
||||
approveActionRequestMock.mockImplementation(async () => {
|
||||
calls.push("approve");
|
||||
return { ...pendingRequest().request, status: "approved" };
|
||||
});
|
||||
createTrustRuleFromActionRequestMock.mockImplementation(async () => {
|
||||
calls.push("trust-rule");
|
||||
return { id: "policy-1" };
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Always allow")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(calls).toEqual(["approve", "trust-rule"]);
|
||||
expect(approveActionRequestMock).toHaveBeenCalledWith("company-1", "request-1");
|
||||
expect(createTrustRuleFromActionRequestMock).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"request-1",
|
||||
{ approvalThreshold: 1 },
|
||||
);
|
||||
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ title: "Always allowed" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, ShieldQuestion, X } from "lucide-react";
|
||||
import type { ToolActionRequestListItem } from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
|
||||
/**
|
||||
* "Ask first" review queue (M1b float / M9 card, PAP-10859).
|
||||
*
|
||||
* Renders pending `tool_action_requests` as prosumer cards with three choices:
|
||||
* • Allow once → approve this single request
|
||||
* • Always allow → approve + create a trust rule (won't ask again)
|
||||
* • Decline → reject this request
|
||||
*
|
||||
* Pass `connectionId` to scope the queue to a single app (App detail); omit it
|
||||
* to show every pending request (Needs attention page).
|
||||
*/
|
||||
export function ReviewQueueCard({
|
||||
connectionId,
|
||||
emptyState = "hidden",
|
||||
heading = "Waiting for your OK",
|
||||
}: {
|
||||
connectionId?: string;
|
||||
emptyState?: "hidden" | "reassure";
|
||||
heading?: string;
|
||||
}) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.tools.actionRequests(selectedCompanyId ?? "__none__", "pending"),
|
||||
queryFn: () => toolsApi.listActionRequests(selectedCompanyId!, "pending"),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 20_000,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
const all = query.data?.actionRequests ?? [];
|
||||
return connectionId ? all.filter((item) => item.connectionId === connectionId) : all;
|
||||
}, [query.data, connectionId]);
|
||||
|
||||
if (!selectedCompanyId) return null;
|
||||
if (query.isLoading) return null;
|
||||
|
||||
if (items.length === 0) {
|
||||
if (emptyState === "hidden") return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
|
||||
Nothing is waiting for your OK right now.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldQuestion className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<h2 className="text-sm font-bold text-foreground">{heading}</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<ReviewRow key={item.request.id} companyId={selectedCompanyId} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ companyId, item }: { companyId: string; item: ToolActionRequestListItem }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [resolving, setResolving] = useState<null | "allow" | "always" | "decline">(null);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.actionRequests(companyId, "pending") });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(companyId) });
|
||||
};
|
||||
|
||||
const allowOnce = useMutation({
|
||||
mutationFn: () => toolsApi.approveActionRequest(companyId, item.request.id),
|
||||
onMutate: () => setResolving("allow"),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Allowed once", body: `${actionLabel(item)} can run this time.`, tone: "success" });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidate();
|
||||
failToast(pushToast, error);
|
||||
},
|
||||
onSettled: () => setResolving(null),
|
||||
});
|
||||
|
||||
const alwaysAllow = useMutation({
|
||||
mutationFn: async () => {
|
||||
const approved = await toolsApi.approveActionRequest(companyId, item.request.id);
|
||||
await toolsApi.createTrustRuleFromActionRequest(companyId, item.request.id, { approvalThreshold: 1 });
|
||||
return approved;
|
||||
},
|
||||
onMutate: () => setResolving("always"),
|
||||
onSuccess: () => {
|
||||
pushToast({
|
||||
title: "Always allowed",
|
||||
body: `${actionLabel(item)} won’t ask again.`,
|
||||
tone: "success",
|
||||
});
|
||||
invalidate();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.trustRules(companyId) });
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidate();
|
||||
failToast(pushToast, error);
|
||||
},
|
||||
onSettled: () => setResolving(null),
|
||||
});
|
||||
|
||||
const decline = useMutation({
|
||||
mutationFn: () => toolsApi.declineActionRequest(companyId, item.request.id),
|
||||
onMutate: () => setResolving("decline"),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Declined", body: `${actionLabel(item)} won’t run.`, tone: "info" });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidate();
|
||||
failToast(pushToast, error);
|
||||
},
|
||||
onSettled: () => setResolving(null),
|
||||
});
|
||||
|
||||
const busy = resolving !== null;
|
||||
const preview = item.request.previewMarkdown?.trim();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.07] p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-bold text-foreground">{actionLabel(item)}</span>
|
||||
{item.applicationName && (
|
||||
<span className="text-muted-foreground">
|
||||
in {humanizeConnectionDisplayName(item.applicationName)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">· asked {timeAgo(item.request.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="mt-2 rounded-lg border border-border bg-background px-3 py-2 text-sm">
|
||||
<MarkdownBody>{preview}</MarkdownBody>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
An agent wants to run this action. It can change something, so we’re checking with you first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={() => allowOnce.mutate()} disabled={busy}>
|
||||
{resolving === "allow" ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1.5 h-3.5 w-3.5" />}
|
||||
Allow once
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => alwaysAllow.mutate()} disabled={busy}>
|
||||
{resolving === "always" ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Always allow
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => decline.mutate()} disabled={busy}>
|
||||
{resolving === "decline" ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <X className="mr-1.5 h-3.5 w-3.5" />}
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(item: ToolActionRequestListItem): string {
|
||||
if (!item.toolTitle && !item.toolName) return "This action";
|
||||
return humanizeConnectionDisplayName(item.toolName ?? "", { title: item.toolTitle });
|
||||
}
|
||||
|
||||
function failToast(
|
||||
pushToast: ReturnType<typeof useToast>["pushToast"],
|
||||
error: unknown,
|
||||
) {
|
||||
pushToast({
|
||||
title: "Couldn’t save that",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, ToolCallEvent } from "@paperclipai/shared";
|
||||
import { ActivityPanel } from "./ActivityPanel";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function event(overrides: Partial<ToolCallEvent>): ToolCallEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
companyId: "co-1",
|
||||
eventType: "call_completed",
|
||||
actorType: "agent",
|
||||
actorId: null,
|
||||
agentId: "agent-1",
|
||||
runId: null,
|
||||
issueId: null,
|
||||
applicationId: null,
|
||||
connectionId: "conn-1",
|
||||
catalogEntryId: null,
|
||||
invocationId: null,
|
||||
actionRequestId: null,
|
||||
runtimeSlotId: null,
|
||||
toolName: "mcp.app-gallery-link-abc:read_values",
|
||||
decision: null,
|
||||
matchedPolicyIds: [],
|
||||
reasonCode: null,
|
||||
outcome: "success",
|
||||
latencyMs: null,
|
||||
requestHash: null,
|
||||
requestSummary: null,
|
||||
resultHash: null,
|
||||
resultSummary: null,
|
||||
resultSizeBytes: null,
|
||||
redactionPlan: null,
|
||||
rateLimitState: null,
|
||||
metadata: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date("2026-06-18T00:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolCallEvent;
|
||||
}
|
||||
|
||||
const agents: Agent[] = [{ id: "agent-1", name: "BenchmarkForensics" } as Agent];
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function renderPanel(props: Parameters<typeof ActivityPanel>[0]) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<ActivityPanel {...props} />));
|
||||
return container.textContent ?? "";
|
||||
}
|
||||
|
||||
describe("ActivityPanel render", () => {
|
||||
const base = {
|
||||
lifecycleEvents: [],
|
||||
issues: {},
|
||||
actionRequests: {},
|
||||
loading: false,
|
||||
agents,
|
||||
connectionId: "conn-1",
|
||||
appName: "Google Sheets",
|
||||
};
|
||||
|
||||
it("renders 'tested as' attribution for a Test-tab call", () => {
|
||||
const text = renderPanel({
|
||||
...base,
|
||||
events: [event({ actorType: "user", actorId: "user-1", metadata: { source: "test" } })],
|
||||
userLabelById: new Map([["user-1", "Dotta"]]),
|
||||
});
|
||||
expect(text).toContain("Dotta tested as BenchmarkForensics used Read Values");
|
||||
});
|
||||
|
||||
it("renders a real heartbeat call without the prefix", () => {
|
||||
const text = renderPanel({ ...base, events: [event({})] });
|
||||
expect(text).toContain("BenchmarkForensics used Read Values");
|
||||
expect(text).not.toContain("tested as");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallEvent } from "@paperclipai/shared";
|
||||
import { humanizeEvent, isTestEvent, resolveActorLabel } from "./ActivityPanel";
|
||||
|
||||
function event(overrides: Partial<ToolCallEvent>): ToolCallEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
companyId: "co-1",
|
||||
eventType: "call_completed",
|
||||
actorType: "agent",
|
||||
actorId: null,
|
||||
agentId: "agent-1",
|
||||
runId: null,
|
||||
issueId: null,
|
||||
applicationId: null,
|
||||
connectionId: "conn-1",
|
||||
catalogEntryId: null,
|
||||
invocationId: null,
|
||||
actionRequestId: null,
|
||||
runtimeSlotId: null,
|
||||
toolName: "mcp.app-gallery-link-abc:read_values",
|
||||
decision: null,
|
||||
matchedPolicyIds: [],
|
||||
reasonCode: null,
|
||||
outcome: "success",
|
||||
latencyMs: null,
|
||||
requestHash: null,
|
||||
requestSummary: null,
|
||||
resultHash: null,
|
||||
resultSummary: null,
|
||||
resultSizeBytes: null,
|
||||
redactionPlan: null,
|
||||
rateLimitState: null,
|
||||
metadata: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date("2026-06-18T00:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolCallEvent;
|
||||
}
|
||||
|
||||
describe("isTestEvent", () => {
|
||||
it("is true only when metadata.source === 'test'", () => {
|
||||
expect(isTestEvent(event({ metadata: { source: "test" } }))).toBe(true);
|
||||
expect(isTestEvent(event({ metadata: { source: "heartbeat" } }))).toBe(false);
|
||||
expect(isTestEvent(event({ metadata: null }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActorLabel", () => {
|
||||
it("prefers the directory display name", () => {
|
||||
expect(resolveActorLabel("user-1", new Map([["user-1", "Dotta"]]))).toBe("Dotta");
|
||||
});
|
||||
|
||||
it("falls back to 'Board' for the local board principal", () => {
|
||||
expect(resolveActorLabel("local-board", new Map())).toBe("Board");
|
||||
expect(resolveActorLabel("local-board", undefined)).toBe("Board");
|
||||
});
|
||||
|
||||
it("falls back to 'Someone' for an unknown actor", () => {
|
||||
expect(resolveActorLabel("user-x", new Map())).toBe("Someone");
|
||||
expect(resolveActorLabel(null, new Map())).toBe("Someone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("humanizeEvent test-tab attribution", () => {
|
||||
it("surfaces '<User> tested as <Agent>' for a successful test call", () => {
|
||||
const { primary } = humanizeEvent(
|
||||
event({ actorType: "user", actorId: "user-1", metadata: { source: "test" } }),
|
||||
"BenchmarkForensics",
|
||||
undefined,
|
||||
"Dotta",
|
||||
);
|
||||
expect(primary).toBe("Dotta tested as BenchmarkForensics used Read Values");
|
||||
});
|
||||
|
||||
it("renders a real heartbeat call without the tested-as prefix", () => {
|
||||
const { primary } = humanizeEvent(event({}), "BenchmarkForensics");
|
||||
expect(primary).toBe("BenchmarkForensics used Read Values");
|
||||
});
|
||||
|
||||
it("attributes a denied test call to the runner instead of an anonymous block", () => {
|
||||
const { primary } = humanizeEvent(
|
||||
event({ eventType: "call_denied", outcome: "denied", metadata: { source: "test" } }),
|
||||
"BenchmarkForensics",
|
||||
undefined,
|
||||
"Board",
|
||||
);
|
||||
expect(primary).toBe("Board tested as BenchmarkForensics - Read Values is turned off");
|
||||
});
|
||||
|
||||
it("keeps the anonymous block wording for non-test denied calls", () => {
|
||||
const { primary } = humanizeEvent(
|
||||
event({ eventType: "call_denied", outcome: "denied" }),
|
||||
"BenchmarkForensics",
|
||||
);
|
||||
expect(primary).toBe("Blocked Read Values - it isn't turned on");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
humanizeConnectionDisplayName,
|
||||
type Agent,
|
||||
type ToolCallEvent,
|
||||
type ToolConnectionLifecycleEvent,
|
||||
} from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { appTabHref } from "../app-tabs";
|
||||
import type { ActivityPanelProps } from "./types";
|
||||
|
||||
export function ActivityPanel(props: ActivityPanelProps) {
|
||||
return <RecentActivity {...props} />;
|
||||
}
|
||||
|
||||
type TimelineRow = {
|
||||
key: string;
|
||||
createdAt: Date | string;
|
||||
primary: string;
|
||||
dotClass: string;
|
||||
/** Secondary "while working on PAP-…" issue link, tool-call rows only. */
|
||||
issue?: { identifier: string } | null;
|
||||
/** Deep-link rendered after the timestamp ("View in Setup"), lifecycle rows only. */
|
||||
link?: { to: string; label: string } | null;
|
||||
};
|
||||
|
||||
function RecentActivity({
|
||||
events,
|
||||
lifecycleEvents,
|
||||
issues,
|
||||
actionRequests,
|
||||
loading,
|
||||
agents,
|
||||
connectionId,
|
||||
appName,
|
||||
userLabelById,
|
||||
}: ActivityPanelProps) {
|
||||
const nameById = useMemo(() => new Map(agents.map((a) => [a.id, a.name])), [agents]);
|
||||
|
||||
const rows = useMemo<TimelineRow[]>(() => {
|
||||
const callRows: TimelineRow[] = events
|
||||
.filter((e) => HUMANIZED_EVENTS.has(e.eventType))
|
||||
.map((event) => {
|
||||
const row = humanizeEvent(
|
||||
event,
|
||||
nameById.get(event.agentId ?? "") ?? null,
|
||||
event.actionRequestId ? actionRequests[event.actionRequestId] : undefined,
|
||||
isTestEvent(event) ? resolveActorLabel(event.actorId, userLabelById) : null,
|
||||
);
|
||||
return {
|
||||
key: `call:${event.id}`,
|
||||
createdAt: event.createdAt,
|
||||
primary: row.primary,
|
||||
dotClass: dotColor(event),
|
||||
issue: event.issueId ? issues[event.issueId] ?? null : null,
|
||||
};
|
||||
});
|
||||
|
||||
const setupHref = appTabHref(connectionId, "setup");
|
||||
const lifecycleRows: TimelineRow[] = lifecycleEvents.map((event) => ({
|
||||
key: `lifecycle:${event.id}`,
|
||||
createdAt: event.createdAt,
|
||||
primary: humanizeLifecycleEvent(event, appName, nameById.get(event.agentId ?? "") ?? null),
|
||||
dotClass: lifecycleDotColor(event),
|
||||
link: { to: setupHref, label: lifecycleLinkLabel(event) },
|
||||
}));
|
||||
|
||||
return [...callRows, ...lifecycleRows].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
}, [events, lifecycleEvents, issues, actionRequests, nameById, connectionId, appName, userLabelById]);
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Recent activity</h2>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2 py-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-5 text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className="flex items-start gap-3 py-3 text-sm">
|
||||
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", row.dotClass)} aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-foreground">{row.primary}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{row.issue ? (
|
||||
<>
|
||||
while working on{" "}
|
||||
<Link
|
||||
to={`/issues/${row.issue.identifier}`}
|
||||
className="font-medium text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{row.issue.identifier}
|
||||
</Link>
|
||||
{" · "}
|
||||
</>
|
||||
) : null}
|
||||
{timeAgo(row.createdAt)}
|
||||
{row.link ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link
|
||||
to={row.link.to}
|
||||
className="font-medium text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{row.link.label}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const HUMANIZED_EVENTS = new Set<ToolCallEvent["eventType"]>([
|
||||
"call_completed",
|
||||
"call_failed",
|
||||
"call_denied",
|
||||
"approval_requested",
|
||||
"approval_resolved",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A row is a prosumer Test-tab call (vs. a real heartbeat-driven agent run) when
|
||||
* the gateway tagged the audit event `metadata.source === "test"` (PAP-11349).
|
||||
*/
|
||||
export function isTestEvent(event: ToolCallEvent): boolean {
|
||||
return (event.metadata as { source?: unknown } | null)?.source === "test";
|
||||
}
|
||||
|
||||
/** Display name for the human who ran a Test-tab call, from the company directory. */
|
||||
export function resolveActorLabel(
|
||||
actorId: string | null,
|
||||
userLabelById: Map<string, string> | undefined,
|
||||
): string {
|
||||
if (actorId) {
|
||||
const label = userLabelById?.get(actorId);
|
||||
if (label) return label;
|
||||
if (actorId === "local-board") return "Board";
|
||||
}
|
||||
return "Someone";
|
||||
}
|
||||
|
||||
export function humanizeEvent(
|
||||
event: ToolCallEvent,
|
||||
agentName: string | null,
|
||||
actionRequest?: ActivityPanelProps["actionRequests"][string],
|
||||
/** When set, this row is a Test-tab call run by the named user; prefix accordingly. */
|
||||
testRunnerLabel?: string | null,
|
||||
): { primary: string } {
|
||||
// For Test-tab calls, surface "<User> tested as <Agent>" so prosumer test runs are
|
||||
// distinguishable from real heartbeat agent activity in the audit trail (PAP-11415).
|
||||
const who = testRunnerLabel
|
||||
? `${testRunnerLabel} tested as ${agentName ?? "an agent"}`
|
||||
: agentName ?? "An agent";
|
||||
// The raw gateway tool name is prefixed (e.g. `mcp.app-gallery-link-…:kv-set`);
|
||||
// humanize it to "Kv Set" to match the cross-app Activity view (PAP-11105).
|
||||
const action = event.toolName ? humanizeConnectionDisplayName(event.toolName) : "an action";
|
||||
switch (event.eventType) {
|
||||
case "call_completed":
|
||||
return {
|
||||
primary: event.outcome === "success"
|
||||
? `${who} used ${action}`
|
||||
: `${who} ran ${action}, but it didn't finish`,
|
||||
};
|
||||
case "call_failed":
|
||||
return { primary: `${action} didn't work for ${lower(who)}` };
|
||||
case "call_denied":
|
||||
return {
|
||||
primary: testRunnerLabel
|
||||
? `${who} - ${action} is turned off`
|
||||
: `Blocked ${action} - it isn't turned on`,
|
||||
};
|
||||
case "approval_requested":
|
||||
return { primary: `${who} asked before running ${action}` };
|
||||
case "approval_resolved":
|
||||
return { primary: humanizeApprovalResolved(action, actionRequest) };
|
||||
default:
|
||||
return { primary: `${who} used ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
function humanizeApprovalResolved(
|
||||
action: string,
|
||||
actionRequest?: ActivityPanelProps["actionRequests"][string],
|
||||
): string {
|
||||
const resolver = actionRequest?.resolverDisplayName ?? "Someone";
|
||||
if (actionRequest?.status === "approved") return `${resolver} approved ${action}`;
|
||||
if (actionRequest?.status === "rejected") return `${resolver} said no to ${action}`;
|
||||
return `${resolver} reviewed ${action}`;
|
||||
}
|
||||
|
||||
/** Humanize a connection lifecycle event into a prosumer sentence (PAP-11284). */
|
||||
function humanizeLifecycleEvent(
|
||||
event: ToolConnectionLifecycleEvent,
|
||||
appName: string,
|
||||
agentName: string | null,
|
||||
): string {
|
||||
const who = event.actorDisplayName ?? agentName ?? "Someone";
|
||||
switch (event.type) {
|
||||
case "app_connected":
|
||||
return `${who} connected ${appName}`;
|
||||
case "app_paused":
|
||||
return `${who} paused this app`;
|
||||
case "app_resumed":
|
||||
return `${who} resumed this app`;
|
||||
case "reconnected":
|
||||
return `${who} reconnected ${appName}`;
|
||||
case "disconnected":
|
||||
return `${who} disconnected ${appName}`;
|
||||
case "allowlist_changed":
|
||||
return humanizeAllowlistChange(who, event.details);
|
||||
case "actions_quarantined": {
|
||||
const count = numberFrom(event.details?.count);
|
||||
return `${count} new ${count === 1 ? "action" : "actions"} need review`;
|
||||
}
|
||||
default:
|
||||
return `${who} updated this app`;
|
||||
}
|
||||
}
|
||||
|
||||
function humanizeAllowlistChange(who: string, details: Record<string, unknown> | null): string {
|
||||
const added = numberFrom(details?.added);
|
||||
const removed = numberFrom(details?.removed);
|
||||
if (added > 0 && removed === 0) {
|
||||
return `${who} added ${added} ${added === 1 ? "sheet" : "sheets"} to the allowlist`;
|
||||
}
|
||||
if (removed > 0 && added === 0) {
|
||||
return `${who} removed ${removed} ${removed === 1 ? "sheet" : "sheets"} from the allowlist`;
|
||||
}
|
||||
if (added > 0 && removed > 0) {
|
||||
return `${who} updated the allowlist (added ${added}, removed ${removed})`;
|
||||
}
|
||||
return `${who} updated the allowlist`;
|
||||
}
|
||||
|
||||
function lifecycleLinkLabel(event: ToolConnectionLifecycleEvent): string {
|
||||
return event.type === "actions_quarantined" ? "Review in Setup" : "View in Setup";
|
||||
}
|
||||
|
||||
function numberFrom(value: unknown): number {
|
||||
const n = Number(value ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function lower(who: string): string {
|
||||
return who === "An agent" ? "an agent" : who;
|
||||
}
|
||||
|
||||
function dotColor(event: ToolCallEvent): string {
|
||||
if (event.eventType === "call_failed" || event.outcome === "failure" || event.outcome === "timeout") {
|
||||
return "bg-red-400";
|
||||
}
|
||||
if (event.eventType === "call_denied" || event.outcome === "denied") return "bg-amber-400";
|
||||
if (event.eventType === "approval_requested") return "bg-amber-400";
|
||||
return "bg-emerald-400";
|
||||
}
|
||||
|
||||
function lifecycleDotColor(event: ToolConnectionLifecycleEvent): string {
|
||||
if (event.type === "disconnected") return "bg-red-400";
|
||||
if (event.type === "app_paused" || event.type === "actions_quarantined") return "bg-amber-400";
|
||||
return "bg-emerald-400";
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ArrowUpRight, Loader2, Lock } from "lucide-react";
|
||||
import type { AppGalleryEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
|
||||
export function AdvancedPanel({
|
||||
connection,
|
||||
appName,
|
||||
galleryEntry,
|
||||
removing,
|
||||
onRemove,
|
||||
onReplaced,
|
||||
}: Pick<AppDetailSectionProps, "connection" | "appName" | "galleryEntry"> & {
|
||||
removing: boolean;
|
||||
onRemove: () => void;
|
||||
onReplaced: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeySection connection={connection} galleryEntry={galleryEntry} onReplaced={onReplaced} />
|
||||
<TechnicalDetails connection={connection} />
|
||||
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeySection({
|
||||
connection,
|
||||
galleryEntry,
|
||||
onReplaced,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
onReplaced: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Key</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Your key is stored securely. Replace it if it stopped working or you rotated it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!open && (
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
Replace key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="border-t border-border px-5 py-4">
|
||||
<ReconnectForm
|
||||
connection={connection}
|
||||
galleryEntry={galleryEntry}
|
||||
onCancel={() => setOpen(false)}
|
||||
onReconnected={() => {
|
||||
setOpen(false);
|
||||
onReplaced();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReconnectCard({
|
||||
connection,
|
||||
galleryEntry,
|
||||
onReconnected,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
onReconnected: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/50 bg-amber-500/10 p-5">
|
||||
<h2 className="text-sm font-bold text-amber-900 dark:text-amber-100">This app needs reconnecting</h2>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
{connection.healthMessage?.trim() || "The key stopped working. Paste a new one to get it back online."}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ReconnectForm connection={connection} galleryEntry={galleryEntry} onReconnected={onReconnected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconnectForm({
|
||||
connection,
|
||||
galleryEntry,
|
||||
onCancel,
|
||||
onReconnected,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
onCancel?: () => void;
|
||||
onReconnected: () => void;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const fields = galleryEntry?.credentialFields ?? [];
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [single, setSingle] = useState("");
|
||||
const usesGallery = fields.length > 0 && !!galleryEntry;
|
||||
|
||||
const reconnect = useMutation({
|
||||
mutationFn: () => {
|
||||
const credentialValues = usesGallery
|
||||
? values
|
||||
: { "credentials.authorization": single.trim() };
|
||||
return toolsApi.reconnectConnection(connection.id, credentialValues);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const healthy =
|
||||
result.connection.healthStatus === "healthy" || result.connection.healthStatus === "unknown";
|
||||
if (healthy) {
|
||||
pushToast({
|
||||
title: "Reconnected",
|
||||
body: `${humanizeConnectionDisplayName(connection)} is back online.`,
|
||||
tone: "success",
|
||||
});
|
||||
onReconnected();
|
||||
} else {
|
||||
pushToast({
|
||||
title: "Still not working",
|
||||
body: result.connection.healthMessage?.trim() || "That key didn't check out. Try another.",
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "That key didn't work",
|
||||
body: error instanceof Error ? error.message : "Check the key and try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const filled = usesGallery
|
||||
? fields.every((f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0)
|
||||
: single.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{usesGallery ? (
|
||||
fields.map((field) => (
|
||||
<div key={field.configPath}>
|
||||
<label className="text-xs font-medium text-foreground">{field.label}</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={values[field.configPath] ?? ""}
|
||||
onChange={(e) => setValues({ ...values, [field.configPath]: e.target.value })}
|
||||
placeholder="****************"
|
||||
className="mt-1 h-10 font-mono"
|
||||
/>
|
||||
{field.helpUrl && (
|
||||
<a
|
||||
href={field.helpUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
|
||||
>
|
||||
Where do I find this? <ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={single}
|
||||
onChange={(e) => setSingle(e.target.value)}
|
||||
placeholder="Paste your new key"
|
||||
className="h-10 font-mono"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" disabled={!filled || reconnect.isPending} onClick={() => reconnect.mutate()}>
|
||||
{reconnect.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{reconnect.isPending ? "Checking..." : "Check & reconnect"}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button size="sm" variant="ghost" onClick={onCancel} disabled={reconnect.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<h2 className="text-sm font-bold text-foreground">Technical details</h2>
|
||||
<dl className="mt-3 grid gap-2 text-xs sm:grid-cols-(--gtc-59)">
|
||||
<dt className="text-muted-foreground">Address</dt>
|
||||
<dd className="break-all font-mono text-foreground">{connectionAddress(connection)}</dd>
|
||||
<dt className="text-muted-foreground">Connection type</dt>
|
||||
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DangerZone({
|
||||
appName,
|
||||
removing,
|
||||
onRemove,
|
||||
}: {
|
||||
appName: string;
|
||||
removing: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
return (
|
||||
<section className="rounded-xl border border-destructive/40 bg-card">
|
||||
<div className="border-b border-destructive/40 px-5 py-3 text-sm font-bold text-destructive">
|
||||
Danger zone
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Remove this app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agents lose access to {appName} right away. You can connect it again later.
|
||||
</p>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)} disabled={removing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onRemove} disabled={removing}>
|
||||
{removing && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Yes, remove it
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
|
||||
Remove app
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function connectionAddress(connection: ToolConnection): string {
|
||||
const config = connection.config ?? connection.transportConfig ?? {};
|
||||
const value = config.url ?? config.endpoint ?? config.remoteUrl;
|
||||
if (typeof value === "string" && value.trim().length > 0) return redactUrlSecrets(value);
|
||||
if (connection.transport === "local_stdio") return "Local command";
|
||||
return "Not set";
|
||||
}
|
||||
|
||||
export function connectionTransportLabel(transport: ToolConnection["transport"]): string {
|
||||
if (transport === "remote_http") return "Remote HTTP";
|
||||
if (transport === "local_stdio") return "Local command";
|
||||
return "Unknown";
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, PackageCheck, RefreshCw } from "lucide-react";
|
||||
import type { Agent, ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { brandChipBadge } from "@/lib/status-colors";
|
||||
import {
|
||||
autoExtendNotice,
|
||||
INSTALL_ALL_WARNING,
|
||||
installInfoNotice,
|
||||
type InstallState,
|
||||
} from "@/lib/tool-installs";
|
||||
import { QuarantinePill } from "./SetupPanel";
|
||||
import type { AccessDraft, AppDetailSectionProps } from "./types";
|
||||
|
||||
type ActionPermission = "off" | "allowed" | "ask";
|
||||
|
||||
export function PermissionsPanel({
|
||||
appName,
|
||||
access,
|
||||
agents,
|
||||
install,
|
||||
readOnly,
|
||||
canChange,
|
||||
quarantined,
|
||||
enabledIds,
|
||||
askFirstIds,
|
||||
pending,
|
||||
installPending,
|
||||
onSaveAccess,
|
||||
onSaveInstall,
|
||||
onSetActionPermission,
|
||||
onTurnOnQuarantined,
|
||||
onRefreshActions,
|
||||
refreshPending,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"access" | "agents" | "readOnly" | "canChange" | "quarantined" | "enabledIds" | "askFirstIds" | "pending"
|
||||
> & {
|
||||
appName: string;
|
||||
install: InstallState;
|
||||
installPending: boolean;
|
||||
onSaveAccess: (next: AccessDraft) => void;
|
||||
onSaveInstall: (next: InstallState) => void;
|
||||
onSetActionPermission: (id: string, next: ActionPermission) => void;
|
||||
onTurnOnQuarantined: (ids: string[]) => void;
|
||||
onRefreshActions: () => void;
|
||||
refreshPending: boolean;
|
||||
}) {
|
||||
// Deep-link from the Test tab's "off" panel: ?focus={catalogEntryId} scrolls
|
||||
// to and highlights that action row.
|
||||
const [searchParams] = useSearchParams();
|
||||
const focusId = searchParams.get("focus");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AccessSection access={access} agents={agents} disabled={pending} onSave={onSaveAccess} />
|
||||
<InstalledSection
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
disabled={installPending}
|
||||
onSave={onSaveInstall}
|
||||
/>
|
||||
<ActionsSection
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={pending}
|
||||
refreshPending={refreshPending}
|
||||
focusId={focusId}
|
||||
onSetPermission={onSetActionPermission}
|
||||
onTurnOnQuarantined={onTurnOnQuarantined}
|
||||
onRefreshActions={onRefreshActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessSection({
|
||||
access,
|
||||
agents,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
access: AccessDraft;
|
||||
agents: Agent[];
|
||||
disabled: boolean;
|
||||
onSave: (next: AccessDraft) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AccessDraft>(access);
|
||||
const liveAgents = agents.filter((a) => a.status !== "terminated");
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(access);
|
||||
}, [access, editing]);
|
||||
|
||||
const summary =
|
||||
access.mode === "all"
|
||||
? "Every agent can use it"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} can use it`;
|
||||
|
||||
const canSave = draft.mode === "all" || draft.agentIds.size > 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Who can use it</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
{!editing && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-3 border-t border-border px-5 py-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-1"
|
||||
checked={draft.mode === "all"}
|
||||
onChange={() => setDraft({ mode: "all", agentIds: new Set() })}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-semibold text-foreground">All agents</span>
|
||||
<span className="block text-xs text-muted-foreground">Anyone you've added to Paperclip.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-1"
|
||||
checked={draft.mode === "specific"}
|
||||
onChange={() => setDraft({ mode: "specific", agentIds: new Set(draft.agentIds) })}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-semibold text-foreground">Only specific agents</span>
|
||||
<span className="block text-xs text-muted-foreground">Pick who can use it.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{draft.mode === "specific" && (
|
||||
<AgentMultiSelect
|
||||
agents={liveAgents}
|
||||
selectedAgentIds={draft.agentIds}
|
||||
onChange={(agentIds) => setDraft({ mode: "specific", agentIds })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || !canSave}
|
||||
onClick={() => {
|
||||
onSave(draft);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)} disabled={disabled}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledSection({
|
||||
appName,
|
||||
agents,
|
||||
access,
|
||||
install,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: Agent[];
|
||||
access: AccessDraft;
|
||||
install: InstallState;
|
||||
disabled: boolean;
|
||||
onSave: (next: InstallState) => void;
|
||||
}) {
|
||||
const liveAgents = agents.filter((a) => a.status !== "terminated");
|
||||
const hasAccess = (agentId: string) => access.mode === "all" || access.agentIds.has(agentId);
|
||||
// Agents that are installed but not (yet) in the access set — installing on
|
||||
// them auto-extends access server-side. Surfaced amber so it's never silent.
|
||||
const extendingAgents =
|
||||
access.mode === "all"
|
||||
? []
|
||||
: [...install.agentIds].filter((id) => !access.agentIds.has(id));
|
||||
const installedCount = install.onAll ? liveAgents.length : install.agentIds.size;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Installed on agents</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Whose harness carries {appName}'s tools on every run.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
{install.onAll ? (
|
||||
<InstalledBadge label="Installed on all agents" />
|
||||
) : install.agentIds.size > 0 ? (
|
||||
<InstalledBadge label={`${installedCount} installed`} />
|
||||
) : (
|
||||
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Permitted only — not installed on any agent
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border px-5 py-4">
|
||||
<InlineBanner tone="info" compact>
|
||||
{installInfoNotice(appName)}
|
||||
</InlineBanner>
|
||||
|
||||
{!install.onAll && (
|
||||
<AgentMultiSelect
|
||||
agents={liveAgents}
|
||||
selectedAgentIds={install.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={
|
||||
install.agentIds.size === 0
|
||||
? "Choose agents to install on"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} installed`
|
||||
}
|
||||
getDescription={(agent) => (hasAccess(agent.id) ? "has access" : "no access yet")}
|
||||
renderNameSuffix={(agent) =>
|
||||
!hasAccess(agent.id) && install.agentIds.has(agent.id) ? (
|
||||
<span className={cn("rounded border px-1 py-0 text-xs font-medium", brandChipBadge.amber)}>
|
||||
will grant access
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border px-3 py-2.5",
|
||||
install.onAll ? "border-foreground bg-muted/40" : "border-border bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={install.onAll}
|
||||
disabled={disabled}
|
||||
aria-label="Install on all agents"
|
||||
onCheckedChange={(checked) =>
|
||||
onSave(checked ? { onAll: true, agentIds: new Set() } : { onAll: false, agentIds: new Set() })
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-foreground">
|
||||
<span className="font-semibold">Install on all agents</span>
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
{INSTALL_ALL_WARNING}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{extendingAgents.length > 0 ? (
|
||||
<InlineBanner tone="warning" compact>
|
||||
<span>
|
||||
{autoExtendNotice(
|
||||
extendingAgents.length === 1
|
||||
? liveAgents.find((a) => a.id === extendingAgents[0])?.name ?? "1 agent"
|
||||
: `${extendingAgents.length} agents`,
|
||||
)}{" "}
|
||||
<span className="font-medium">
|
||||
Review the {extendingAgents.length} access change
|
||||
{extendingAgents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</span>
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium", brandChipBadge.green)}>
|
||||
<PackageCheck className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsSection({
|
||||
readOnly,
|
||||
canChange,
|
||||
quarantined,
|
||||
enabledIds,
|
||||
askFirstIds,
|
||||
disabled,
|
||||
refreshPending,
|
||||
focusId,
|
||||
onSetPermission,
|
||||
onTurnOnQuarantined,
|
||||
onRefreshActions,
|
||||
}: {
|
||||
readOnly: ToolCatalogEntry[];
|
||||
canChange: ToolCatalogEntry[];
|
||||
quarantined: ToolCatalogEntry[];
|
||||
enabledIds: Set<string>;
|
||||
askFirstIds: Set<string>;
|
||||
disabled: boolean;
|
||||
refreshPending: boolean;
|
||||
focusId?: string | null;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
onTurnOnQuarantined: (ids: string[]) => void;
|
||||
onRefreshActions: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Action permissions</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Choose what agents can do and what needs a human first.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving...</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefreshActions}
|
||||
disabled={refreshPending || disabled}
|
||||
>
|
||||
{refreshPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Refresh actions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quarantined.length > 0 && (
|
||||
<QuarantinePill
|
||||
count={quarantined.length}
|
||||
entries={quarantined}
|
||||
disabled={disabled}
|
||||
onTurnOn={onTurnOnQuarantined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionGroup
|
||||
title="Read only"
|
||||
hint="Can look up context without changing anything."
|
||||
actions={readOnly}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
<ActionGroup
|
||||
title="Can make changes"
|
||||
hint="Can change something in another app."
|
||||
actions={canChange}
|
||||
enabledIds={enabledIds}
|
||||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionGroup({
|
||||
title,
|
||||
hint,
|
||||
actions,
|
||||
enabledIds,
|
||||
askFirstIds,
|
||||
disabled,
|
||||
focusId,
|
||||
onSetPermission,
|
||||
}: {
|
||||
title: string;
|
||||
hint: string;
|
||||
actions: ToolCatalogEntry[];
|
||||
enabledIds: Set<string>;
|
||||
askFirstIds: Set<string>;
|
||||
disabled: boolean;
|
||||
focusId?: string | null;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
}) {
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (focusId && focusRef.current) {
|
||||
focusRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [focusId]);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="border-b border-border px-5 py-3 text-sm">
|
||||
<span className="font-bold text-foreground">{title}</span>
|
||||
<span className="ml-2 text-muted-foreground">- {hint}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{actions.map((action) => {
|
||||
const value = actionPermission(action.id, enabledIds, askFirstIds);
|
||||
const focused = focusId === action.id;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
ref={focused ? focusRef : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-5 py-3",
|
||||
focused && "rounded-md bg-primary/5 ring-2 ring-primary/40",
|
||||
)}
|
||||
data-action-id={action.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{action.title ?? action.toolName}</div>
|
||||
{action.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
aria-label={`${action.title ?? action.toolName} permission`}
|
||||
className={cn(
|
||||
"h-9 w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onSetPermission(action.id, event.currentTarget.value as ActionPermission)}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="allowed">Allowed</option>
|
||||
<option value="ask">Ask a human first</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function actionPermission(
|
||||
id: string,
|
||||
enabledIds: Set<string>,
|
||||
askFirstIds: Set<string>,
|
||||
): ActionPermission {
|
||||
if (!enabledIds.has(id)) return "off";
|
||||
return askFirstIds.has(id) ? "ask" : "allowed";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ReviewQueueCard } from "../ReviewQueueCard";
|
||||
import { QuarantinePill } from "./SetupPanel";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
|
||||
export function ReviewPanel({
|
||||
connectionId,
|
||||
quarantined = [],
|
||||
pending = false,
|
||||
onTurnOnQuarantined,
|
||||
}: Pick<AppDetailSectionProps, "connectionId"> &
|
||||
Partial<Pick<AppDetailSectionProps, "quarantined" | "pending">> & {
|
||||
onTurnOnQuarantined?: (ids: string[]) => void;
|
||||
}) {
|
||||
const showsQuarantinedActions = quarantined.length > 0 && !!onTurnOnQuarantined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showsQuarantinedActions ? (
|
||||
<QuarantinePill
|
||||
count={quarantined.length}
|
||||
entries={quarantined}
|
||||
disabled={pending}
|
||||
onTurnOn={onTurnOnQuarantined}
|
||||
/>
|
||||
) : null}
|
||||
<ReviewQueueCard
|
||||
connectionId={connectionId}
|
||||
heading="Waiting for your OK"
|
||||
emptyState={showsQuarantinedActions ? "hidden" : "reassure"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
import { googleSheetsConfigWithAllowlist, parseGoogleSheetIds } from "../google-sheets";
|
||||
|
||||
export function SetupPanel({
|
||||
connection,
|
||||
galleryEntry,
|
||||
onToggleApp,
|
||||
appToggleDisabled,
|
||||
onUpdateConfig,
|
||||
configUpdateDisabled,
|
||||
onStartOAuth,
|
||||
oauthStartDisabled,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"connection" | "galleryEntry"
|
||||
> & {
|
||||
onToggleApp: () => void;
|
||||
appToggleDisabled: boolean;
|
||||
onUpdateConfig: (config: Record<string, unknown>) => void;
|
||||
configUpdateDisabled: boolean;
|
||||
onStartOAuth: () => void;
|
||||
oauthStartDisabled: boolean;
|
||||
}) {
|
||||
const description = galleryEntry?.description ?? galleryEntry?.tagline ?? null;
|
||||
const oauth = connection.config?.oauth;
|
||||
const hasOAuthSignIn = Boolean(oauth && typeof oauth === "object" && !Array.isArray(oauth));
|
||||
const isSmokeLabFixture = connection.config?.smokeLabFixture === "oauth-http";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{description && (
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{galleryEntry?.key === "google-sheets" && (
|
||||
<GoogleSheetsAllowlistSection
|
||||
connection={connection}
|
||||
disabled={configUpdateDisabled}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
)}
|
||||
{hasOAuthSignIn && (
|
||||
<OAuthConnectionSection
|
||||
connected={Boolean((oauth as Record<string, unknown>).connectedAt)}
|
||||
isSmokeLabFixture={isSmokeLabFixture}
|
||||
disabled={oauthStartDisabled}
|
||||
onStart={onStartOAuth}
|
||||
/>
|
||||
)}
|
||||
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthConnectionSection({
|
||||
connected,
|
||||
isSmokeLabFixture,
|
||||
disabled,
|
||||
onStart,
|
||||
}: {
|
||||
connected: boolean;
|
||||
isSmokeLabFixture: boolean;
|
||||
disabled: boolean;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const providerName = isSmokeLabFixture ? "Smoke OAuth" : "OAuth";
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
{connected ? `Connected with ${providerName}` : `Connect with ${providerName}`}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{connected
|
||||
? "Sign in again to replace this connection's OAuth session."
|
||||
: "Open the provider's consent page to finish connecting this app."}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" disabled={disabled} onClick={onStart}>
|
||||
{connected ? "Reconnect" : `Connect with ${providerName}`}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function currentSpreadsheetIds(connection: ToolConnection): string[] {
|
||||
const raw = connection.config?.allowedSpreadsheetIds;
|
||||
return Array.isArray(raw) ? raw.map((value) => String(value).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function googleSheetsUrlForId(id: string): string {
|
||||
return `https://docs.google.com/spreadsheets/d/${encodeURIComponent(id)}/edit`;
|
||||
}
|
||||
|
||||
function GoogleSheetsAllowlistSection({
|
||||
connection,
|
||||
disabled,
|
||||
onUpdateConfig,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
disabled: boolean;
|
||||
onUpdateConfig: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ids = currentSpreadsheetIds(connection);
|
||||
const saveIds = (nextIds: string[]) =>
|
||||
onUpdateConfig(googleSheetsConfigWithAllowlist(connection.config, nextIds));
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Sheets agents can use</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Agents can only use the sheets listed here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{ids.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No sheets are connected yet.</div>
|
||||
) : (
|
||||
ids.map((id) => {
|
||||
const sheetUrl = googleSheetsUrlForId(id);
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-3 border-t border-border py-2 first:border-t-0">
|
||||
<a
|
||||
href={sheetUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className="block truncate">Open sheet</span>
|
||||
<span className="block truncate font-mono text-xs font-normal text-muted-foreground">
|
||||
{sheetUrl}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-(length:--text-micro) font-normal text-muted-foreground/80">
|
||||
ID: {id}
|
||||
</span>
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || ids.length <= 1}
|
||||
title={ids.length <= 1 ? "Add another sheet before removing this one." : undefined}
|
||||
onClick={() => saveIds(ids.filter((current) => current !== id))}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://docs.google.com/spreadsheets/d/..."
|
||||
className="h-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const parsed = parseGoogleSheetIds(draft);
|
||||
if (parsed.ids.length === 0) {
|
||||
setError("Paste a Google Sheets link.");
|
||||
return;
|
||||
}
|
||||
if (parsed.invalidCount > 0) {
|
||||
setError("That doesn't look like a Google Sheets link.");
|
||||
return;
|
||||
}
|
||||
saveIds(Array.from(new Set([...ids, ...parsed.ids])));
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
Add sheet
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppLifecycleSection({
|
||||
connection,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const enabled = connection.enabled !== false && connection.status !== "disabled";
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
{enabled ? "Agents can use this app" : "This app is paused"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{enabled
|
||||
? "Pause it to stop every agent from using its actions."
|
||||
: "Resume it when agents should be able to use its actions again."}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
aria-label={enabled ? "Pause this app" : "Resume this app"}
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onToggle}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuarantinePill({
|
||||
count,
|
||||
entries,
|
||||
disabled,
|
||||
onTurnOn,
|
||||
}: {
|
||||
count: number;
|
||||
entries: ToolCatalogEntry[];
|
||||
disabled: boolean;
|
||||
onTurnOn: (ids: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/[0.08] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-amber-800 dark:text-amber-200">
|
||||
{count} new {count === 1 ? "action" : "actions"} to review
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "Hide" : "Review"}
|
||||
</Button>
|
||||
<Button size="sm" disabled={disabled} onClick={() => onTurnOn(entries.map((e) => e.id))}>
|
||||
Turn on all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
This app added actions since you set it up. They stay off until you turn them on.
|
||||
</p>
|
||||
{open && (
|
||||
<div className="mt-3 divide-y divide-amber-500/25 rounded-lg border border-amber-500/40 bg-background">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{entry.title ?? entry.toolName}</div>
|
||||
{entry.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">{entry.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={disabled} onClick={() => onTurnOn([entry.id])}>
|
||||
Turn on
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { TestPanel, errorHints } from "./TestPanel";
|
||||
|
||||
const listTestAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const runTestCallMock = vi.hoisted(() => vi.fn());
|
||||
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
|
||||
const declineActionRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
|
||||
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
|
||||
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
|
||||
getTestCallStatusMock(connectionId, actionRequestId),
|
||||
declineActionRequest: (companyId: string, actionRequestId: string) =>
|
||||
declineActionRequestMock(companyId, actionRequestId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The form field input — excludes the "Find an action" search box. */
|
||||
function formInput(): HTMLInputElement {
|
||||
const inputs = [...container.querySelectorAll("input")].filter(
|
||||
(i) => i.getAttribute("aria-label") !== "Find an action",
|
||||
);
|
||||
return inputs[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
async function fillFormField(value: string) {
|
||||
const input = formInput();
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
await act(async () => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
/** Wait past the 200ms minimum-spinner delay so the result panel renders. */
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function clickByText(text: string) {
|
||||
const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text);
|
||||
expect(btn).toBeTruthy();
|
||||
await act(async () => btn!.click());
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function catalogEntry(overrides: Partial<ToolCatalogEntry> = {}): ToolCatalogEntry {
|
||||
return {
|
||||
id: "catalog-read",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
connectionId: "conn-1",
|
||||
entryKind: "tool",
|
||||
toolName: "read_sheet",
|
||||
title: "Read a sheet",
|
||||
description: "Get rows and cell values from a sheet.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } },
|
||||
required: ["spreadsheetId"],
|
||||
},
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-01-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-01-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
function agent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-claude",
|
||||
name: "ClaudeCoder",
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
effectiveAccess: {
|
||||
connectionId: "conn-1",
|
||||
toolCount: 3,
|
||||
allowedCount: 1,
|
||||
askFirstCount: 1,
|
||||
offCount: 1,
|
||||
lastChangedAt: null,
|
||||
lastChangedByAgentId: null,
|
||||
lastChangedByName: null,
|
||||
tools: [
|
||||
{ toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] },
|
||||
{ toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] },
|
||||
{ toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const readEntry = catalogEntry();
|
||||
const writeAskEntry = catalogEntry({
|
||||
id: "catalog-append",
|
||||
toolName: "append_row",
|
||||
title: "Append a row",
|
||||
description: "Add a new row to the bottom of a sheet.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
riskLevel: "write",
|
||||
});
|
||||
const offEntry = catalogEntry({
|
||||
id: "catalog-delete",
|
||||
toolName: "delete_row",
|
||||
title: "Delete a row",
|
||||
description: "Remove a row from a sheet permanently.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: true,
|
||||
riskLevel: "destructive",
|
||||
});
|
||||
|
||||
let container: HTMLDivElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let root: any;
|
||||
|
||||
function renderPanel(
|
||||
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
|
||||
quarantined: ToolCatalogEntry[] = [],
|
||||
) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
listTestAgentsMock.mockReset();
|
||||
runTestCallMock.mockReset();
|
||||
getTestCallStatusMock.mockReset();
|
||||
declineActionRequestMock.mockReset();
|
||||
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
|
||||
// Default ask-first polls report the request still waiting on approval.
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "waiting",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
durationMs: null,
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("TestPanel", () => {
|
||||
it("renders the Test-as header and grouped actions with access badges", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Test as");
|
||||
expect(container.textContent).toContain("ClaudeCoder");
|
||||
expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
|
||||
expect(container.textContent).toContain("Read (1)");
|
||||
expect(container.textContent).toContain("Write (2)");
|
||||
// Each action shows its decision badge.
|
||||
expect(container.textContent).toContain("Allowed");
|
||||
expect(container.textContent).toContain("Ask first");
|
||||
expect(container.textContent).toContain("Off");
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no actions", async () => {
|
||||
await act(async () => renderPanel([]));
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Nothing to test yet");
|
||||
expect(container.textContent).toContain("Go to Setup");
|
||||
});
|
||||
|
||||
it("renders an allowed result panel with a row-count headline after a successful run", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed",
|
||||
invocationId: "inv-1",
|
||||
result: [
|
||||
{ name: "Acme", stage: "Demo" },
|
||||
{ name: "Globex", stage: "Trial" },
|
||||
],
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
// Expand the allowed (read) action.
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
|
||||
expect(trigger).toBeTruthy();
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
// Fill the required field then Run.
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
|
||||
agentId: "agent-claude",
|
||||
toolName: "read_sheet",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
});
|
||||
expect(container.textContent).toContain("Worked. 2 rows came back.");
|
||||
expect(container.textContent).toContain("Ran as ClaudeCoder");
|
||||
expect(container.textContent).toContain("Preview");
|
||||
});
|
||||
|
||||
it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
|
||||
// The gateway let the call through (decision:"allowed") but the upstream MCP
|
||||
// tool failed at the tool layer — the envelope carries isError + an error.
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed",
|
||||
invocationId: "inv-err",
|
||||
result: {
|
||||
content: "Invalid arguments: range is required",
|
||||
data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true },
|
||||
error: "MCP tool returned an error result",
|
||||
},
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("It didn't work.");
|
||||
expect(container.textContent).not.toContain("Worked.");
|
||||
// The actual MCP message wins over the generic gateway wrapper string.
|
||||
expect(container.textContent).toContain("Invalid arguments: range is required");
|
||||
});
|
||||
|
||||
it("renders the ask-first card linking to Review", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("Sent for your OK.");
|
||||
expect(container.textContent).toContain("Cancel this request");
|
||||
// "Where" is populated from the polled action-request snapshot.
|
||||
expect(container.textContent).toContain("spreadsheetId: sheet-123");
|
||||
expect(container.textContent).toContain("Waiting ·");
|
||||
const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab"));
|
||||
expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review");
|
||||
});
|
||||
|
||||
it("restores the ask-first card after leaving and returning to the Test tab", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
expect(container.textContent).toContain("Sent for your OK.");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(runTestCallMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Sent for your OK.");
|
||||
expect(container.textContent).toContain("spreadsheetId: sheet-123");
|
||||
expect(container.textContent).toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("shows 'Approved · running' while an approved ask-first call is executing", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "running",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: "2026-06-18T00:00:05.000Z",
|
||||
durationMs: null,
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("Approved · running");
|
||||
// While running we don't offer cancel — the call is already on its way.
|
||||
expect(container.textContent).not.toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("mutates the ask-first card into the allowed result once approved and done", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "done",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
result: [{ name: "Acme", stage: "Demo" }],
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: "2026-06-18T00:00:05.000Z",
|
||||
durationMs: 1200,
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
// No longer the pending card — the real response is shown without re-running.
|
||||
expect(container.textContent).not.toContain("Sent for your OK.");
|
||||
expect(container.textContent).toContain("Worked.");
|
||||
expect(container.textContent).toContain("1.2s");
|
||||
});
|
||||
|
||||
it("shows the denied status when an ask-first call is declined in Review", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "denied",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: "2026-06-18T00:00:05.000Z",
|
||||
durationMs: null,
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("Denied — see Review for why");
|
||||
expect(container.textContent).not.toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("explains an off action and links to Permissions without calling the API", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Delete a row is off for ClaudeCoder.");
|
||||
const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions");
|
||||
expect(permLink).toBeTruthy();
|
||||
// No Run button is offered for an off action.
|
||||
expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false);
|
||||
expect(runTestCallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
|
||||
listTestAgentsMock.mockResolvedValue({
|
||||
agents: [
|
||||
agent({
|
||||
effectiveAccess: {
|
||||
...agent().effectiveAccess,
|
||||
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
lastChangedByAgentId: "agent-admin",
|
||||
lastChangedByName: "Dotta",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Last changed by Dotta");
|
||||
});
|
||||
|
||||
it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => {
|
||||
const quarantinedEntry = catalogEntry({
|
||||
id: "catalog-new",
|
||||
toolName: "rename_sheet",
|
||||
title: "Rename a sheet",
|
||||
description: "Change a sheet's name.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
riskLevel: "write",
|
||||
status: "quarantined",
|
||||
});
|
||||
await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry]));
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("New (1)");
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet"));
|
||||
expect(trigger).toBeTruthy();
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This action is new and hasn't been turned on yet.");
|
||||
expect(runTestCallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorHints", () => {
|
||||
it("maps known reason codes to tailored hints", () => {
|
||||
expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0);
|
||||
expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission");
|
||||
expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0);
|
||||
expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit");
|
||||
});
|
||||
|
||||
it("returns the locked generic fallback for an unknown error", () => {
|
||||
expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
Agent,
|
||||
AppGalleryEntry,
|
||||
ToolCallEvent,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionLifecycleEvent,
|
||||
ToolCatalogEntry,
|
||||
ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export type AccessDraft = { mode: "all" | "specific"; agentIds: Set<string> };
|
||||
|
||||
export interface AppDetailSectionProps {
|
||||
connectionId: string;
|
||||
connection: ToolConnection;
|
||||
appName: string;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
catalog: ToolCatalogEntry[];
|
||||
active: ToolCatalogEntry[];
|
||||
readOnly: ToolCatalogEntry[];
|
||||
canChange: ToolCatalogEntry[];
|
||||
quarantined: ToolCatalogEntry[];
|
||||
enabledIds: Set<string>;
|
||||
askFirstIds: Set<string>;
|
||||
access: AccessDraft;
|
||||
agents: Agent[];
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
export interface ActivityPanelProps {
|
||||
events: ToolCallEvent[];
|
||||
lifecycleEvents: ToolConnectionLifecycleEvent[];
|
||||
issues: ToolConnectionActivityResponse["issues"];
|
||||
actionRequests: ToolConnectionActivityResponse["actionRequests"];
|
||||
loading: boolean;
|
||||
agents: Agent[];
|
||||
connectionId: string;
|
||||
appName: string;
|
||||
/** Maps a user principalId → display name, for "tested as" attribution on Test-tab calls. */
|
||||
userLabelById?: Map<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-react";
|
||||
|
||||
export const APP_TABS = [
|
||||
{ key: "setup", label: "Setup", icon: Settings2 },
|
||||
{ key: "review", label: "Review", icon: Inbox },
|
||||
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
|
||||
{ key: "activity", label: "Activity", icon: Activity },
|
||||
{ key: "test", label: "Test", icon: Beaker },
|
||||
{ key: "advanced", label: "Advanced", icon: Wrench },
|
||||
] as const;
|
||||
|
||||
export type AppTabKey = (typeof APP_TABS)[number]["key"];
|
||||
|
||||
/**
|
||||
* Tabs hidden for an application that has no live connection (the
|
||||
* `AppNotConnected` shell). The Test tab runs real calls against a connected
|
||||
* app, so it only appears once the app is connected.
|
||||
*/
|
||||
export const CONNECTED_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>(["test"]);
|
||||
|
||||
export function appTabHref(connectionId: string, tab: AppTabKey): string {
|
||||
return `/apps/${connectionId}/${tab}`;
|
||||
}
|
||||
|
||||
export function appApplicationTabHref(applicationId: string, tab: AppTabKey): string {
|
||||
return `/apps/app/${applicationId}/${tab}`;
|
||||
}
|
||||
|
||||
export function isAppTabKey(value: string | undefined): value is AppTabKey {
|
||||
return APP_TABS.some((tab) => tab.key === value);
|
||||
}
|
||||
|
||||
export function appTabLabel(tabKey: AppTabKey): string {
|
||||
return APP_TABS.find((tab) => tab.key === tabKey)?.label ?? "Setup";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SubNavKey = "connected" | "gateways" | "activity";
|
||||
|
||||
const ITEMS: { key: SubNavKey; label: string; href: string }[] = [
|
||||
{ key: "connected", label: "Connected", href: "/apps" },
|
||||
{ key: "gateways", label: "Gateways", href: "/apps/gateways" },
|
||||
{ key: "activity", label: "Activity", href: "/activity" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared Apps section sub-navigation (Connected · Gateways · Activity). Keeps
|
||||
* the Gateways surface reachable as a first-class Apps tab per the PAP-11178
|
||||
* design of record, rather than buried under the Advanced developer door.
|
||||
*/
|
||||
export function AppsSubNav({ active }: { active: SubNavKey }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-6 border-b border-border text-sm" aria-label="Apps sections">
|
||||
{ITEMS.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 pb-2.5 pt-1 font-medium transition-colors",
|
||||
isActive
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import type { ToolMcpGatewayTokenCreated, ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatSnippetConfig, maskedTokenLabel, orderedSnippets } from "./gateway-helpers";
|
||||
|
||||
type PanelKey = string; // snippet client key, or "raw_url"
|
||||
|
||||
/**
|
||||
* "Connect a client" dialog (PAP-11178 design of record). Shows the copy-paste
|
||||
* config for each supported client plus a raw URL fallback. If a token was just
|
||||
* minted it can be revealed once here; otherwise the config carries a masked
|
||||
* placeholder and the value never persists in the DOM.
|
||||
*/
|
||||
export function ConnectClientDialog({
|
||||
gateway,
|
||||
open,
|
||||
onOpenChange,
|
||||
createdToken,
|
||||
}: {
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
createdToken?: ToolMcpGatewayTokenCreated | null;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const snippets = useMemo(() => orderedSnippets(gateway.clientSnippets ?? []), [gateway.clientSnippets]);
|
||||
const endpoint = useMemo(() => {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
return `${origin}${gateway.endpointPath}`;
|
||||
}, [gateway.endpointPath]);
|
||||
|
||||
const [active, setActive] = useState<PanelKey>(snippets[0]?.client ?? "raw_url");
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActive(snippets[0]?.client ?? "raw_url");
|
||||
setRevealed(false);
|
||||
}
|
||||
}, [open, snippets]);
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
try {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
throw new Error("Clipboard access is unavailable.");
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
pushToast({ title: "Copied", body: label, tone: "success" });
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
title: "Copy failed",
|
||||
body: error instanceof Error ? error.message : "Clipboard access is unavailable.",
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const activeSnippet = snippets.find((snippet) => snippet.client === active) ?? null;
|
||||
const configText = activeSnippet ? formatSnippetConfig(activeSnippet.config) : "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect a client</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick how you’ll point your client at this gateway.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-(--gtc-10)">
|
||||
<nav className="flex gap-1 overflow-x-auto sm:flex-col" aria-label="Clients">
|
||||
{snippets.map((snippet) => (
|
||||
<button
|
||||
key={snippet.client}
|
||||
type="button"
|
||||
onClick={() => setActive(snippet.client)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
active === snippet.client
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{snippet.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive("raw_url")}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
active === "raw_url"
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
Raw URL
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 space-y-3">
|
||||
{active === "raw_url" ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-foreground">Endpoint URL</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{endpoint}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Authenticate with <code>Authorization: Bearer <token></code> over streamable HTTP.
|
||||
</p>
|
||||
</div>
|
||||
) : activeSnippet ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-foreground">{activeSnippet.label}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyText(configText, `${activeSnippet.label} config`)}
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
|
||||
{configText}
|
||||
</pre>
|
||||
{activeSnippet.notes.length > 0 ? (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
{activeSnippet.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No client snippets available for this gateway.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 rounded-md border border-border p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Token</div>
|
||||
{createdToken ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1.5 font-mono text-xs text-foreground">
|
||||
{revealed ? createdToken.token : maskedTokenLabel(createdToken)}
|
||||
</code>
|
||||
{revealed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyText(createdToken.token, "Access token")}
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
|
||||
Show
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mint a token on the <span className="font-medium">Tokens</span> tab, then paste it where the
|
||||
snippet shows <code>Bearer …</code>. You won’t see a token’s full value again after it’s
|
||||
created.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Treat this like a password. Anyone with the token can call exactly the tools this gateway
|
||||
allows. If it leaks, revoke it — the client goes silent immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
<Check className="mr-1.5 h-4 w-4" />
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import type { ToolMcpGatewayTokenCreated } from "@paperclipai/shared";
|
||||
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ErrorState } from "@/pages/tools/shared";
|
||||
import { GATEWAY_TABS, gatewayTabHref, isGatewayTabKey, type GatewayTabKey } from "./gateway-tabs";
|
||||
import { gatewaysQueryKey } from "./NewGatewayDialog";
|
||||
import { ConnectClientDialog } from "./ConnectClientDialog";
|
||||
import { deriveGatewayApps, isGatewayOn } from "./gateway-helpers";
|
||||
import { OverviewPanel } from "./panels/OverviewPanel";
|
||||
import { AppsToolsPanel } from "./panels/AppsToolsPanel";
|
||||
import { TokensPanel } from "./panels/TokensPanel";
|
||||
import { GatewayActivityPanel } from "./panels/GatewayActivityPanel";
|
||||
import { GatewayAdvancedPanel } from "./panels/GatewayAdvancedPanel";
|
||||
|
||||
export function GatewayDetail() {
|
||||
const { gatewayId = "", tab } = useParams<{ gatewayId: string; tab?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [snippetOpen, setSnippetOpen] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<ToolMcpGatewayTokenCreated | null>(null);
|
||||
|
||||
const activeTab: GatewayTabKey | null = isGatewayTabKey(tab) ? tab : null;
|
||||
|
||||
const gatewaysQuery = useQuery({
|
||||
queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGateways(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const connectionsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const gateway = useMemo(
|
||||
() => (gatewaysQuery.data?.gateways ?? []).find((g) => g.id === gatewayId),
|
||||
[gatewaysQuery.data, gatewayId],
|
||||
);
|
||||
const profile = useMemo(
|
||||
() => (profilesQuery.data?.profiles ?? []).find((p) => p.id === gateway?.profileId),
|
||||
[profilesQuery.data, gateway?.profileId],
|
||||
);
|
||||
const apps = useMemo(
|
||||
() =>
|
||||
deriveGatewayApps(
|
||||
profile,
|
||||
applicationsQuery.data?.applications ?? [],
|
||||
connectionsQuery.data?.connections ?? [],
|
||||
),
|
||||
[profile, applicationsQuery.data, connectionsQuery.data],
|
||||
);
|
||||
const agentNames = useMemo(
|
||||
() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent.name])),
|
||||
[agentsQuery.data],
|
||||
);
|
||||
const projectNames = useMemo(
|
||||
() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project.name])),
|
||||
[projectsQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gateway) return;
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Gateways", href: "/apps/gateways" },
|
||||
{ label: gateway.name },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, gateway]);
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.updateGateway(selectedCompanyId!, gatewayId, {
|
||||
status: gateway && isGatewayOn(gateway) ? "disabled" : "active",
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
pushToast({
|
||||
title: updated.status === "active" ? "Gateway on" : "Gateway off",
|
||||
body:
|
||||
updated.status === "active"
|
||||
? `${updated.name} is exposing its tools again.`
|
||||
: `${updated.name} is off — every client goes silent.`,
|
||||
tone: "success",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(selectedCompanyId!) });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't update the gateway",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage gateways.</div>;
|
||||
}
|
||||
if (!activeTab) {
|
||||
return <Navigate replace to={gatewayTabHref(gatewayId, "overview")} />;
|
||||
}
|
||||
if (gatewaysQuery.isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl space-y-4">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-9 w-full max-w-lg" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (gatewaysQuery.isError) {
|
||||
return <ErrorState error={gatewaysQuery.error} onRetry={() => gatewaysQuery.refetch()} />;
|
||||
}
|
||||
if (!gateway) {
|
||||
return (
|
||||
<div className="max-w-3xl p-6">
|
||||
<p className="text-sm text-muted-foreground">We couldn’t find that gateway.</p>
|
||||
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps/gateways")}>
|
||||
Back to gateways
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const endpointHost = (() => {
|
||||
try {
|
||||
return `${new URL(window.location.origin).host}${gateway.endpointPath}`;
|
||||
} catch {
|
||||
return gateway.endpointPath;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-5 pb-12">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/apps/gateways" className="hover:underline">
|
||||
Apps · Gateways
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-bold tracking-tight">{gateway.name}</h1>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{endpointHost}</p>
|
||||
</div>
|
||||
<Button onClick={() => setSnippetOpen(true)}>
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
Show snippet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-border text-sm" aria-label="Gateway tabs">
|
||||
{GATEWAY_TABS.map((item) => {
|
||||
const isActive = item.key === activeTab;
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={gatewayTabHref(gatewayId, item.key)}
|
||||
className={cn(
|
||||
"-mb-px shrink-0 border-b-2 pb-2.5 pt-1 font-medium transition-colors",
|
||||
isActive
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{activeTab === "overview" && (
|
||||
<OverviewPanel
|
||||
gateway={gateway}
|
||||
profile={profile}
|
||||
apps={apps}
|
||||
agentNames={agentNames}
|
||||
projectNames={projectNames}
|
||||
toggleDisabled={toggleMutation.isPending}
|
||||
onToggle={() => toggleMutation.mutate()}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "apps" && <AppsToolsPanel apps={apps} profile={profile} />}
|
||||
{activeTab === "tokens" && (
|
||||
<TokensPanel
|
||||
companyId={selectedCompanyId}
|
||||
gateway={gateway}
|
||||
onTokenCreated={(token) => setCreatedToken(token)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
<GatewayActivityPanel companyId={selectedCompanyId} gateway={gateway} />
|
||||
)}
|
||||
{activeTab === "advanced" && (
|
||||
<GatewayAdvancedPanel companyId={selectedCompanyId} gateway={gateway} />
|
||||
)}
|
||||
|
||||
<ConnectClientDialog
|
||||
gateway={gateway}
|
||||
open={snippetOpen}
|
||||
onOpenChange={setSnippetOpen}
|
||||
createdToken={createdToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
|
||||
import { AppsSubNav } from "./AppsSubNav";
|
||||
import { NewGatewayDialog, gatewaysQueryKey } from "./NewGatewayDialog";
|
||||
import { gatewayTabHref } from "./gateway-tabs";
|
||||
import {
|
||||
activeTokenCount,
|
||||
allowedToolsLabel,
|
||||
deriveGatewayApps,
|
||||
expiringTokenCount,
|
||||
formatScope,
|
||||
isGatewayOn,
|
||||
latestTokenActivity,
|
||||
} from "./gateway-helpers";
|
||||
|
||||
export function GatewaysList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [search, setSearch] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Gateways" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
const gatewaysQuery = useQuery({
|
||||
queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listGateways(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const connectionsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const profileById = useMemo(
|
||||
() => new Map((profilesQuery.data?.profiles ?? []).map((profile) => [profile.id, profile])),
|
||||
[profilesQuery.data],
|
||||
);
|
||||
const agentNames = useMemo(
|
||||
() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent.name])),
|
||||
[agentsQuery.data],
|
||||
);
|
||||
const projectNames = useMemo(
|
||||
() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project.name])),
|
||||
[projectsQuery.data],
|
||||
);
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ gateway }: { gateway: ToolMcpGatewayWithTokens }) =>
|
||||
toolsApi.updateGateway(selectedCompanyId!, gateway.id, {
|
||||
status: isGatewayOn(gateway) ? "disabled" : "active",
|
||||
}),
|
||||
onSuccess: async (gateway) => {
|
||||
pushToast({
|
||||
title: gateway.status === "active" ? "Gateway on" : "Gateway off",
|
||||
body:
|
||||
gateway.status === "active"
|
||||
? `${gateway.name} is exposing its tools again.`
|
||||
: `${gateway.name} is off — every client goes silent.`,
|
||||
tone: "success",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(selectedCompanyId!) });
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't update the gateway",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to manage gateways.</div>;
|
||||
}
|
||||
|
||||
const gateways = gatewaysQuery.data?.gateways ?? [];
|
||||
const term = search.trim().toLowerCase();
|
||||
const filtered = term
|
||||
? gateways.filter((gateway) => {
|
||||
const scope = formatScope(gateway, projectNames, agentNames).toLowerCase();
|
||||
return (
|
||||
gateway.name.toLowerCase().includes(term) ||
|
||||
gateway.displaySlug.toLowerCase().includes(term) ||
|
||||
scope.includes(term)
|
||||
);
|
||||
})
|
||||
: gateways;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Apps</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A gateway is one safe MCP endpoint that exposes only the apps you assign. Hand it to a client
|
||||
like Cursor or Claude Desktop.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AppsSubNav active="gateways" />
|
||||
|
||||
{gatewaysQuery.isLoading ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<Skeleton className="h-9 w-full max-w-sm" />
|
||||
<Skeleton className="h-52 w-full" />
|
||||
</div>
|
||||
) : gatewaysQuery.isError ? (
|
||||
<ErrorState error={gatewaysQuery.error} onRetry={() => gatewaysQuery.refetch()} />
|
||||
) : gateways.length === 0 ? (
|
||||
<EmptyGateways onCreate={() => setCreating(true)} />
|
||||
) : (
|
||||
<div className="space-y-4 pt-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search by name, app, or owner"
|
||||
className="pl-9"
|
||||
aria-label="Search gateways"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setCreating(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New gateway
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const rows = filtered.map((gateway) => {
|
||||
const profile = profileById.get(gateway.profileId);
|
||||
const apps = deriveGatewayApps(
|
||||
profile,
|
||||
applicationsQuery.data?.applications ?? [],
|
||||
connectionsQuery.data?.connections ?? [],
|
||||
);
|
||||
return {
|
||||
gateway,
|
||||
profile,
|
||||
scope: formatScope(gateway, projectNames, agentNames),
|
||||
appsLabel: `${apps.length} ${apps.length === 1 ? "app" : "apps"}${
|
||||
profile ? ` · ${allowedToolsLabel(profile)}` : ""
|
||||
}`,
|
||||
active: activeTokenCount(gateway),
|
||||
expiring: expiringTokenCount(gateway),
|
||||
lastUsed: latestTokenActivity(gateway),
|
||||
href: gatewayTabHref(gateway.id, "overview"),
|
||||
};
|
||||
});
|
||||
const toggle = (gateway: ToolMcpGatewayWithTokens) => (
|
||||
<ToggleSwitch
|
||||
checked={isGatewayOn(gateway)}
|
||||
disabled={toggleMutation.isPending}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={() => toggleMutation.mutate({ gateway })}
|
||||
aria-label={`Turn ${gateway.name} ${isGatewayOn(gateway) ? "off" : "on"}`}
|
||||
/>
|
||||
);
|
||||
const empty = (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No gateways match “{search.trim()}”.
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{/* Desktop / tablet: full table. */}
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
|
||||
<table className="w-full min-w-(--sz-40rem) text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Gateway</th>
|
||||
<th className="px-4 py-2.5">Scope</th>
|
||||
<th className="px-4 py-2.5">Apps</th>
|
||||
<th className="px-4 py-2.5">Tokens</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
<th className="px-4 py-2.5 text-right">On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
|
||||
<tr
|
||||
key={gateway.id}
|
||||
onClick={() => navigate(href)}
|
||||
className="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{gateway.name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{endpointHost(gateway.endpointPath, gateway.displaySlug)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{scope}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{appsLabel}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end">{toggle(gateway)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}>{empty}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards so the On toggle stays reachable and thumb-sized. */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{rows.map(({ gateway, scope, appsLabel, active, expiring, lastUsed, href }) => (
|
||||
<div
|
||||
key={gateway.id}
|
||||
onClick={() => navigate(href)}
|
||||
className="cursor-pointer rounded-lg border border-border p-4 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{gateway.name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{endpointHost(gateway.endpointPath, gateway.displaySlug)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">{toggle(gateway)}</div>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<MobileField label="Scope" value={scope} />
|
||||
<MobileField label="Apps" value={appsLabel} />
|
||||
<MobileField
|
||||
label="Tokens"
|
||||
value={`${active} active${expiring > 0 ? ` · ${expiring} expiring` : ""}`}
|
||||
/>
|
||||
<MobileField
|
||||
label="Last used"
|
||||
value={lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-border">{empty}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-foreground">Why a gateway?</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
You pick which apps go through it, who can use it, and how. Revoke the token, the whole
|
||||
gateway goes silent — no app-by-app cleanup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewGatewayDialog
|
||||
companyId={selectedCompanyId}
|
||||
open={creating}
|
||||
onOpenChange={setCreating}
|
||||
onCreated={(gatewayId) => navigate(gatewayTabHref(gatewayId, "overview"))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Show `mcp.host/g/<slug>` when the endpoint is absolute, else the raw path. */
|
||||
function endpointHost(endpointPath: string, slug: string): string {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const host = new URL(window.location.origin).host;
|
||||
return `${host}${endpointPath}`;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return endpointPath || `/g/${slug}`;
|
||||
}
|
||||
|
||||
/** One label:value pair inside a mobile stacked card. */
|
||||
function MobileField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 truncate text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyGateways({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border p-12 text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground">No gateways yet</h2>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
Group your connected apps into one safe endpoint you can hand to a client, then revoke it in one
|
||||
move.
|
||||
</p>
|
||||
<Button className="mt-5" onClick={onCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New gateway
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayContextScopeType, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { allowedToolsLabel } from "./gateway-helpers";
|
||||
|
||||
export const gatewaysQueryKey = (companyId: string) => ["tools", "gateways", companyId] as const;
|
||||
|
||||
/**
|
||||
* "New gateway" dialog. A gateway is one safe MCP endpoint that exposes only
|
||||
* the tools in its access profile. Matches the prosumer create flow from the
|
||||
* PAP-11178 design of record.
|
||||
*/
|
||||
export function NewGatewayDialog({
|
||||
companyId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
companyId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (gatewayId: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [profileId, setProfileId] = useState("");
|
||||
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(companyId),
|
||||
queryFn: () => toolsApi.listProfiles(companyId),
|
||||
enabled: open,
|
||||
});
|
||||
const activeProfiles = (profilesQuery.data?.profiles ?? []).filter(
|
||||
(profile: ToolProfileWithDetails) => profile.status !== "archived",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !profileId && activeProfiles[0]) setProfileId(activeProfiles[0].id);
|
||||
}, [open, profileId, activeProfiles]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.createGateway(companyId, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
profileId,
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "company" satisfies ToolMcpGatewayContextScopeType,
|
||||
}),
|
||||
onSuccess: async (gateway) => {
|
||||
pushToast({ title: "Gateway created", body: gateway.name, tone: "success" });
|
||||
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
|
||||
setName("");
|
||||
setDescription("");
|
||||
onOpenChange(false);
|
||||
onCreated?.(gateway.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Gateway was not created",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || !profileId) return;
|
||||
createMutation.mutate();
|
||||
}
|
||||
|
||||
const profilesLoading = profilesQuery.isLoading;
|
||||
const noProfiles = !profilesLoading && activeProfiles.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New gateway</DialogTitle>
|
||||
<DialogDescription>
|
||||
One safe MCP endpoint that exposes only the apps in its access profile. Hand it to a client
|
||||
like Cursor or Claude Desktop.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Name</span>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="CTO agents"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Access profile</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={profileId}
|
||||
onChange={(event) => setProfileId(event.target.value)}
|
||||
required
|
||||
disabled={noProfiles}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{profilesLoading ? "Loading profiles…" : "Choose a profile"}
|
||||
</option>
|
||||
{activeProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} — {allowedToolsLabel(profile)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
The profile decides which tools this gateway allows. You can change it later.
|
||||
</span>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Description (optional)</span>
|
||||
<textarea
|
||||
className="min-h-16 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Who this endpoint is for and when it should be rotated."
|
||||
/>
|
||||
</label>
|
||||
{noProfiles ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Create an access profile under Advanced before adding a gateway.
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || noProfiles || !name.trim() || !profileId}
|
||||
>
|
||||
{createMutation.isPending ? "Creating…" : "Create gateway"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolMcpGatewayClientSnippet,
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayWithTokens,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeTokenCount,
|
||||
deriveGatewayApps,
|
||||
expiringTokenCount,
|
||||
formatScope,
|
||||
isGatewayOn,
|
||||
maskedTokenLabel,
|
||||
orderedSnippets,
|
||||
tokenStatus,
|
||||
} from "./gateway-helpers";
|
||||
|
||||
const NOW = new Date("2026-06-16T12:00:00.000Z").getTime();
|
||||
|
||||
function token(overrides: Partial<ToolMcpGatewayToken> = {}): ToolMcpGatewayToken {
|
||||
return {
|
||||
id: "t",
|
||||
companyId: "c",
|
||||
gatewayId: "g",
|
||||
name: "tok",
|
||||
tokenPrefix: "pcgw_abc",
|
||||
subjectType: "gateway_client",
|
||||
subjectId: null,
|
||||
clientLabel: "Cursor",
|
||||
ownerNote: "",
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
expiresAt: null,
|
||||
expiryOverrideReason: null,
|
||||
expiryOverrideByUserId: null,
|
||||
expiryOverrideByAgentId: null,
|
||||
expiryOverrideAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "u",
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function gateway(overrides: Partial<ToolMcpGatewayWithTokens> = {}): ToolMcpGatewayWithTokens {
|
||||
return {
|
||||
id: "gateway-1",
|
||||
companyId: "c",
|
||||
gatewayPublicId: "gw",
|
||||
name: "CTO agents",
|
||||
displaySlug: "cto-agents",
|
||||
slug: "cto-agents",
|
||||
description: null,
|
||||
status: "active",
|
||||
profileId: "profile-1",
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "none",
|
||||
contextScopeId: null,
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
approvalIssueId: null,
|
||||
endpointPath: "/g/cto-agents/mcp",
|
||||
authConfig: {} as ToolMcpGatewayWithTokens["authConfig"],
|
||||
headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"],
|
||||
metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"],
|
||||
onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"],
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "u",
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tokens: [],
|
||||
clientSnippets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tokenStatus", () => {
|
||||
it("classifies revoked, expired, expiring, and active", () => {
|
||||
expect(tokenStatus(token({ revokedAt: "2026-06-10T00:00:00.000Z" }), NOW)).toBe("revoked");
|
||||
expect(tokenStatus(token({ expiresAt: "2026-06-01T00:00:00.000Z" }), NOW)).toBe("expired");
|
||||
expect(tokenStatus(token({ expiresAt: "2026-06-20T00:00:00.000Z" }), NOW)).toBe("expiring");
|
||||
expect(tokenStatus(token({ expiresAt: "2026-12-01T00:00:00.000Z" }), NOW)).toBe("active");
|
||||
expect(tokenStatus(token({ expiresAt: null }), NOW)).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("token counts", () => {
|
||||
it("counts active (incl. expiring) but not revoked/expired", () => {
|
||||
const g = gateway({
|
||||
tokens: [
|
||||
token({ id: "a", expiresAt: "2026-12-01T00:00:00.000Z" }),
|
||||
token({ id: "b", expiresAt: "2026-06-20T00:00:00.000Z" }),
|
||||
token({ id: "c", revokedAt: "2026-06-10T00:00:00.000Z" }),
|
||||
token({ id: "d", expiresAt: "2026-06-01T00:00:00.000Z" }),
|
||||
],
|
||||
});
|
||||
expect(activeTokenCount(g, NOW)).toBe(2);
|
||||
expect(expiringTokenCount(g, NOW)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskedTokenLabel", () => {
|
||||
it("never reveals the full value — only prefix plus dots", () => {
|
||||
expect(maskedTokenLabel({ tokenPrefix: "pcgw_live_8x4Pa" })).toBe("pcgw_live_8x4Pa•••");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGatewayOn", () => {
|
||||
it("is on only when status is active", () => {
|
||||
expect(isGatewayOn(gateway({ status: "active" }))).toBe(true);
|
||||
expect(isGatewayOn(gateway({ status: "disabled" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatScope", () => {
|
||||
it("labels company, project, and agent scopes", () => {
|
||||
expect(formatScope(gateway(), new Map(), new Map())).toBe("Company");
|
||||
expect(
|
||||
formatScope(
|
||||
gateway({ contextScopeType: "project", contextScopeId: "p1" }),
|
||||
new Map([["p1", "Support"]]),
|
||||
new Map(),
|
||||
),
|
||||
).toBe("Project · Support");
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderedSnippets", () => {
|
||||
it("orders clients cursor → claude_desktop → vscode → claude_code → opencode", () => {
|
||||
const snippets: ToolMcpGatewayClientSnippet[] = [
|
||||
{ client: "opencode", label: "OpenCode", config: {}, notes: [] },
|
||||
{ client: "cursor", label: "Cursor", config: {}, notes: [] },
|
||||
{ client: "vscode", label: "VS Code", config: {}, notes: [] },
|
||||
];
|
||||
expect(orderedSnippets(snippets).map((s) => s.client)).toEqual(["cursor", "vscode", "opencode"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveGatewayApps", () => {
|
||||
const profile: ToolProfileWithDetails = {
|
||||
id: "profile-1",
|
||||
companyId: "c",
|
||||
profileKey: "eng",
|
||||
name: "Engineering",
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 3,
|
||||
allowedApplicationCount: 2,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 3,
|
||||
assignmentCount: 0,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
},
|
||||
entries: [
|
||||
{ effect: "include", applicationId: "app-gh", catalogEntryId: "ce-1" },
|
||||
{ effect: "include", applicationId: "app-gh", catalogEntryId: "ce-2" },
|
||||
{ effect: "include", connectionId: "conn-sheets", catalogEntryId: "ce-3" },
|
||||
{ effect: "exclude", applicationId: "app-linear", catalogEntryId: "ce-4" },
|
||||
] as unknown as ToolProfileWithDetails["entries"],
|
||||
};
|
||||
|
||||
const applications = [
|
||||
{ id: "app-gh", name: "GitHub", status: "active" },
|
||||
{ id: "app-sheets", name: "Sheets", status: "active" },
|
||||
] as unknown as ToolApplication[];
|
||||
|
||||
const connections = [
|
||||
{ id: "conn-gh", applicationId: "app-gh", name: "GitHub", healthStatus: "healthy" },
|
||||
{ id: "conn-sheets", applicationId: "app-sheets", name: "Sheets", healthStatus: "missing_secret" },
|
||||
] as unknown as ToolConnection[];
|
||||
|
||||
it("lists included apps with tool counts and surfaces attention health", () => {
|
||||
const rows = deriveGatewayApps(profile, applications, connections);
|
||||
expect(rows.map((r) => r.application.id)).toEqual(["app-gh", "app-sheets"]);
|
||||
const gh = rows.find((r) => r.application.id === "app-gh")!;
|
||||
expect(gh.toolCount).toBe(2);
|
||||
expect(gh.needsAttention).toBe(false);
|
||||
const sheets = rows.find((r) => r.application.id === "app-sheets")!;
|
||||
expect(sheets.toolCount).toBe(1);
|
||||
expect(sheets.needsAttention).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers a live connection over an archived connection", () => {
|
||||
const rows = deriveGatewayApps(profile, applications, [
|
||||
{ ...connections[0], id: "conn-gh-archived", status: "archived" },
|
||||
{ ...connections[0], id: "conn-gh-active", status: "active" },
|
||||
connections[1],
|
||||
] as ToolConnection[]);
|
||||
|
||||
expect(rows.find((row) => row.application.id === "app-gh")?.connection?.id).toBe(
|
||||
"conn-gh-active",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing without a profile", () => {
|
||||
expect(deriveGatewayApps(undefined, applications, connections)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolMcpGatewayClientSnippet,
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayWithTokens,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName, isToolConnectionAttentionHealth } from "@paperclipai/shared";
|
||||
|
||||
/** Days before expiry at which we surface a token as "Expiring". */
|
||||
export const TOKEN_EXPIRING_WINDOW_DAYS = 14;
|
||||
|
||||
export type TokenStatus = "active" | "expiring" | "expired" | "revoked";
|
||||
|
||||
export function toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** Reveal-once, then masked. We never persist the full token in the DOM. */
|
||||
export function maskedTokenLabel(token: Pick<ToolMcpGatewayToken, "tokenPrefix">): string {
|
||||
return `${token.tokenPrefix}•••`;
|
||||
}
|
||||
|
||||
export function tokenStatus(
|
||||
token: Pick<ToolMcpGatewayToken, "revokedAt" | "expiresAt">,
|
||||
now: number = Date.now(),
|
||||
): TokenStatus {
|
||||
if (token.revokedAt) return "revoked";
|
||||
const expires = toDate(token.expiresAt);
|
||||
if (!expires) return "active";
|
||||
const diffMs = expires.getTime() - now;
|
||||
if (diffMs <= 0) return "expired";
|
||||
if (diffMs <= TOKEN_EXPIRING_WINDOW_DAYS * 24 * 60 * 60 * 1000) return "expiring";
|
||||
return "active";
|
||||
}
|
||||
|
||||
export const TOKEN_STATUS_LABEL: Record<TokenStatus, string> = {
|
||||
active: "Active",
|
||||
expiring: "Expiring",
|
||||
expired: "Expired",
|
||||
revoked: "Revoked",
|
||||
};
|
||||
|
||||
/** Count of tokens that can currently authenticate (not revoked, not expired). */
|
||||
export function activeTokenCount(gateway: ToolMcpGatewayWithTokens, now: number = Date.now()): number {
|
||||
return gateway.tokens.filter((token) => {
|
||||
const status = tokenStatus(token, now);
|
||||
return status === "active" || status === "expiring";
|
||||
}).length;
|
||||
}
|
||||
|
||||
export function expiringTokenCount(gateway: ToolMcpGatewayWithTokens, now: number = Date.now()): number {
|
||||
return gateway.tokens.filter((token) => tokenStatus(token, now) === "expiring").length;
|
||||
}
|
||||
|
||||
export function latestTokenActivity(gateway: ToolMcpGatewayWithTokens): Date | null {
|
||||
return gateway.tokens.reduce<Date | null>((latest, token) => {
|
||||
const candidate = toDate(token.lastUsedAt);
|
||||
if (!candidate) return latest;
|
||||
return !latest || candidate.getTime() > latest.getTime() ? candidate : latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function shortId(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return value.length > 12 ? `${value.slice(0, 8)}…` : value;
|
||||
}
|
||||
|
||||
/** Prosumer "Scope" label — Company / Project · X / Agent · X. */
|
||||
export function formatScope(
|
||||
gateway: ToolMcpGatewayWithTokens,
|
||||
projectNames: Map<string, string>,
|
||||
agentNames: Map<string, string>,
|
||||
): string {
|
||||
if (gateway.contextScopeType !== "none" && gateway.contextScopeId) {
|
||||
if (gateway.contextScopeType === "project") {
|
||||
return `Project · ${projectNames.get(gateway.contextScopeId) ?? shortId(gateway.contextScopeId)}`;
|
||||
}
|
||||
if (gateway.contextScopeType === "agent") {
|
||||
return `Agent · ${agentNames.get(gateway.contextScopeId) ?? shortId(gateway.contextScopeId)}`;
|
||||
}
|
||||
return `${gateway.contextScopeType} · ${shortId(gateway.contextScopeId)}`;
|
||||
}
|
||||
if (gateway.projectId) return `Project · ${projectNames.get(gateway.projectId) ?? shortId(gateway.projectId)}`;
|
||||
if (gateway.agentId) return `Agent · ${agentNames.get(gateway.agentId) ?? shortId(gateway.agentId)}`;
|
||||
return "Company";
|
||||
}
|
||||
|
||||
export function formatOwner(gateway: ToolMcpGatewayWithTokens, agentNames: Map<string, string>): string {
|
||||
if (gateway.createdByAgentId) {
|
||||
return agentNames.get(gateway.createdByAgentId) ?? `Agent ${shortId(gateway.createdByAgentId)}`;
|
||||
}
|
||||
return "Board";
|
||||
}
|
||||
|
||||
/** Whether the gateway is exposing tools to clients right now. */
|
||||
export function isGatewayOn(gateway: ToolMcpGatewayWithTokens): boolean {
|
||||
return gateway.status === "active";
|
||||
}
|
||||
|
||||
/** Human summary of how many tools a profile allows. */
|
||||
export function allowedToolsLabel(profile: ToolProfileWithDetails | undefined): string {
|
||||
if (!profile) return "Profile unavailable";
|
||||
const { accessMode, allowedToolCount, totalToolCount, excludedToolCount } = profile.summary;
|
||||
const count =
|
||||
accessMode === "all_except"
|
||||
? Math.max(totalToolCount - excludedToolCount, 0)
|
||||
: allowedToolCount;
|
||||
if (count === 0) return "No tools allowed";
|
||||
return `${count} ${count === 1 ? "tool" : "tools"}`;
|
||||
}
|
||||
|
||||
export type GatewayAppRow = {
|
||||
application: ToolApplication;
|
||||
connection: ToolConnection | null;
|
||||
toolCount: number;
|
||||
needsAttention: boolean;
|
||||
attentionReason: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the "Apps in this gateway" list from the bound profile's include
|
||||
* entries, joined to the company's applications and connections. Missing
|
||||
* credentials / unreachable servers surface through the connection health
|
||||
* status, mirroring the connection-level "Needs attention" contract — the
|
||||
* gateway does not own a separate credential store.
|
||||
*/
|
||||
export function deriveGatewayApps(
|
||||
profile: ToolProfileWithDetails | undefined,
|
||||
applications: ToolApplication[],
|
||||
connections: ToolConnection[],
|
||||
): GatewayAppRow[] {
|
||||
if (!profile) return [];
|
||||
const applicationsById = new Map(applications.map((app) => [app.id, app]));
|
||||
const connectionsById = new Map(connections.map((conn) => [conn.id, conn]));
|
||||
const connectionsByApplication = new Map<string, ToolConnection[]>();
|
||||
for (const conn of connections) {
|
||||
connectionsByApplication.set(conn.applicationId, [
|
||||
...(connectionsByApplication.get(conn.applicationId) ?? []),
|
||||
conn,
|
||||
]);
|
||||
}
|
||||
|
||||
const toolCountByApp = new Map<string, number>();
|
||||
const includedApplicationIds = new Set<string>();
|
||||
for (const entry of profile.entries ?? []) {
|
||||
if (entry.effect !== "include") continue;
|
||||
let applicationId = entry.applicationId ?? null;
|
||||
if (!applicationId && entry.connectionId) {
|
||||
applicationId = connectionsById.get(entry.connectionId)?.applicationId ?? null;
|
||||
}
|
||||
if (!applicationId) continue;
|
||||
includedApplicationIds.add(applicationId);
|
||||
if (entry.catalogEntryId || entry.toolName) {
|
||||
toolCountByApp.set(applicationId, (toolCountByApp.get(applicationId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: GatewayAppRow[] = [];
|
||||
for (const applicationId of includedApplicationIds) {
|
||||
const application = applicationsById.get(applicationId);
|
||||
if (!application || application.status === "archived") continue;
|
||||
const appConnections = connectionsByApplication.get(applicationId) ?? [];
|
||||
const connection =
|
||||
appConnections.find((conn) => conn.status !== "archived") ?? appConnections[0] ?? null;
|
||||
const attentionConnection = appConnections.find((conn) =>
|
||||
isToolConnectionAttentionHealth(conn.healthStatus),
|
||||
);
|
||||
rows.push({
|
||||
application,
|
||||
connection,
|
||||
toolCount: toolCountByApp.get(applicationId) ?? 0,
|
||||
needsAttention: Boolean(attentionConnection),
|
||||
attentionReason: attentionConnection
|
||||
? "Sign-in expired — reconnect to restore access."
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => a.application.name.localeCompare(b.application.name));
|
||||
}
|
||||
|
||||
export function gatewayAppDisplayName(row: GatewayAppRow): string {
|
||||
return row.connection
|
||||
? humanizeConnectionDisplayName(row.connection)
|
||||
: humanizeConnectionDisplayName(row.application.name);
|
||||
}
|
||||
|
||||
const SNIPPET_CLIENT_ORDER: ToolMcpGatewayClientSnippet["client"][] = [
|
||||
"cursor",
|
||||
"claude_desktop",
|
||||
"vscode",
|
||||
"claude_code",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
export function orderedSnippets(
|
||||
snippets: ToolMcpGatewayClientSnippet[],
|
||||
): ToolMcpGatewayClientSnippet[] {
|
||||
return [...snippets].sort(
|
||||
(a, b) => SNIPPET_CLIENT_ORDER.indexOf(a.client) - SNIPPET_CLIENT_ORDER.indexOf(b.client),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSnippetConfig(config: Record<string, unknown>): string {
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Activity, LayoutGrid, KeyRound, Wrench, Boxes } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Gateway detail tabs (PAP-11200). Terminology is locked by the approved
|
||||
* PAP-11178 design of record: Overview · Apps & tools · Tokens · Activity ·
|
||||
* Advanced. Raw protocol / JSON / transport details live under Advanced.
|
||||
*/
|
||||
export const GATEWAY_TABS = [
|
||||
{ key: "overview", label: "Overview", icon: LayoutGrid },
|
||||
{ key: "apps", label: "Apps & tools", icon: Boxes },
|
||||
{ key: "tokens", label: "Tokens", icon: KeyRound },
|
||||
{ key: "activity", label: "Activity", icon: Activity },
|
||||
{ key: "advanced", label: "Advanced", icon: Wrench },
|
||||
] as const;
|
||||
|
||||
export type GatewayTabKey = (typeof GATEWAY_TABS)[number]["key"];
|
||||
|
||||
export function gatewayTabHref(gatewayId: string, tab: GatewayTabKey): string {
|
||||
return `/apps/gateways/${gatewayId}/${tab}`;
|
||||
}
|
||||
|
||||
export function isGatewayTabKey(value: string | undefined): value is GatewayTabKey {
|
||||
return GATEWAY_TABS.some((tab) => tab.key === value);
|
||||
}
|
||||
|
||||
export function gatewayTabLabel(tabKey: GatewayTabKey): string {
|
||||
return GATEWAY_TABS.find((tab) => tab.key === tabKey)?.label ?? "Overview";
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { allowedToolsLabel, type GatewayAppRow, gatewayAppDisplayName } from "../gateway-helpers";
|
||||
|
||||
/**
|
||||
* Apps & tools tab — which apps this gateway exposes and how many tools each
|
||||
* contributes, derived from the bound access profile. Missing credentials
|
||||
* surface as "Needs attention", carried from the connection health status.
|
||||
*/
|
||||
export function AppsToolsPanel({
|
||||
apps,
|
||||
profile,
|
||||
}: {
|
||||
apps: GatewayAppRow[];
|
||||
profile: ToolProfileWithDetails | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These apps go through this gateway. The bound profile
|
||||
{profile ? ` (${profile.name})` : ""} decides which tools are allowed
|
||||
{profile ? ` — ${allowedToolsLabel(profile)}.` : "."} Change the profile under Advanced.
|
||||
</p>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No apps are assigned to this gateway’s profile yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full min-w-(--sz-32rem) text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">App</th>
|
||||
<th className="px-4 py-2.5">Tools</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apps.map((app) => {
|
||||
const href = app.connection
|
||||
? `/apps/${app.connection.id}/setup`
|
||||
: `/apps/app/${app.application.id}/setup`;
|
||||
return (
|
||||
<tr key={app.application.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={href} className="font-medium text-foreground hover:underline">
|
||||
{gatewayAppDisplayName(app)}
|
||||
</Link>
|
||||
{app.needsAttention && app.attentionReason ? (
|
||||
<div className="text-xs text-muted-foreground">{app.attentionReason}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{app.toolCount} {app.toolCount === 1 ? "tool" : "tools"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
app.needsAttention
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{app.needsAttention ? "Needs attention" : "Healthy"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link to={href} className="text-xs font-medium text-primary hover:underline">
|
||||
Open →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { toolsApi, type ToolAuditOutcome, type ToolGatewayActivityEvent } from "@/api/tools";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OUTCOME_LABEL: Record<ToolAuditOutcome, string> = {
|
||||
allowed: "Allowed",
|
||||
blocked: "Blocked",
|
||||
asked_first: "Ask first",
|
||||
waiting: "Waiting",
|
||||
failed: "Failed",
|
||||
unknown: "—",
|
||||
};
|
||||
|
||||
const OUTCOME_CLASS: Record<ToolAuditOutcome, string> = {
|
||||
allowed: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
blocked: "border-foreground bg-foreground text-background",
|
||||
asked_first: "border-foreground bg-foreground text-background",
|
||||
waiting: "border-border bg-muted text-muted-foreground",
|
||||
failed: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
unknown: "border-border bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
function belongsToGateway(event: ToolGatewayActivityEvent, gateway: ToolMcpGatewayWithTokens): boolean {
|
||||
const details = event.details ?? {};
|
||||
return (
|
||||
details.gatewayId === gateway.id ||
|
||||
details.gatewayPublicId === gateway.gatewayPublicId ||
|
||||
details.gatewaySlug === gateway.displaySlug
|
||||
);
|
||||
}
|
||||
|
||||
export function GatewayActivityPanel({
|
||||
companyId,
|
||||
gateway,
|
||||
}: {
|
||||
companyId: string;
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
}) {
|
||||
const activityQuery = useQuery({
|
||||
queryKey: ["tools", "gateway-activity", companyId, gateway.id],
|
||||
queryFn: () => toolsApi.listActivity(companyId, { window: "7d", limit: 100 }),
|
||||
});
|
||||
|
||||
const events = useMemo(
|
||||
() => (activityQuery.data?.events ?? []).filter((event) => belongsToGateway(event, gateway)),
|
||||
[activityQuery.data, gateway],
|
||||
);
|
||||
|
||||
if (activityQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activityQuery.isError) {
|
||||
return <ErrorState error={activityQuery.error} onRetry={() => activityQuery.refetch()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every call through this gateway in the last 7 days, with why it was allowed, blocked, or paused.
|
||||
</p>
|
||||
{events.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No calls have gone through this gateway yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{events.map((event) => {
|
||||
const outcome = event.normalizedOutcome;
|
||||
const tool = event.toolDisplayName ?? "tool";
|
||||
const app = event.appDisplayName ?? event.applicationDisplayName ?? "app";
|
||||
const actor = event.agentDisplayName ?? "Client";
|
||||
return (
|
||||
<li key={event.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{actor}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{app} · {tool} · <RelativeTime value={event.createdAt} />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
OUTCOME_CLASS[outcome],
|
||||
)}
|
||||
>
|
||||
{OUTCOME_LABEL[outcome]}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy } from "lucide-react";
|
||||
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { gatewaysQueryKey } from "../NewGatewayDialog";
|
||||
|
||||
/**
|
||||
* Advanced tab — raw protocol/transport details, config JSON and the archive
|
||||
* (destructive) action live here, out of the default prosumer view per the
|
||||
* PAP-11174 contract's default-vs-Advanced split.
|
||||
*/
|
||||
export function GatewayAdvancedPanel({
|
||||
companyId,
|
||||
gateway,
|
||||
}: {
|
||||
companyId: string;
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { pushToast } = useToast();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [confirmName, setConfirmName] = useState("");
|
||||
|
||||
const endpoint = `${typeof window !== "undefined" ? window.location.origin : ""}${gateway.endpointPath}`;
|
||||
const rawConfig = JSON.stringify(
|
||||
{
|
||||
gatewayPublicId: gateway.gatewayPublicId,
|
||||
displaySlug: gateway.displaySlug,
|
||||
status: gateway.status,
|
||||
profileId: gateway.profileId,
|
||||
defaultProfileMode: gateway.defaultProfileMode,
|
||||
contextScopeType: gateway.contextScopeType,
|
||||
contextScopeId: gateway.contextScopeId,
|
||||
endpointPath: gateway.endpointPath,
|
||||
authConfig: gateway.authConfig,
|
||||
headerPolicy: gateway.headerPolicy,
|
||||
metadataPolicy: gateway.metadataPolicy,
|
||||
onDemandToolsConfig: gateway.onDemandToolsConfig,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => toolsApi.updateGateway(companyId, gateway.id, { status: "archived" }),
|
||||
onSuccess: async () => {
|
||||
pushToast({ title: "Gateway archived", body: `${gateway.name} is no longer reachable.`, tone: "success" });
|
||||
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
|
||||
navigate("/apps/gateways");
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't archive the gateway",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
async function copy(value: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
pushToast({ title: "Copied", body: label, tone: "success" });
|
||||
} catch {
|
||||
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Transport</h3>
|
||||
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
|
||||
<Row label="Transport" value="streamable_http" />
|
||||
<Row label="Authentication" value="bearer" />
|
||||
<Row label="Protocol version" value="2025-03-26" />
|
||||
<Row label="Public ID" value={gateway.gatewayPublicId} mono />
|
||||
</dl>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{endpoint}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => void copy(endpoint, "Endpoint URL")}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Raw configuration</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => void copy(rawConfig, "Gateway config JSON")}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy JSON
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
|
||||
{rawConfig}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-lg border border-destructive/40 p-4">
|
||||
<h3 className="text-sm font-semibold text-destructive">Danger zone</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Archiving takes the gateway offline for every client. Existing tokens stop working. Type the
|
||||
gateway name to confirm.
|
||||
</p>
|
||||
{confirming ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={confirmName}
|
||||
onChange={(e) => setConfirmName(e.target.value)}
|
||||
placeholder={gateway.name}
|
||||
aria-label="Type the gateway name to confirm archive"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={confirmName.trim() !== gateway.name || archiveMutation.isPending}
|
||||
onClick={() => archiveMutation.mutate()}
|
||||
>
|
||||
{archiveMutation.isPending ? "Archiving…" : "Archive gateway"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setConfirming(false); setConfirmName(""); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setConfirming(true)}>
|
||||
Archive gateway
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className={mono ? "mt-0.5 font-mono text-foreground" : "mt-0.5 text-foreground"}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import type { ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
activeTokenCount,
|
||||
allowedToolsLabel,
|
||||
expiringTokenCount,
|
||||
formatScope,
|
||||
type GatewayAppRow,
|
||||
gatewayAppDisplayName,
|
||||
isGatewayOn,
|
||||
} from "../gateway-helpers";
|
||||
|
||||
export function OverviewPanel({
|
||||
gateway,
|
||||
profile,
|
||||
apps,
|
||||
agentNames,
|
||||
projectNames,
|
||||
toggleDisabled,
|
||||
onToggle,
|
||||
}: {
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
profile: ToolProfileWithDetails | undefined;
|
||||
apps: GatewayAppRow[];
|
||||
agentNames: Map<string, string>;
|
||||
projectNames: Map<string, string>;
|
||||
toggleDisabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const endpoint = `${typeof window !== "undefined" ? window.location.origin : ""}${gateway.endpointPath}`;
|
||||
const active = activeTokenCount(gateway);
|
||||
const expiring = expiringTokenCount(gateway);
|
||||
const needsAttention = apps.filter((app) => app.needsAttention);
|
||||
const on = isGatewayOn(gateway);
|
||||
|
||||
const snippet = [
|
||||
"{",
|
||||
' "mcpServers": {',
|
||||
` "paperclip-${gateway.displaySlug}": {`,
|
||||
` "url": "${endpoint}",`,
|
||||
' "headers": { "Authorization": "Bearer pcgw_•••_TOKEN" }',
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
async function copy(value: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
pushToast({ title: "Copied", body: label, tone: "success" });
|
||||
} catch {
|
||||
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="text-xs font-medium text-muted-foreground">{on ? "On" : "Off"}</div>
|
||||
<div className="mt-2">
|
||||
<ToggleSwitch checked={on} disabled={toggleDisabled} onCheckedChange={onToggle} aria-label="Toggle gateway" />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Toggle the whole gateway off here.</p>
|
||||
</div>
|
||||
<StatCard label="Apps">
|
||||
{apps.length} {apps.length === 1 ? "app" : "apps"}
|
||||
{profile ? ` · ${allowedToolsLabel(profile)}` : ""}
|
||||
</StatCard>
|
||||
<StatCard label="Tokens">
|
||||
{active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
|
||||
</StatCard>
|
||||
<StatCard label="Health">
|
||||
{needsAttention.length === 0 ? "All green" : `${needsAttention.length} needs attention`}
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Who can use it</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Anyone holding an active token below, restricted by the rules in the bound profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Chip>Scope · {formatScope(gateway, projectNames, agentNames)}</Chip>
|
||||
<Chip>Profile · {profile?.name ?? "Unavailable"}</Chip>
|
||||
<Chip>{active} active {active === 1 ? "token" : "tokens"}</Chip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Apps in this gateway</h3>
|
||||
{apps.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This gateway’s profile doesn’t include any apps yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{apps.map((app) => (
|
||||
<AppRow key={app.application.id} app={app} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">How clients connect</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => void copy(snippet, "Client config")}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-auto whitespace-pre-wrap break-words rounded bg-background p-3 font-mono text-xs text-muted-foreground">
|
||||
{snippet}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-border px-3 py-1 text-xs font-medium text-foreground">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRow({ app }: { app: GatewayAppRow }) {
|
||||
const href = app.connection ? `/apps/${app.connection.id}/setup` : `/apps/app/${app.application.id}/setup`;
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<Link to={href} className="font-medium text-foreground hover:underline">
|
||||
{gatewayAppDisplayName(app)}
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{app.toolCount} {app.toolCount === 1 ? "tool" : "tools"}
|
||||
{app.needsAttention && app.attentionReason ? ` · ${app.attentionReason}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
app.needsAttention
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{app.needsAttention ? "Needs attention" : "Healthy"}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ToolMcpGatewayToken, ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TokensPanel } from "./TokensPanel";
|
||||
|
||||
const createGatewayTokenMock = vi.hoisted(() => vi.fn());
|
||||
const revokeGatewayTokenMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
createGatewayToken: (...args: unknown[]) => createGatewayTokenMock(...args),
|
||||
revokeGatewayToken: (...args: unknown[]) => revokeGatewayTokenMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function token(overrides: Partial<ToolMcpGatewayToken> = {}): ToolMcpGatewayToken {
|
||||
return {
|
||||
id: "token-1",
|
||||
companyId: "company-1",
|
||||
gatewayId: "gateway-1",
|
||||
name: "cto-cursor",
|
||||
tokenPrefix: "pcgw_live_8x4Pa",
|
||||
subjectType: "gateway_client",
|
||||
subjectId: null,
|
||||
clientLabel: "Cursor",
|
||||
ownerNote: "Local IDE",
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
expiresAt: "2026-09-01T00:00:00.000Z",
|
||||
expiryOverrideReason: null,
|
||||
expiryOverrideByUserId: null,
|
||||
expiryOverrideByAgentId: null,
|
||||
expiryOverrideAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function gateway(overrides: Partial<ToolMcpGatewayWithTokens> = {}): ToolMcpGatewayWithTokens {
|
||||
return {
|
||||
id: "gateway-1",
|
||||
companyId: "company-1",
|
||||
gatewayPublicId: "gw",
|
||||
name: "CTO agents",
|
||||
displaySlug: "cto-agents",
|
||||
slug: "cto-agents",
|
||||
description: null,
|
||||
status: "active",
|
||||
profileId: "profile-1",
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "none",
|
||||
contextScopeId: null,
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
approvalIssueId: null,
|
||||
endpointPath: "/g/cto-agents/mcp",
|
||||
authConfig: {} as ToolMcpGatewayWithTokens["authConfig"],
|
||||
headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"],
|
||||
metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"],
|
||||
onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"],
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tokens: [],
|
||||
clientSnippets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TokensPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-16T12:00:00.000Z").getTime());
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function render(node: ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function clickButton(label: string) {
|
||||
const button = [...container.querySelectorAll("button")].find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
);
|
||||
if (!button) throw new Error(`Button "${label}" not found`);
|
||||
return act(async () => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function setInput(selectorText: string, value: string) {
|
||||
const input = container.querySelector<HTMLInputElement>(selectorText);
|
||||
if (!input) throw new Error(`Input ${selectorText} not found`);
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
return act(async () => {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("masks existing tokens and never renders the full secret at rest", async () => {
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
|
||||
expect(container.textContent).toContain("pcgw_live_8x4Pa•••");
|
||||
expect(container.textContent).toContain("Active");
|
||||
});
|
||||
|
||||
it("mints a token and reveals it once, then copies the full value", async () => {
|
||||
const created = { ...token({ id: "new-token" }), token: "pcgw_live_FULLSECRETVALUE" };
|
||||
createGatewayTokenMock.mockResolvedValue(created);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway()} />);
|
||||
|
||||
await clickButton("Mint token"); // open the mint form
|
||||
await setInput('input[placeholder="cto-cursor"]', "cto-cursor");
|
||||
const form = container.querySelector("form");
|
||||
if (!form) throw new Error("mint form not found");
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(createGatewayTokenMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("New token — copy now");
|
||||
// Reveal-once banner shows the full value immediately after creation.
|
||||
expect(container.textContent).toContain("pcgw_live_FULLSECRETVALUE");
|
||||
|
||||
await clickButton("Copy");
|
||||
expect(writeText).toHaveBeenCalledWith("pcgw_live_FULLSECRETVALUE");
|
||||
});
|
||||
|
||||
it("gates revoke behind a typed confirmation matching the token name", async () => {
|
||||
revokeGatewayTokenMock.mockResolvedValue(token({ revokedAt: "2026-06-16T12:00:00.000Z" }));
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
|
||||
|
||||
await clickButton("Revoke");
|
||||
const confirmButton = [...container.querySelectorAll("button")].find(
|
||||
(el) => el.textContent?.trim() === "Revoke token",
|
||||
) as HTMLButtonElement;
|
||||
expect(confirmButton.disabled).toBe(true);
|
||||
|
||||
await setInput('input[aria-label="Type the token name to confirm"]', "cto-cursor");
|
||||
expect(confirmButton.disabled).toBe(false);
|
||||
|
||||
await clickButton("Revoke token");
|
||||
await flushReact();
|
||||
expect(revokeGatewayTokenMock).toHaveBeenCalledWith("company-1", "token-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, KeyRound, Plus } from "lucide-react";
|
||||
import type {
|
||||
ToolMcpGatewayTokenAction,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayWithTokens,
|
||||
} from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { RelativeTime } from "@/pages/tools/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { gatewaysQueryKey } from "../NewGatewayDialog";
|
||||
import { maskedTokenLabel, TOKEN_STATUS_LABEL, tokenStatus, type TokenStatus } from "../gateway-helpers";
|
||||
|
||||
const DEFAULT_ACTIONS: ToolMcpGatewayTokenAction[] = ["tools/list", "tools/call"];
|
||||
|
||||
function defaultExpiry(): string {
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<TokenStatus, string> = {
|
||||
active: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
expiring: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
expired: "border-border bg-muted text-muted-foreground",
|
||||
revoked: "border-foreground bg-foreground text-background",
|
||||
};
|
||||
|
||||
/** Token status pill, shared by the desktop table and mobile cards. */
|
||||
function StatusBadge({ status }: { status: TokenStatus }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
STATUS_CLASS[status],
|
||||
)}
|
||||
>
|
||||
{TOKEN_STATUS_LABEL[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label:value pair inside a mobile stacked card. */
|
||||
function TokenField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 truncate text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokensPanel({
|
||||
companyId,
|
||||
gateway,
|
||||
onTokenCreated,
|
||||
}: {
|
||||
companyId: string;
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
onTokenCreated?: (token: ToolMcpGatewayTokenCreated) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [minting, setMinting] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [clientLabel, setClientLabel] = useState("");
|
||||
const [ownerNote, setOwnerNote] = useState("");
|
||||
const [expiresAt, setExpiresAt] = useState(defaultExpiry());
|
||||
const [created, setCreated] = useState<ToolMcpGatewayTokenCreated | null>(null);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [revokeName, setRevokeName] = useState("");
|
||||
const [confirmToken, setConfirmToken] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.createGatewayToken(companyId, gateway.id, {
|
||||
name: name.trim(),
|
||||
clientLabel: clientLabel.trim() || name.trim(),
|
||||
ownerNote: ownerNote.trim() || name.trim(),
|
||||
allowedActions: DEFAULT_ACTIONS,
|
||||
expiresAt: expiresAt ? `${expiresAt}T23:59:59.000Z` : null,
|
||||
}),
|
||||
onSuccess: async (token) => {
|
||||
setCreated(token);
|
||||
setRevealed(true);
|
||||
setMinting(false);
|
||||
setName("");
|
||||
setClientLabel("");
|
||||
setOwnerNote("");
|
||||
setExpiresAt(defaultExpiry());
|
||||
pushToast({
|
||||
title: "Token minted",
|
||||
body: "Copy it now — you won’t see the full value again.",
|
||||
tone: "success",
|
||||
});
|
||||
onTokenCreated?.(token);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Token was not minted",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (tokenId: string) => toolsApi.revokeGatewayToken(companyId, tokenId),
|
||||
onSuccess: async (token) => {
|
||||
setConfirmToken(null);
|
||||
setRevokeName("");
|
||||
pushToast({ title: "Token revoked", body: `${token.name} can no longer connect.`, tone: "success" });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Token was not revoked",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
async function copyToken(value: string) {
|
||||
try {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
throw new Error("Clipboard access is unavailable.");
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
pushToast({ title: "Copied", body: "Access token", tone: "success" });
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
title: "Copy failed",
|
||||
body: error instanceof Error ? error.message : "Clipboard access is unavailable.",
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
createMutation.mutate();
|
||||
}
|
||||
|
||||
const tokens = useMemo(
|
||||
() =>
|
||||
[...gateway.tokens].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
[gateway.tokens],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each token is a separate way in. Revoke any one without breaking the others.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setMinting((value) => !value)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Mint token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{minting ? (
|
||||
<form className="space-y-3 rounded-md border border-border p-4" onSubmit={submit}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="cto-cursor" required autoFocus />
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Owner / client</span>
|
||||
<Input
|
||||
value={clientLabel}
|
||||
onChange={(e) => setClientLabel(e.target.value)}
|
||||
placeholder="Cursor on work laptop"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Note (why it exists)</span>
|
||||
<Input value={ownerNote} onChange={(e) => setOwnerNote(e.target.value)} placeholder="Dotta’s MacBook" />
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Expires</span>
|
||||
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} required />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setMinting(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={createMutation.isPending || !name.trim()}>
|
||||
{createMutation.isPending ? "Minting…" : "Mint token"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{created ? (
|
||||
<div className="space-y-2 rounded-md border-2 border-foreground/80 bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">New token — copy now</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
You won’t see the full value again. Store it in your client’s config or your secret manager.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreated(null)} aria-label="Dismiss new token">
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background px-3 py-2 font-mono text-xs text-foreground">
|
||||
{revealed ? created.token : maskedTokenLabel(created)}
|
||||
</code>
|
||||
{revealed ? (
|
||||
<Button variant="outline" size="sm" onClick={() => void copyToken(created.token)}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
|
||||
Show
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tokens.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No tokens yet. Mint one for the client that will connect to this gateway.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop / tablet: full table. */}
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
|
||||
<table className="w-full min-w-(--sz-44rem) text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Token</th>
|
||||
<th className="px-4 py-2.5">Owner</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => {
|
||||
const status = tokenStatus(token);
|
||||
const canRevoke = status !== "revoked";
|
||||
return (
|
||||
<tr key={token.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{token.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{token.clientLabel || token.ownerNote || "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground"><RelativeTime value={token.createdAt} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canRevoke ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setConfirmToken({ id: token.id, name: token.name });
|
||||
setRevokeName("");
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards so status + Revoke stay reachable. */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{tokens.map((token) => {
|
||||
const status = tokenStatus(token);
|
||||
const canRevoke = status !== "revoked";
|
||||
return (
|
||||
<div key={token.id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{token.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<TokenField label="Owner" value={token.clientLabel || token.ownerNote || "—"} />
|
||||
<TokenField label="Created" value={<RelativeTime value={token.createdAt} />} />
|
||||
<TokenField
|
||||
label="Last used"
|
||||
value={token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
|
||||
/>
|
||||
<TokenField
|
||||
label="Expires"
|
||||
value={
|
||||
token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
{canRevoke ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3 w-full text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setConfirmToken({ id: token.id, name: token.name });
|
||||
setRevokeName("");
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Every mint, reveal, and revoke is recorded in Activity.
|
||||
</p>
|
||||
|
||||
{confirmToken ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-md space-y-3 rounded-lg border border-border bg-card p-5 shadow-lg">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Revoke this token?</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Any client using <span className="font-medium text-foreground">{confirmToken.name}</span> goes
|
||||
silent immediately. This can’t be undone. Type the token name to confirm.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
value={revokeName}
|
||||
onChange={(e) => setRevokeName(e.target.value)}
|
||||
placeholder={confirmToken.name}
|
||||
aria-label="Type the token name to confirm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirmToken(null);
|
||||
setRevokeName("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={revokeName.trim() !== confirmToken.name || revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate(confirmToken.id)}
|
||||
>
|
||||
{revokeMutation.isPending ? "Revoking…" : "Revoke token"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export function parseGoogleSheetIds(value: string): { ids: string[]; invalidCount: number } {
|
||||
const ids: string[] = [];
|
||||
let invalidCount = 0;
|
||||
for (const rawToken of value.split(/[\s,]+/g)) {
|
||||
const token = rawToken.trim().replace(/[),.;\]]+$/g, "");
|
||||
if (!token) continue;
|
||||
try {
|
||||
const parsed = new URL(token);
|
||||
const match = parsed.pathname.match(/\/spreadsheets\/d\/([^/]+)/i);
|
||||
if (match?.[1]) {
|
||||
ids.push(decodeURIComponent(match[1]));
|
||||
continue;
|
||||
}
|
||||
invalidCount += 1;
|
||||
} catch {
|
||||
invalidCount += 1;
|
||||
}
|
||||
}
|
||||
return { ids: Array.from(new Set(ids)), invalidCount };
|
||||
}
|
||||
|
||||
export function googleSheetsConfigWithAllowlist(
|
||||
currentConfig: Record<string, unknown> | null | undefined,
|
||||
allowedSpreadsheetIds: string[],
|
||||
): Record<string, unknown> {
|
||||
const env = currentConfig && typeof currentConfig.env === "object" && !Array.isArray(currentConfig.env)
|
||||
? currentConfig.env as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
...(currentConfig ?? {}),
|
||||
allowedSpreadsheetIds,
|
||||
env: {
|
||||
...env,
|
||||
GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: allowedSpreadsheetIds.join(","),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ServerCog, Wrench } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { advancedTabHref } from "@/pages/tools/tool-tabs";
|
||||
|
||||
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
|
||||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "linear"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
|
||||
/** Zapier connects with the complete MCP URL issued by Zapier. */
|
||||
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
|
||||
|
||||
/**
|
||||
* First-class "Connect your own tool" card (PAP-12371, Finding C; PAP-13254).
|
||||
* Lives in Browse as a persistent row and launches the guided URL flow.
|
||||
*/
|
||||
export function ByoConnectCard({ onConnect }: { onConnect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="flex w-full items-center gap-4 rounded-xl border border-dashed border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"
|
||||
>
|
||||
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
<ServerCog className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">Connect your own tool</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paste the URL from a custom or self-hosted MCP server and review its actions before enabling it.
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold text-primary">Connect →</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Labeled door to the developer control-plane (PAP-12371, Finding A cross-link). */
|
||||
export function AdvancedToolsLink() {
|
||||
return (
|
||||
<Link
|
||||
to={advancedTabHref("run-your-own")}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Developer tools (advanced)
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
|
||||
/**
|
||||
* Live count of Ask-first requests waiting for the user's OK (PAP-12371,
|
||||
* Finding B). This is the "decisions waiting on you" signal — deliberately
|
||||
* separate from the health "Needs attention" count so an approval is never
|
||||
* hidden behind an error-triage label. Backs the sidebar "Review" badge and
|
||||
* the persistent review affordance on /apps.
|
||||
*/
|
||||
export function useReviewCount(): number {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.tools.actionRequests(selectedCompanyId ?? "__none__", "pending"),
|
||||
queryFn: () => toolsApi.listActionRequests(selectedCompanyId!, "pending"),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 20_000,
|
||||
});
|
||||
return query.data?.actionRequests.length ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnection,
|
||||
ToolPolicy,
|
||||
ToolProfileEffectiveSummary,
|
||||
ToolProfileNewToolsReview,
|
||||
ToolProfileSummary,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
||||
import { ProfileDetail } from "@/pages/tools/profiles/ProfileDetail";
|
||||
import { ProfilesIndex } from "@/pages/tools/profiles/ProfilesIndex";
|
||||
import { StepAssign, StepName } from "@/pages/tools/profiles/ProfileWizard";
|
||||
import { WizardToolsStep } from "@/pages/tools/profiles/WizardToolsStep";
|
||||
import {
|
||||
groupCatalogByApp,
|
||||
TEMPLATES,
|
||||
type AdvancedRule,
|
||||
type WizardSelections,
|
||||
} from "@/pages/tools/profiles/profile-model";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
// --- Catalog fixtures ------------------------------------------------------
|
||||
|
||||
function makeTool(
|
||||
id: string,
|
||||
toolName: string,
|
||||
cap: "read" | "write" | "destructive",
|
||||
title: string,
|
||||
applicationId: string,
|
||||
connectionId: string,
|
||||
): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
applicationId,
|
||||
connectionId,
|
||||
entryKind: "tool",
|
||||
toolName,
|
||||
title,
|
||||
description: title,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: cap,
|
||||
isReadOnly: cap === "read",
|
||||
isWrite: cap === "write",
|
||||
isDestructive: cap === "destructive",
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const GMAIL_TOOLS: ToolCatalogEntry[] = [
|
||||
makeTool("g-list", "gmail.list_messages", "read", "List messages in a mailbox", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-read", "gmail.get_message", "read", "Read a single message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-search", "gmail.search", "read", "Search the mailbox", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-labels", "gmail.list_labels", "read", "List labels", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-draft", "gmail.create_draft", "write", "Create a draft", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-send", "gmail.send_message", "write", "Send a message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-label", "gmail.modify_labels", "write", "Add or remove labels", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-archive", "gmail.archive", "write", "Archive a thread", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-trash", "gmail.trash_message", "destructive", "Move a message to trash", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-delete", "gmail.delete_message", "destructive", "Permanently delete a message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-purge", "gmail.empty_trash", "destructive", "Empty the trash", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-filter", "gmail.delete_filter", "destructive", "Delete a filter", "app-gmail", "conn-gmail"),
|
||||
];
|
||||
|
||||
const SLACK_TOOLS: ToolCatalogEntry[] = [
|
||||
makeTool("s-list", "slack.list_channels", "read", "List channels", "app-slack", "conn-slack"),
|
||||
makeTool("s-history", "slack.channel_history", "read", "Read channel history", "app-slack", "conn-slack"),
|
||||
makeTool("s-post", "slack.post_message", "write", "Post a message", "app-slack", "conn-slack"),
|
||||
makeTool("s-archive", "slack.archive_channel", "destructive", "Archive a channel", "app-slack", "conn-slack"),
|
||||
];
|
||||
|
||||
const CATALOG = [...GMAIL_TOOLS, ...SLACK_TOOLS];
|
||||
|
||||
const APP_GROUPS = groupCatalogByApp(
|
||||
CATALOG,
|
||||
new Map([
|
||||
["app-gmail", "Gmail"],
|
||||
["app-slack", "Slack"],
|
||||
]),
|
||||
new Map([
|
||||
["conn-gmail", "Gmail"],
|
||||
["conn-slack", "Slack"],
|
||||
]),
|
||||
);
|
||||
|
||||
// --- Profile fixtures ------------------------------------------------------
|
||||
|
||||
function summary(partial: Partial<ToolProfileSummary>): ToolProfileSummary {
|
||||
return {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 0,
|
||||
allowedApplicationCount: 0,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 16,
|
||||
assignmentCount: 0,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function profile(
|
||||
id: string,
|
||||
name: string,
|
||||
status: ToolProfileWithDetails["status"],
|
||||
s: Partial<ToolProfileSummary>,
|
||||
updatedAt: string,
|
||||
): ToolProfileWithDetails {
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
profileKey: id,
|
||||
name,
|
||||
description: null,
|
||||
status,
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date(updatedAt),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: summary(s),
|
||||
};
|
||||
}
|
||||
|
||||
const PROFILES: ToolProfileWithDetails[] = [
|
||||
profile("read-only", "Read-only starter", "active", { allowedToolCount: 6, allowedApplicationCount: 0, appliesToAgentCount: 3 }, "2026-06-11T10:00:00Z"),
|
||||
profile("everyday", "Everyday work", "active", { allowedToolCount: 12, allowedApplicationCount: 1, appliesToAgentCount: 2 }, "2026-06-12T09:00:00Z"),
|
||||
profile("company-baseline", "Company baseline", "active", { accessMode: "all_except", excludedToolCount: 4, isCompanyDefault: true }, "2026-06-09T14:00:00Z"),
|
||||
profile("marketing", "Marketing reach", "active", { allowedToolCount: 3, allowedApplicationCount: 0 }, "2026-06-10T08:00:00Z"),
|
||||
profile("half-built", "Onboarding bot access", "draft", { allowedToolCount: 2 }, "2026-06-12T16:30:00Z"),
|
||||
];
|
||||
|
||||
const DETAIL_PROFILE: ToolProfileWithDetails = {
|
||||
...profile("detail", "Everyday work", "active", { allowedToolCount: 12, allowedApplicationCount: 1, appliesToAgentCount: 2, assignmentCount: 2 }, "2026-06-12T09:00:00Z"),
|
||||
description: "Read and make routine changes across connected work apps.",
|
||||
entries: [
|
||||
{
|
||||
id: "entry-gmail",
|
||||
companyId: COMPANY,
|
||||
profileId: "detail",
|
||||
selectorType: "application",
|
||||
effect: "include",
|
||||
applicationId: "app-gmail",
|
||||
connectionId: null,
|
||||
catalogEntryId: null,
|
||||
toolName: null,
|
||||
riskLevel: null,
|
||||
conditions: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{
|
||||
id: "bind-sage",
|
||||
companyId: COMPANY,
|
||||
profileId: "detail",
|
||||
targetType: "agent",
|
||||
targetId: "a-sage",
|
||||
priority: 0,
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "bind-atlas",
|
||||
companyId: COMPANY,
|
||||
profileId: "detail",
|
||||
targetType: "agent",
|
||||
targetId: "a-atlas",
|
||||
priority: 0,
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const NEW_TOOLS_PROFILE: ToolProfileWithDetails = {
|
||||
...DETAIL_PROFILE,
|
||||
id: "gmail-new-tools",
|
||||
name: "Gmail everyday",
|
||||
newToolsPendingCount: 3,
|
||||
};
|
||||
|
||||
const AUTO_ALLOW_PROFILE: ToolProfileWithDetails = {
|
||||
...DETAIL_PROFILE,
|
||||
id: "gmail-auto-allow",
|
||||
name: "Gmail auto-allow",
|
||||
defaultAction: "allow",
|
||||
};
|
||||
|
||||
const NEW_TOOLS_REVIEW: ToolProfileNewToolsReview = {
|
||||
profileId: NEW_TOOLS_PROFILE.id,
|
||||
reviewedAt: new Date("2026-05-01T00:00:00Z"),
|
||||
pendingCount: 3,
|
||||
tools: [
|
||||
{
|
||||
catalogEntryId: "g-send",
|
||||
applicationId: "app-gmail",
|
||||
applicationName: "Gmail",
|
||||
connectionId: "conn-gmail",
|
||||
connectionName: "Gmail",
|
||||
toolName: "gmail.send_message",
|
||||
title: "Send a message",
|
||||
description: "Send a message from Gmail.",
|
||||
capability: "write",
|
||||
riskLevel: "write",
|
||||
addedAt: new Date("2026-05-28T00:00:00Z"),
|
||||
firstSeenAt: new Date("2026-05-28T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
catalogEntryId: "g-label",
|
||||
applicationId: "app-gmail",
|
||||
applicationName: "Gmail",
|
||||
connectionId: "conn-gmail",
|
||||
connectionName: "Gmail",
|
||||
toolName: "gmail.modify_labels",
|
||||
title: "Add or remove labels",
|
||||
description: "Apply labels to existing messages.",
|
||||
capability: "write",
|
||||
riskLevel: "write",
|
||||
addedAt: new Date("2026-05-28T00:00:00Z"),
|
||||
firstSeenAt: new Date("2026-05-28T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
catalogEntryId: "g-trash",
|
||||
applicationId: "app-gmail",
|
||||
applicationName: "Gmail",
|
||||
connectionId: "conn-gmail",
|
||||
connectionName: "Gmail",
|
||||
toolName: "gmail.trash_message",
|
||||
title: "Move a message to trash",
|
||||
description: "Move a message out of the inbox and into trash.",
|
||||
capability: "destructive",
|
||||
riskLevel: "destructive",
|
||||
addedAt: new Date("2026-05-28T00:00:00Z"),
|
||||
firstSeenAt: new Date("2026-05-28T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CONNECTIONS: ToolConnection[] = [
|
||||
{
|
||||
id: "conn-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
name: "Gmail",
|
||||
connectionKind: "managed",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
installs: [
|
||||
{
|
||||
id: "install-gmail-sage",
|
||||
companyId: COMPANY,
|
||||
connectionId: "conn-gmail",
|
||||
targetType: "agent",
|
||||
targetId: "a-sage",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
} as ToolConnection,
|
||||
{
|
||||
id: "conn-slack",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-slack",
|
||||
name: "Slack",
|
||||
connectionKind: "managed",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolConnection,
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "a-sage", name: "Sage" },
|
||||
{ id: "a-atlas", name: "Atlas" },
|
||||
{ id: "a-nova", name: "Nova" },
|
||||
];
|
||||
|
||||
// --- Seeded index host -----------------------------------------------------
|
||||
|
||||
function SeededIndex({
|
||||
profiles,
|
||||
initialStatusFilter,
|
||||
initialResolverOpen,
|
||||
}: {
|
||||
profiles: ToolProfileWithDetails[];
|
||||
initialStatusFilter?: "active" | "archived";
|
||||
initialResolverOpen?: boolean;
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.profiles(COMPANY), { profiles });
|
||||
c.setQueryData(queryKeys.tools.applications(COMPANY), { applications: [] });
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: [] });
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
c.setQueryData(queryKeys.tools.effectiveProfilesForAgent(COMPANY, "a-sage"), {
|
||||
agentId: "a-sage",
|
||||
profiles: [DETAIL_PROFILE],
|
||||
entries: DETAIL_PROFILE.entries,
|
||||
bindings: DETAIL_PROFILE.bindings,
|
||||
allowedTools: GMAIL_TOOLS.slice(0, 4),
|
||||
allowedToolNames: GMAIL_TOOLS.slice(0, 4).map((tool) => tool.toolName),
|
||||
installedConnections: [],
|
||||
} satisfies ToolProfileEffectiveSummary);
|
||||
c.setQueryData(queryKeys.projects.list(COMPANY), []);
|
||||
c.setQueryData(queryKeys.routines.list(COMPANY), []);
|
||||
return c;
|
||||
}, [profiles]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<ProfilesIndex
|
||||
companyId={COMPANY}
|
||||
initialStatusFilter={initialStatusFilter}
|
||||
initialResolverOpen={initialResolverOpen}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SeededDetail({
|
||||
profile,
|
||||
catalog = CATALOG,
|
||||
connections = CONNECTIONS,
|
||||
initialCreated,
|
||||
initialReviewOpen,
|
||||
}: {
|
||||
profile: ToolProfileWithDetails;
|
||||
catalog?: ToolCatalogEntry[];
|
||||
connections?: ToolConnection[];
|
||||
initialCreated?: boolean;
|
||||
initialReviewOpen?: boolean;
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.profiles(COMPANY), { profiles: [profile, ...PROFILES] });
|
||||
c.setQueryData(queryKeys.tools.applications(COMPANY), {
|
||||
applications: [
|
||||
{ id: "app-gmail", name: "Gmail" },
|
||||
{ id: "app-slack", name: "Slack" },
|
||||
],
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: catalog.filter((tool) => tool.connectionId === "conn-gmail") });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: catalog.filter((tool) => tool.connectionId === "conn-slack") });
|
||||
c.setQueryData(queryKeys.tools.profileNewTools(profile.id), profile.newToolsPendingCount ? NEW_TOOLS_REVIEW : { profileId: profile.id, reviewedAt: null, pendingCount: 0, tools: [] });
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
c.setQueryData(queryKeys.projects.list(COMPANY), []);
|
||||
c.setQueryData(queryKeys.routines.list(COMPANY), []);
|
||||
return c;
|
||||
}, [profile, catalog, connections]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<ProfileDetail
|
||||
companyId={COMPANY}
|
||||
profileId={profile.id}
|
||||
initialCreated={initialCreated}
|
||||
initialReviewOpen={initialReviewOpen}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SeededAgentTools() {
|
||||
const allowed = [...GMAIL_TOOLS.slice(0, 6), SLACK_TOOLS[0]];
|
||||
const policy: ToolPolicy = {
|
||||
id: "policy-write",
|
||||
companyId: COMPANY,
|
||||
name: "Ask first for sending mail",
|
||||
description: "Write tools need a quick human OK.",
|
||||
policyType: "require_approval",
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
selectors: {},
|
||||
conditions: null,
|
||||
config: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
};
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.effectiveProfilesForAgent(COMPANY, "a-sage"), {
|
||||
agentId: "a-sage",
|
||||
profiles: [{ ...DETAIL_PROFILE, summary: summary({ allowedToolCount: allowed.length, allowedApplicationCount: 1, assignmentCount: 1, appliesToAgentCount: 1, isCompanyDefault: true }) }],
|
||||
entries: DETAIL_PROFILE.entries,
|
||||
bindings: DETAIL_PROFILE.bindings,
|
||||
allowedTools: allowed,
|
||||
allowedToolNames: allowed.map((tool) => tool.toolName),
|
||||
installedConnections: CONNECTIONS.filter((connection) => connection.id === "conn-gmail"),
|
||||
} satisfies ToolProfileEffectiveSummary);
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: CONNECTIONS });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL_TOOLS });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK_TOOLS });
|
||||
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies: [policy] });
|
||||
return c;
|
||||
}, [allowed, policy]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<AgentToolsTab agent={{ id: "a-sage", name: "Sage" } as never} companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Access profiles",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const IndexPopulated: Story = {
|
||||
name: "Index — populated",
|
||||
render: () => <SeededIndex profiles={PROFILES} />,
|
||||
};
|
||||
|
||||
export const IndexNewToolsChip: Story = {
|
||||
name: "Index — new tools chip (AP1 v2)",
|
||||
render: () => <SeededIndex profiles={[NEW_TOOLS_PROFILE, ...PROFILES]} />,
|
||||
};
|
||||
|
||||
export const IndexLongProfileName: Story = {
|
||||
name: "Index — long profile name",
|
||||
render: () => (
|
||||
<SeededIndex
|
||||
profiles={[
|
||||
profile(
|
||||
"long-profile-name",
|
||||
"This profile has an exceptionally long name that should truncate before the Allows column",
|
||||
"active",
|
||||
{ allowedToolCount: 14, allowedApplicationCount: 2, appliesToAgentCount: 1 },
|
||||
"2026-06-12T18:00:00Z",
|
||||
),
|
||||
...PROFILES,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AccessCheckerSheet: Story = {
|
||||
name: "Access checker sheet (AP8)",
|
||||
render: () => <SeededIndex profiles={PROFILES} initialResolverOpen />,
|
||||
};
|
||||
|
||||
export const IndexEmpty: Story = {
|
||||
name: "Index — empty (template cards)",
|
||||
render: () => <SeededIndex profiles={[]} />,
|
||||
};
|
||||
|
||||
export const IndexArchived: Story = {
|
||||
name: "Index — archived filter",
|
||||
render: () => <SeededIndex initialStatusFilter="archived" profiles={[...PROFILES, profile("old-profile", "Archived profile", "archived", { allowedToolCount: 5, assignmentCount: 1 }, "2026-06-08T10:00:00Z")]} />,
|
||||
};
|
||||
|
||||
export const DetailAssigned: Story = {
|
||||
name: "Detail — assigned",
|
||||
render: () => <SeededDetail profile={DETAIL_PROFILE} />,
|
||||
};
|
||||
|
||||
export const DetailNewToolsBanner: Story = {
|
||||
name: "Detail — new tools banner (AP16)",
|
||||
render: () => <SeededDetail profile={NEW_TOOLS_PROFILE} />,
|
||||
};
|
||||
|
||||
export const DetailNewToolsModal: Story = {
|
||||
name: "Detail — new tools review modal (AP16)",
|
||||
render: () => <SeededDetail profile={NEW_TOOLS_PROFILE} initialReviewOpen />,
|
||||
};
|
||||
|
||||
export const DetailAutoAllowedRows: Story = {
|
||||
name: "Detail — auto-added row notes (AP16b)",
|
||||
render: () => <SeededDetail profile={AUTO_ALLOW_PROFILE} />,
|
||||
};
|
||||
|
||||
export const AgentAccessProfilesCard: Story = {
|
||||
name: "Agent page — access profiles card (AP19)",
|
||||
render: () => <SeededAgentTools />,
|
||||
};
|
||||
|
||||
export const DetailUnassignedPostCreate: Story = {
|
||||
name: "Detail — unassigned post-create",
|
||||
render: () => (
|
||||
<SeededDetail
|
||||
initialCreated
|
||||
profile={{
|
||||
...DETAIL_PROFILE,
|
||||
id: "detail-unassigned",
|
||||
bindings: [],
|
||||
summary: summary({ allowedToolCount: 12, allowedApplicationCount: 1, assignmentCount: 0, appliesToAgentCount: 0 }),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const DetailArchived: Story = {
|
||||
name: "Detail — archived",
|
||||
render: () => (
|
||||
<SeededDetail
|
||||
profile={{
|
||||
...DETAIL_PROFILE,
|
||||
id: "detail-archived",
|
||||
status: "archived",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const DetailDegraded: Story = {
|
||||
name: "Detail — degraded",
|
||||
render: () => (
|
||||
<SeededDetail
|
||||
profile={{
|
||||
...DETAIL_PROFILE,
|
||||
id: "detail-degraded",
|
||||
summary: summary({ allowedToolCount: 12, allowedApplicationCount: 1, appliesToAgentCount: 2, assignmentCount: 2 }),
|
||||
}}
|
||||
connections={[{ ...CONNECTIONS[0], status: "disabled", healthStatus: "error" } as ToolConnection, CONNECTIONS[1]]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const WizardStep1: Story = {
|
||||
name: "Wizard — step 1 (Name)",
|
||||
render: function Step1() {
|
||||
const [template, setTemplate] = useState<(typeof TEMPLATES)[number]["key"] | null>("everyday");
|
||||
const [name, setName] = useState("Everyday work");
|
||||
const [description, setDescription] = useState("Read and make routine changes — no destructive tools.");
|
||||
const [profileKey, setProfileKey] = useState("everyday");
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<StepName
|
||||
template={template}
|
||||
onTemplate={setTemplate}
|
||||
copyFromId={null}
|
||||
onCopyFrom={() => {}}
|
||||
copyOptions={PROFILES.filter((p) => p.status !== "draft")}
|
||||
name={name}
|
||||
onName={setName}
|
||||
description={description}
|
||||
onDescription={setDescription}
|
||||
profileKey={profileKey}
|
||||
onProfileKey={setProfileKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WizardStep2: Story = {
|
||||
name: "Wizard — step 2 (Choose tools)",
|
||||
render: function Step2() {
|
||||
const [selections, setSelections] = useState<WizardSelections>({
|
||||
"app-gmail": { kind: "all" },
|
||||
"app-slack": { kind: "some", included: ["s-list", "s-history"] },
|
||||
});
|
||||
const [rules, setRules] = useState<AdvancedRule[]>([]);
|
||||
const [action, setAction] = useState<"deny" | "allow">("deny");
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<WizardToolsStep
|
||||
appGroups={APP_GROUPS}
|
||||
catalogLoading={false}
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
advancedRules={rules}
|
||||
onAdvancedRulesChange={setRules}
|
||||
newToolsAction={action}
|
||||
onNewToolsActionChange={setAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WizardStep2Partial: Story = {
|
||||
name: "Wizard — step 2 partial selection (AP4b)",
|
||||
render: function Step2Partial() {
|
||||
const [selections, setSelections] = useState<WizardSelections>({
|
||||
"app-gmail": { kind: "all_except", excluded: ["g-delete", "g-purge"] },
|
||||
"app-slack": { kind: "all" },
|
||||
});
|
||||
const [rules, setRules] = useState<AdvancedRule[]>([
|
||||
{ id: "r1", kind: "risk_level", value: "destructive", riskLevel: "destructive", effect: "exclude" },
|
||||
]);
|
||||
const [action, setAction] = useState<"deny" | "allow">("deny");
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<WizardToolsStep
|
||||
appGroups={APP_GROUPS}
|
||||
catalogLoading={false}
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
advancedRules={rules}
|
||||
onAdvancedRulesChange={setRules}
|
||||
newToolsAction={action}
|
||||
onNewToolsActionChange={setAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WizardStep3: Story = {
|
||||
name: "Wizard — step 3 (Assign)",
|
||||
render: function Step3() {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(["a-sage"]));
|
||||
const [companyDefault, setCompanyDefault] = useState(false);
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<StepAssign
|
||||
agents={AGENTS}
|
||||
profiles={PROFILES}
|
||||
selectedAgentIds={selected}
|
||||
onToggleAgent={(id) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
companyDefault={companyDefault}
|
||||
onCompanyDefault={setCompanyDefault}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
|
||||
|
||||
const binding = {
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api · acme-workspace",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
toolName: "slack.post_message",
|
||||
risk: "medium" as const,
|
||||
isWrite: true,
|
||||
binding,
|
||||
input: { channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false },
|
||||
reason: "This tool can write to your workspace, so a human signs off before the agent posts.",
|
||||
policyNumber: 7,
|
||||
expiresInLabel: "expires in 23h 51m",
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ActionCard> = {
|
||||
title: "Tools & Access/ActionCard",
|
||||
component: ActionCard,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ActionCard>;
|
||||
|
||||
export const Pending: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-xl p-6">
|
||||
<ActionCard {...baseProps} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Stale: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-xl p-6">
|
||||
<ActionCard
|
||||
{...baseProps}
|
||||
variant="stale"
|
||||
binding={{
|
||||
...binding,
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
|
||||
previousCatalogSha256: binding.catalogSha256,
|
||||
}}
|
||||
expiresInLabel="expires in 18h 02m"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DesktopSideBySide: Story = {
|
||||
render: () => (
|
||||
<div className="grid max-w-5xl gap-4 p-6 lg:grid-cols-2">
|
||||
<ActionCard {...baseProps} />
|
||||
<ActionCard
|
||||
{...baseProps}
|
||||
variant="stale"
|
||||
binding={{
|
||||
...binding,
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
|
||||
previousCatalogSha256: binding.catalogSha256,
|
||||
}}
|
||||
expiresInLabel="expires in 18h 02m"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
render: () => (
|
||||
<div className="w-[390px] bg-background p-3">
|
||||
<ActionCardMobile {...baseProps} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Bindings: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-md p-6">
|
||||
<BindingsTable
|
||||
rows={[
|
||||
{ label: "Application", value: "Slack · manifest v2.4.1" },
|
||||
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
|
||||
{ label: "Catalog", value: "sha256:9f86d08188…f00a08", mono: true },
|
||||
{ label: "Payload", value: "sha256:2c26b46b68…66e7ae", mono: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import type { ToolGatewayActivityEvent } from "@/api/tools";
|
||||
import { AuditTab } from "@/pages/tools/AuditTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
function event(overrides: Partial<ToolGatewayActivityEvent> = {}): ToolGatewayActivityEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
companyId: COMPANY,
|
||||
action: "tool_gateway.call_completed",
|
||||
actorType: "agent",
|
||||
actorId: "agent-fable",
|
||||
entityType: "issue",
|
||||
entityId: "run-1",
|
||||
agentId: "agent-fable",
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-gmail",
|
||||
agentDisplayName: "Fable",
|
||||
appDisplayName: "Gmail",
|
||||
applicationDisplayName: "Gmail",
|
||||
connectionDisplayName: "Gmail",
|
||||
toolDisplayName: "Send Email",
|
||||
normalizedOutcome: "allowed",
|
||||
details: {
|
||||
reasonCode: "profile_allows_tool",
|
||||
tool: "mail:send_email",
|
||||
issueId: "issue-1",
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
matchedPolicyIds: ["pol-allow-mail"],
|
||||
},
|
||||
createdAt: new Date(Date.now() - 5 * MIN).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const EVENTS: ToolGatewayActivityEvent[] = [
|
||||
event(),
|
||||
event({
|
||||
id: "evt-2",
|
||||
action: "tool_gateway.call_denied",
|
||||
agentDisplayName: "Scout",
|
||||
actorId: "agent-scout",
|
||||
agentId: "agent-scout",
|
||||
toolDisplayName: "Delete Spreadsheet",
|
||||
appDisplayName: "Google Sheets",
|
||||
applicationDisplayName: "Google Sheets",
|
||||
connectionDisplayName: "Google Sheets",
|
||||
normalizedOutcome: "blocked",
|
||||
details: {
|
||||
reasonCode: "deny_policy_block",
|
||||
tool: "sheets:delete_spreadsheet",
|
||||
issueId: "issue-2",
|
||||
runId: "22222222-2222-4222-8222-222222222222",
|
||||
matchedPolicyIds: ["pol-block-destructive"],
|
||||
},
|
||||
createdAt: new Date(Date.now() - 12 * MIN).toISOString(),
|
||||
}),
|
||||
event({
|
||||
id: "evt-3",
|
||||
action: "tool_gateway.approval_requested",
|
||||
agentDisplayName: "Fable",
|
||||
toolDisplayName: "Send Money",
|
||||
appDisplayName: "Mercury",
|
||||
applicationDisplayName: "Mercury",
|
||||
connectionDisplayName: "Mercury",
|
||||
normalizedOutcome: "asked_first",
|
||||
details: { reasonCode: "requires_approval_policy", tool: "mercury:send_money", issueId: "issue-3" },
|
||||
createdAt: new Date(Date.now() - 26 * MIN).toISOString(),
|
||||
}),
|
||||
event({
|
||||
id: "evt-4",
|
||||
action: "tool_gateway.call_failed",
|
||||
agentDisplayName: "Scout",
|
||||
toolDisplayName: "Create Issue",
|
||||
appDisplayName: "GitHub",
|
||||
applicationDisplayName: "GitHub",
|
||||
connectionDisplayName: "GitHub",
|
||||
normalizedOutcome: "failed",
|
||||
details: { reasonCode: "tool_failed", tool: "github:create_issue", issueId: "issue-4" },
|
||||
createdAt: new Date(Date.now() - 48 * MIN).toISOString(),
|
||||
}),
|
||||
event({
|
||||
id: "evt-5",
|
||||
action: "tool_gateway.call_deferred",
|
||||
agentDisplayName: "Fable",
|
||||
toolDisplayName: "Read Channel",
|
||||
appDisplayName: "Slack",
|
||||
applicationDisplayName: "Slack",
|
||||
connectionDisplayName: "Slack",
|
||||
normalizedOutcome: "waiting",
|
||||
details: { reasonCode: "defer_runtime", tool: "slack:read_channel" },
|
||||
createdAt: new Date(Date.now() - 70 * MIN).toISOString(),
|
||||
}),
|
||||
];
|
||||
|
||||
const APPLICATIONS = [
|
||||
{ id: "app-gmail", name: "Gmail" },
|
||||
{ id: "app-sheets", name: "Google Sheets" },
|
||||
{ id: "app-mercury", name: "Mercury" },
|
||||
{ id: "app-github", name: "GitHub" },
|
||||
{ id: "app-slack", name: "Slack" },
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "agent-fable", name: "Fable" },
|
||||
{ id: "agent-scout", name: "Scout" },
|
||||
];
|
||||
|
||||
const POLICIES = [
|
||||
{ id: "pol-allow-mail", name: "Fable can email customers" },
|
||||
{ id: "pol-block-destructive", name: "destructive actions → Block" },
|
||||
];
|
||||
|
||||
function defaultActivityKey() {
|
||||
return queryKeys.tools.activity(COMPANY, { window: "24h" });
|
||||
}
|
||||
|
||||
function Seeded({ events }: { events: ToolGatewayActivityEvent[] }) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
},
|
||||
});
|
||||
c.setQueryData(defaultActivityKey(), {
|
||||
pages: [{ events, nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
// Filtered-empty: typing "refund" resolves to an empty page.
|
||||
c.setQueryData(queryKeys.tools.activity(COMPANY, { window: "24h", search: "refund" }), {
|
||||
pages: [{ events: [], nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.applications(COMPANY), { applications: APPLICATIONS });
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies: POLICIES });
|
||||
return c;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<AuditTab companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Activity (audit feed)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Populated: Story = {
|
||||
name: "Populated",
|
||||
render: () => <Seeded events={EVENTS} />,
|
||||
};
|
||||
|
||||
export const RowExpanded: Story = {
|
||||
name: "Row expanded (use the chevron)",
|
||||
render: () => <Seeded events={EVENTS} />,
|
||||
};
|
||||
|
||||
export const TrueEmpty: Story = {
|
||||
name: "Empty",
|
||||
render: () => <Seeded events={[]} />,
|
||||
};
|
||||
|
||||
export const FilteredEmpty: Story = {
|
||||
name: "Filtered empty (type 'refund' in search)",
|
||||
render: () => <Seeded events={EVENTS} />,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
Agent,
|
||||
ToolCallEvent,
|
||||
ToolConnectionLifecycleEvent,
|
||||
} from "@paperclipai/shared";
|
||||
import { ActivityPanel } from "@/pages/apps/app-detail/ActivityPanel";
|
||||
import type { ActivityPanelProps } from "@/pages/apps/app-detail/types";
|
||||
|
||||
const CONNECTION_ID = "conn-sheets";
|
||||
const MIN = 60 * 1000;
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "agent-coder", name: "Coder" },
|
||||
{ id: "agent-scout", name: "Scout" },
|
||||
] as unknown as Agent[];
|
||||
|
||||
function callEvent(overrides: Partial<ToolCallEvent>): ToolCallEvent {
|
||||
return {
|
||||
id: "evt",
|
||||
companyId: "company-storybook",
|
||||
eventType: "call_completed",
|
||||
actorType: "agent",
|
||||
actorId: "agent-coder",
|
||||
agentId: "agent-coder",
|
||||
runId: null,
|
||||
issueId: null,
|
||||
applicationId: "app-sheets",
|
||||
connectionId: CONNECTION_ID,
|
||||
catalogEntryId: null,
|
||||
invocationId: null,
|
||||
actionRequestId: null,
|
||||
runtimeSlotId: null,
|
||||
toolName: "Get value",
|
||||
decision: "allow",
|
||||
matchedPolicyIds: [],
|
||||
reasonCode: null,
|
||||
outcome: "success",
|
||||
latencyMs: 120,
|
||||
requestHash: null,
|
||||
requestSummary: null,
|
||||
resultHash: null,
|
||||
resultSummary: null,
|
||||
resultSizeBytes: null,
|
||||
redactionPlan: null,
|
||||
rateLimitState: null,
|
||||
metadata: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(Date.now() - 40 * MIN),
|
||||
...overrides,
|
||||
} as ToolCallEvent;
|
||||
}
|
||||
|
||||
function lifecycle(overrides: Partial<ToolConnectionLifecycleEvent>): ToolConnectionLifecycleEvent {
|
||||
return {
|
||||
id: "life",
|
||||
connectionId: CONNECTION_ID,
|
||||
type: "app_connected",
|
||||
actorType: "user",
|
||||
actorId: "user-board",
|
||||
agentId: null,
|
||||
actorDisplayName: "Dotta",
|
||||
details: null,
|
||||
createdAt: new Date(Date.now() - 90 * MIN),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const LIFECYCLE_EVENTS: ToolConnectionLifecycleEvent[] = [
|
||||
lifecycle({ id: "life-connected", type: "app_connected", createdAt: new Date(Date.now() - 180 * MIN) }),
|
||||
lifecycle({
|
||||
id: "life-allowlist",
|
||||
type: "allowlist_changed",
|
||||
details: { added: 1, removed: 0, total: 3 },
|
||||
createdAt: new Date(Date.now() - 120 * MIN),
|
||||
}),
|
||||
lifecycle({
|
||||
id: "life-quarantine",
|
||||
type: "actions_quarantined",
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
actorDisplayName: null,
|
||||
details: { count: 2 },
|
||||
createdAt: new Date(Date.now() - 60 * MIN),
|
||||
}),
|
||||
lifecycle({ id: "life-paused", type: "app_paused", details: { enabled: false }, createdAt: new Date(Date.now() - 25 * MIN) }),
|
||||
lifecycle({ id: "life-resumed", type: "app_resumed", details: { enabled: true }, createdAt: new Date(Date.now() - 10 * MIN) }),
|
||||
];
|
||||
|
||||
const CALL_EVENTS: ToolCallEvent[] = [
|
||||
callEvent({ id: "evt-1", toolName: "Get value", createdAt: new Date(Date.now() - 40 * MIN) }),
|
||||
callEvent({
|
||||
id: "evt-2",
|
||||
eventType: "call_denied",
|
||||
toolName: "Delete spreadsheet",
|
||||
outcome: "denied",
|
||||
createdAt: new Date(Date.now() - 15 * MIN),
|
||||
}),
|
||||
];
|
||||
|
||||
function panelProps(overrides: Partial<ActivityPanelProps> = {}): ActivityPanelProps {
|
||||
return {
|
||||
events: CALL_EVENTS,
|
||||
lifecycleEvents: LIFECYCLE_EVENTS,
|
||||
issues: {},
|
||||
actionRequests: {},
|
||||
loading: false,
|
||||
agents: AGENTS,
|
||||
connectionId: CONNECTION_ID,
|
||||
appName: "Google Sheets (stdio smoke)",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Panel(props: ActivityPanelProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<ActivityPanel {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/App detail · Activity (lifecycle)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const MixedTimeline: Story = {
|
||||
name: "Tool calls + lifecycle",
|
||||
render: () => <Panel {...panelProps()} />,
|
||||
};
|
||||
|
||||
export const LifecycleOnly: Story = {
|
||||
name: "Lifecycle only",
|
||||
render: () => <Panel {...panelProps({ events: [] })} />,
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
name: "Loading",
|
||||
render: () => <Panel {...panelProps({ loading: true })} />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: "Empty",
|
||||
render: () => <Panel {...panelProps({ events: [], lifecycleEvents: [] })} />,
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
TOOL_APP_GALLERY,
|
||||
type AppGalleryEntry,
|
||||
type McpJsonImportPreview,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AppsConnect } from "@/pages/apps/AppsConnect";
|
||||
import { PasteConfigTab } from "@/pages/tools/PasteConfigTab";
|
||||
|
||||
/**
|
||||
* PAP-11091 — discoverability copy for remote MCP URLs.
|
||||
*
|
||||
* Two surfaces, captured for the UXDesigner re-review:
|
||||
* 1. Apps Connect gallery — the "Connect with a link" field now advertises that
|
||||
* any remote tool URL (incl. a local MCP server) works, with a localhost
|
||||
* example in the placeholder.
|
||||
* 2. The Advanced "Paste a config" tab — a hint that routes a bare URL back to
|
||||
* the gallery's link flow.
|
||||
*/
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
const GALLERY: AppGalleryEntry[] = TOOL_APP_GALLERY.slice(0, 6) as AppGalleryEntry[];
|
||||
|
||||
function seededClient() {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
},
|
||||
});
|
||||
c.setQueryData(queryKeys.apps.gallery(COMPANY), { apps: GALLERY });
|
||||
return c;
|
||||
}
|
||||
|
||||
function GalleryHost() {
|
||||
const client = useMemo(() => seededClient(), []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<AppsConnect />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PasteConfigHost() {
|
||||
const client = useMemo(() => seededClient(), []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<PasteConfigTab companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/Connect discoverability (PAP-11091)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const GalleryLinkAffordance: Story = {
|
||||
name: "Gallery — Connect with a link",
|
||||
render: () => <GalleryHost />,
|
||||
};
|
||||
|
||||
export const PasteConfigRedirectHint: Story = {
|
||||
name: "Paste a config — redirect hint",
|
||||
render: () => <PasteConfigHost />,
|
||||
};
|
||||
|
||||
/**
|
||||
* PAP-11092 / PAP-11094 — preview states after "Check config" runs.
|
||||
*
|
||||
* Stubs the import-json POST so the preview renders without a backend, then
|
||||
* auto-fills the textarea and clicks "Check config" so the screenshot lands on
|
||||
* the activation hand-off (Continue buttons + footer copy).
|
||||
*/
|
||||
function PreviewHost({ preview, snippet }: { preview: McpJsonImportPreview; snippet: string }) {
|
||||
const client = useMemo(() => seededClient(), []);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const realFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/tools/mcp/import-json")) {
|
||||
return new Response(JSON.stringify(preview), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return realFetch(input as RequestInfo, init);
|
||||
}) as typeof window.fetch;
|
||||
return () => {
|
||||
window.fetch = realFetch;
|
||||
};
|
||||
}, [preview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) return;
|
||||
let cancelled = false;
|
||||
const tick = window.setInterval(() => {
|
||||
if (cancelled) return;
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>("textarea");
|
||||
const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((b) =>
|
||||
b.textContent?.trim().startsWith("Check config"),
|
||||
);
|
||||
if (!textarea || !button) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter?.call(textarea, snippet);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
window.setTimeout(() => {
|
||||
button.click();
|
||||
ranRef.current = true;
|
||||
window.clearInterval(tick);
|
||||
}, 30);
|
||||
}, 50);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(tick);
|
||||
};
|
||||
}, [snippet]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<PasteConfigTab companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const REMOTE_PREVIEW: McpJsonImportPreview = {
|
||||
drafts: [
|
||||
{
|
||||
name: "kv-demo",
|
||||
transport: "remote_http",
|
||||
status: "draft",
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
credentialRefs: [],
|
||||
credentialFields: [],
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MIXED_PREVIEW: McpJsonImportPreview = {
|
||||
drafts: [
|
||||
{
|
||||
name: "linear",
|
||||
transport: "remote_http",
|
||||
status: "draft",
|
||||
config: { url: "https://mcp.linear.app/sse" },
|
||||
credentialRefs: [
|
||||
{ name: "LINEAR_API_KEY", secretId: "draft-1", placement: "header", key: "LINEAR_API_KEY" },
|
||||
],
|
||||
credentialFields: [{
|
||||
configPath: "headers.LINEAR_API_KEY",
|
||||
label: "LINEAR_API_KEY",
|
||||
placement: "header",
|
||||
key: "LINEAR_API_KEY",
|
||||
prefix: null,
|
||||
required: true,
|
||||
}],
|
||||
warnings: [],
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
transport: "local_stdio",
|
||||
status: "draft",
|
||||
config: { importedCommand: "npx -y @modelcontextprotocol/server-github", importedArgs: [] },
|
||||
credentialRefs: [
|
||||
{ name: "GITHUB_TOKEN", secretId: "draft-2", placement: "env", key: "GITHUB_TOKEN" },
|
||||
],
|
||||
credentialFields: [],
|
||||
warnings: ["Imported stdio commands stay draft-only unless mapped to an approved Paperclip template."],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STDIO_PREVIEW: McpJsonImportPreview = {
|
||||
drafts: [
|
||||
{
|
||||
name: "github",
|
||||
transport: "local_stdio",
|
||||
status: "draft",
|
||||
config: { importedCommand: "npx -y @modelcontextprotocol/server-github", importedArgs: [] },
|
||||
credentialRefs: [
|
||||
{ name: "GITHUB_TOKEN", secretId: "draft-3", placement: "env", key: "GITHUB_TOKEN" },
|
||||
],
|
||||
credentialFields: [],
|
||||
warnings: ["Imported stdio commands stay draft-only unless mapped to an approved Paperclip template."],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const PasteConfigPreviewRemote: Story = {
|
||||
name: "Paste a config — remote draft (check actions)",
|
||||
render: () => (
|
||||
<PreviewHost
|
||||
preview={REMOTE_PREVIEW}
|
||||
snippet='{ "mcpServers": { "kv-demo": { "url": "http://127.0.0.1:8848/mcp" } } }'
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const PasteConfigPreviewMixed: Story = {
|
||||
name: "Paste a config — mixed remote + stdio",
|
||||
render: () => (
|
||||
<PreviewHost
|
||||
preview={MIXED_PREVIEW}
|
||||
snippet='{ "mcpServers": { "linear": { "url": "https://mcp.linear.app/sse" }, "github": { "command": "npx -y @modelcontextprotocol/server-github" } } }'
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const PasteConfigPreviewStdioOnly: Story = {
|
||||
name: "Paste a config — stdio only (draft, no activation)",
|
||||
render: () => (
|
||||
<PreviewHost
|
||||
preview={STDIO_PREVIEW}
|
||||
snippet='{ "mcpServers": { "github": { "command": "npx -y @modelcontextprotocol/server-github" } } }'
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TOOL_APP_GALLERY, type AppGalleryEntry } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AppsConnect } from "@/pages/apps/AppsConnect";
|
||||
|
||||
/**
|
||||
* PAP-11283 — the gallery Connect wizard names the connection at create time.
|
||||
*
|
||||
* The key step now leads with a Name field (default = the app name) so two
|
||||
* connections to the same app (e.g. a stdio and an HTTP Google Sheets) can be
|
||||
* told apart in the Apps list without a detail-page rename hop.
|
||||
*/
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
// Zapier is an api_key gallery app, so the key step renders both the new Name
|
||||
// field and a credential input — a representative shape for this screenshot.
|
||||
const ZAPIER = TOOL_APP_GALLERY.find((e) => e.key === "zapier") as AppGalleryEntry;
|
||||
const GALLERY: AppGalleryEntry[] = [ZAPIER];
|
||||
|
||||
function seededClient() {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
},
|
||||
});
|
||||
c.setQueryData(queryKeys.apps.gallery(COMPANY), { apps: GALLERY });
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-advances into the key step by clicking the first gallery card, then —
|
||||
* for the custom-name variant — types a distinct name so the screenshot lands
|
||||
* on the edited field.
|
||||
*/
|
||||
function KeyStepHost({ customName }: { customName?: string }) {
|
||||
const client = useMemo(() => seededClient(), []);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) return;
|
||||
let cancelled = false;
|
||||
const tick = window.setInterval(() => {
|
||||
if (cancelled) return;
|
||||
const card = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((b) =>
|
||||
b.textContent?.includes("Zapier"),
|
||||
);
|
||||
if (!card) return;
|
||||
card.click();
|
||||
ranRef.current = true;
|
||||
window.clearInterval(tick);
|
||||
if (customName) {
|
||||
window.setTimeout(() => {
|
||||
const nameInput = Array.from(document.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(i) => i.getAttribute("placeholder") === "My app",
|
||||
);
|
||||
if (!nameInput) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(nameInput, customName);
|
||||
nameInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, 30);
|
||||
}
|
||||
}, 50);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(tick);
|
||||
};
|
||||
}, [customName]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<AppsConnect />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/Connect name at create time (PAP-11283)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const DefaultName: Story = {
|
||||
name: "Gallery key step — default name",
|
||||
render: () => <KeyStepHost />,
|
||||
};
|
||||
|
||||
export const CustomName: Story = {
|
||||
name: "Gallery key step — custom name",
|
||||
render: () => <KeyStepHost customName="Zapier (stdio smoke)" />,
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { ToastProvider } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { GatewaysTab } from "@/pages/tools/GatewaysTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
const POPULATED: ToolMcpGatewayWithTokens[] = [
|
||||
{
|
||||
id: "gw-1",
|
||||
companyId: COMPANY,
|
||||
name: "Engineering inbox",
|
||||
slug: "engineering-inbox",
|
||||
description: "Shared MCP endpoint for the engineering org's editors and IDEs.",
|
||||
status: "active",
|
||||
profileId: "profile-eng",
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
endpointPath: "/api/mcp/gateways/engineering-inbox",
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-06-10T12:00:00Z"),
|
||||
updatedAt: new Date("2026-06-15T18:00:00Z"),
|
||||
tokens: [
|
||||
{
|
||||
id: "tok-1",
|
||||
companyId: COMPANY,
|
||||
gatewayId: "gw-1",
|
||||
name: "Dotta's MacBook",
|
||||
expiresAt: new Date("2026-09-01T00:00:00Z"),
|
||||
lastUsedAt: new Date("2026-06-16T08:00:00Z"),
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-06-10T12:00:00Z"),
|
||||
updatedAt: new Date("2026-06-16T08:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "tok-2",
|
||||
companyId: COMPANY,
|
||||
gatewayId: "gw-1",
|
||||
name: "CI runner",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-06-11T09:00:00Z"),
|
||||
updatedAt: new Date("2026-06-11T09:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "tok-3",
|
||||
companyId: COMPANY,
|
||||
gatewayId: "gw-1",
|
||||
name: "Old laptop (rotated)",
|
||||
expiresAt: null,
|
||||
lastUsedAt: new Date("2026-04-01T10:00:00Z"),
|
||||
revokedAt: new Date("2026-05-15T16:00:00Z"),
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-04-01T09:00:00Z"),
|
||||
updatedAt: new Date("2026-05-15T16:00:00Z"),
|
||||
},
|
||||
],
|
||||
clientSnippets: [
|
||||
{
|
||||
client: "cursor",
|
||||
label: "Cursor",
|
||||
config: {
|
||||
mcpServers: {
|
||||
"paperclip-engineering-inbox": {
|
||||
url: "https://paperclip.app/api/mcp/gateways/engineering-inbox",
|
||||
headers: { Authorization: "Bearer YOUR_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: ["Paste into ~/.cursor/mcp.json"],
|
||||
},
|
||||
{
|
||||
client: "claude_desktop",
|
||||
label: "Claude Desktop",
|
||||
config: {
|
||||
mcpServers: {
|
||||
"paperclip-engineering-inbox": {
|
||||
url: "https://paperclip.app/api/mcp/gateways/engineering-inbox",
|
||||
headers: { Authorization: "Bearer YOUR_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: ["Edit ~/Library/Application Support/Claude/claude_desktop_config.json"],
|
||||
},
|
||||
{
|
||||
client: "vscode",
|
||||
label: "VS Code",
|
||||
config: {
|
||||
servers: {
|
||||
"paperclip-engineering-inbox": {
|
||||
type: "http",
|
||||
url: "https://paperclip.app/api/mcp/gateways/engineering-inbox",
|
||||
headers: { Authorization: "Bearer YOUR_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
} as unknown as ToolMcpGatewayWithTokens,
|
||||
{
|
||||
id: "gw-2",
|
||||
companyId: COMPANY,
|
||||
name: "Support ops (read-only)",
|
||||
slug: "support-ops",
|
||||
description: null,
|
||||
status: "active",
|
||||
profileId: "profile-support",
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
endpointPath: "/api/mcp/gateways/support-ops",
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-06-12T10:00:00Z"),
|
||||
updatedAt: new Date("2026-06-12T10:00:00Z"),
|
||||
tokens: [],
|
||||
clientSnippets: [
|
||||
{
|
||||
client: "claude_code",
|
||||
label: "Claude Code",
|
||||
config: {
|
||||
mcpServers: {
|
||||
"paperclip-support-ops": {
|
||||
url: "https://paperclip.app/api/mcp/gateways/support-ops",
|
||||
headers: { Authorization: "Bearer YOUR_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
} as unknown as ToolMcpGatewayWithTokens,
|
||||
{
|
||||
id: "gw-3",
|
||||
companyId: COMPANY,
|
||||
name: "Disabled legacy gateway with an unusually long human-readable name for truncation testing",
|
||||
slug: "legacy-long",
|
||||
description:
|
||||
"Earlier shared endpoint kept around for one Cursor user who hasn't migrated yet. Will be removed in July.",
|
||||
status: "disabled",
|
||||
profileId: "profile-legacy",
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
endpointPath: "/api/mcp/gateways/legacy-very-long-slug-for-overflow-testing-of-the-mono-chip-on-mobile-and-desktop",
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-03-01T10:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T10:00:00Z"),
|
||||
tokens: [
|
||||
{
|
||||
id: "tok-4",
|
||||
companyId: COMPANY,
|
||||
gatewayId: "gw-3",
|
||||
name: "Legacy IDE",
|
||||
expiresAt: new Date("2026-06-30T00:00:00Z"),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-dotta",
|
||||
createdAt: new Date("2026-03-01T10:00:00Z"),
|
||||
updatedAt: new Date("2026-03-01T10:00:00Z"),
|
||||
},
|
||||
],
|
||||
clientSnippets: [],
|
||||
} as unknown as ToolMcpGatewayWithTokens,
|
||||
];
|
||||
|
||||
function seededClient(gateways: ToolMcpGatewayWithTokens[]) {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
},
|
||||
});
|
||||
c.setQueryData(["tools", "gateways", COMPANY], { gateways });
|
||||
c.setQueryData(queryKeys.tools.profiles(COMPANY), {
|
||||
profiles: [
|
||||
profile("profile-eng", "Engineering", 18),
|
||||
profile("profile-support", "Support ops", 7),
|
||||
profile("profile-legacy", "Legacy read-only", 3),
|
||||
],
|
||||
});
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), []);
|
||||
c.setQueryData(queryKeys.projects.list(COMPANY), []);
|
||||
return c;
|
||||
}
|
||||
|
||||
function profile(id: string, name: string, allowedToolCount: number) {
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
profileKey: id,
|
||||
name,
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount,
|
||||
allowedApplicationCount: 2,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 24,
|
||||
assignmentCount: 1,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function GatewaysHost({ gateways }: { gateways: ToolMcpGatewayWithTokens[] }) {
|
||||
const client = useMemo(() => seededClient(gateways), [gateways]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<ToastProvider>
|
||||
<div className="mx-auto max-w-4xl p-4 sm:p-6">
|
||||
<GatewaysTab companyId={COMPANY} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Gateways (PAP-11182)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Empty: Story = {
|
||||
name: "Gateways — empty state",
|
||||
render: () => <GatewaysHost gateways={[]} />,
|
||||
};
|
||||
|
||||
export const Populated: Story = {
|
||||
name: "Gateways — populated",
|
||||
render: () => <GatewaysHost gateways={POPULATED} />,
|
||||
};
|
||||
|
||||
export const SingleGateway: Story = {
|
||||
name: "Gateways — single gateway with no tokens",
|
||||
render: () => <GatewaysHost gateways={[POPULATED[1]]} />,
|
||||
};
|
||||
@@ -11,8 +11,15 @@ import {
|
||||
acceptedRequestConfirmationInteraction,
|
||||
boundedRequestCheckboxConfirmationInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
declinedToolActionInteraction,
|
||||
executedToolActionInteraction,
|
||||
expiredToolActionInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
failedToolActionInteraction,
|
||||
genericPendingRequestConfirmationInteraction,
|
||||
pendingToolActionDestructiveInteraction,
|
||||
pendingToolActionWriteInteraction,
|
||||
runningToolActionInteraction,
|
||||
issueThreadInteractionComments,
|
||||
issueThreadInteractionEvents,
|
||||
issueThreadInteractionFixtureMeta,
|
||||
@@ -647,6 +654,189 @@ export const RequestConfirmationFailed: Story = {
|
||||
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
|
||||
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP tool-approval card (PAP-13745). A `request_confirmation` carrying a
|
||||
// `payload.toolAction` renders as the dedicated tool-approval card: identity
|
||||
// header, humanized preview, technical-details drawer, and lifecycle states.
|
||||
// The governing rule — approve = run — means no terminal state reads "Accepted".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolActionCard({
|
||||
interaction,
|
||||
interactive = false,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={interactive ? () => undefined : undefined}
|
||||
onRejectInteraction={interactive ? () => undefined : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ToolActionPendingWrite: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending · write"
|
||||
description="A write tool call awaits approval: identity header, WRITE risk badge, humanized preview, collapsible technical details, expiry countdown, and an Approve & run CTA."
|
||||
>
|
||||
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionPendingDestructive: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending · destructive"
|
||||
description="A destructive call takes the red risk badge and a destructive primary button; the countdown sits inside the sub-5-minute urgent window."
|
||||
>
|
||||
<ToolActionCard interaction={pendingToolActionDestructiveInteraction} interactive />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionRunning: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Approved — running…"
|
||||
description="The transient post-approve state: an amber spinner strip that self-resolves to Executed or Failed when the gateway writes back. Buttons are removed to prevent double-submit."
|
||||
>
|
||||
<ToolActionCard interaction={runningToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionExecuted: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Executed"
|
||||
description="The green terminal state reports the real outcome — a result summary plus a deep-link — never a bare checkmark or “Accepted”."
|
||||
>
|
||||
<ToolActionCard interaction={executedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionFailed: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Failed"
|
||||
description="You approved it and it ran, but the connector errored: amber card, red icon, and the verbatim connector error. Distinct from Declined."
|
||||
>
|
||||
<ToolActionCard interaction={failedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionDeclined: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Declined"
|
||||
description="You rejected it and nothing ran: red card with a dimmed/greyscale identity header and the decline reason attached."
|
||||
>
|
||||
<ToolActionCard interaction={declinedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionExpired: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Expired"
|
||||
description="Nobody responded within 60 minutes: a neutral grey (not error-red) card that always states the rule and the recovery path — the agent can request approval again."
|
||||
>
|
||||
<ToolActionCard interaction={expiredToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionLegacyGeneric: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Legacy · no toolAction"
|
||||
description="A confirmation without a toolAction payload keeps the existing generic rendering unchanged — the tool-approval surface is strictly additive."
|
||||
>
|
||||
<ToolActionCard interaction={genericPendingRequestConfirmationInteraction} interactive />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionAllStates: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<Section eyebrow="MCP Tool Approval" title="All six lifecycle states (PAP-13745)">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ScenarioCard title="1 · Pending (write)" description="Awaiting approval — Approve & run.">
|
||||
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="1b · Pending (destructive)" description="Red risk badge, urgent countdown.">
|
||||
<ToolActionCard interaction={pendingToolActionDestructiveInteraction} interactive />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="2 · Approved — running…" description="Transient, self-resolving spinner.">
|
||||
<ToolActionCard interaction={runningToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="3 · Executed" description="Green, with a result summary.">
|
||||
<ToolActionCard interaction={executedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="4 · Failed" description="Ran, but the connector errored.">
|
||||
<ToolActionCard interaction={failedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="5 · Declined" description="Rejected — nothing ran.">
|
||||
<ToolActionCard interaction={declinedToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="6 · Expired" description="No response in 60 min.">
|
||||
<ToolActionCard interaction={expiredToolActionInteraction} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="Legacy · no toolAction" description="Unchanged generic rendering.">
|
||||
<ToolActionCard interaction={genericPendingRequestConfirmationInteraction} interactive />
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ToolActionMobile: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Mobile · 390"
|
||||
description="Single column: risk badge wraps under the tool name, actions stack full-width, the technical drawer stays collapsed."
|
||||
>
|
||||
<div className="mx-auto max-w-[358px]">
|
||||
<ToolActionCard interaction={pendingToolActionWriteInteraction} interactive />
|
||||
</div>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "mobile1" },
|
||||
},
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
Agent,
|
||||
ToolCatalogEntry,
|
||||
ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
||||
import { PermissionsPanel } from "@/pages/apps/app-detail/PermissionsPanel";
|
||||
import { InstallStep } from "@/pages/apps/AppsConnect";
|
||||
import type { AccessDraft } from "@/pages/apps/app-detail/types";
|
||||
import type { InstallState } from "@/lib/tool-installs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3b — Permitted vs Installed UX review harness (PAP-13634).
|
||||
// Renders the three changed surfaces at a real viewport so visual craft can be
|
||||
// signed off. Fixtures are self-contained; casts keep the stories terse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COMPANY = "company-review";
|
||||
|
||||
const AGENTS: Agent[] = [
|
||||
{ id: "a-sage", name: "Sage", status: "active" },
|
||||
{ id: "a-atlas", name: "Atlas", status: "active" },
|
||||
{ id: "a-nova", name: "Nova", status: "active" },
|
||||
{ id: "a-orion", name: "Orion", status: "active" },
|
||||
] as unknown as Agent[];
|
||||
|
||||
function tool(
|
||||
id: string,
|
||||
toolName: string,
|
||||
cap: "read" | "write" | "destructive",
|
||||
title: string,
|
||||
connectionId: string,
|
||||
): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
connectionId,
|
||||
entryKind: "tool",
|
||||
toolName,
|
||||
title,
|
||||
description: title,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: cap,
|
||||
isReadOnly: cap === "read",
|
||||
isWrite: cap === "write",
|
||||
isDestructive: cap === "destructive",
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
} as unknown as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const GMAIL_TOOLS = [
|
||||
tool("g-list", "gmail.list", "read", "List messages", "conn-gmail"),
|
||||
tool("g-read", "gmail.read", "read", "Read message", "conn-gmail"),
|
||||
tool("g-send", "gmail.send", "write", "Send message", "conn-gmail"),
|
||||
];
|
||||
const SLACK_TOOLS = [tool("s-list", "slack.list", "read", "List channels", "conn-slack")];
|
||||
|
||||
function connection(id: string, name: string, installs: InstallState): ToolConnection {
|
||||
const rows = [
|
||||
...(installs.onAll ? [{ targetType: "company", targetId: COMPANY }] : []),
|
||||
...[...installs.agentIds].map((targetId) => ({ targetType: "agent", targetId })),
|
||||
].map((r, i) => ({
|
||||
id: `${id}-install-${i}`,
|
||||
companyId: COMPANY,
|
||||
connectionId: id,
|
||||
...r,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
}));
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
name,
|
||||
status: "active",
|
||||
installs: rows,
|
||||
} as unknown as ToolConnection;
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Permitted vs Installed",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
// --- Surface 1: App detail Permissions tab (PermissionsPanel) --------------
|
||||
|
||||
function PanelHarness({ access, install }: { access: AccessDraft; install: InstallState }) {
|
||||
const [state, setState] = useState(install);
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<PermissionsPanel
|
||||
appName="Gmail"
|
||||
access={access}
|
||||
agents={AGENTS}
|
||||
install={state}
|
||||
readOnly={GMAIL_TOOLS.filter((t) => t.isReadOnly)}
|
||||
canChange={GMAIL_TOOLS.filter((t) => !t.isReadOnly)}
|
||||
quarantined={[]}
|
||||
enabledIds={new Set(["g-list", "g-read"])}
|
||||
askFirstIds={new Set(["g-send"])}
|
||||
pending={false}
|
||||
installPending={false}
|
||||
refreshPending={false}
|
||||
onSaveAccess={() => {}}
|
||||
onSaveInstall={setState}
|
||||
onSetActionPermission={() => {}}
|
||||
onTurnOnQuarantined={() => {}}
|
||||
onRefreshActions={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppDetailInstalledMixed: Story = {
|
||||
name: "1 · App detail — mixed install + auto-extend warning",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
access={{ mode: "specific", agentIds: new Set(["a-sage", "a-atlas"]) }}
|
||||
install={{ onAll: false, agentIds: new Set(["a-sage", "a-orion"]) }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AppDetailInstalledOnAll: Story = {
|
||||
name: "1 · App detail — installed on all agents",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
access={{ mode: "all", agentIds: new Set() }}
|
||||
install={{ onAll: true, agentIds: new Set() }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AppDetailPermittedOnly: Story = {
|
||||
name: "1 · App detail — permitted only (not installed)",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
access={{ mode: "all", agentIds: new Set() }}
|
||||
install={{ onAll: false, agentIds: new Set() }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Surface 2: Agent detail Tools tab (AgentToolsTab) ---------------------
|
||||
|
||||
function SeededAgentTools() {
|
||||
const allowed = [...GMAIL_TOOLS, SLACK_TOOLS[0]];
|
||||
const connections = [
|
||||
connection("conn-gmail", "Gmail", { onAll: false, agentIds: new Set(["a-sage"]) }),
|
||||
connection("conn-slack", "Slack", { onAll: false, agentIds: new Set() }),
|
||||
];
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.effectiveProfilesForAgent(COMPANY, "a-sage"), {
|
||||
agentId: "a-sage",
|
||||
profiles: [],
|
||||
bindings: [],
|
||||
allowedTools: allowed,
|
||||
allowedToolNames: allowed.map((t) => t.toolName),
|
||||
installedConnections: connections.filter((conn) => conn.id === "conn-gmail"),
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL_TOOLS });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK_TOOLS });
|
||||
return c;
|
||||
}, []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-6xl bg-background p-6">
|
||||
<AgentToolsTab agent={{ id: "a-sage", name: "Sage" } as never} companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const AgentToolsInstalledApps: Story = {
|
||||
name: "2 · Agent Tools tab — Installed apps + badges",
|
||||
render: () => <SeededAgentTools />,
|
||||
};
|
||||
|
||||
// --- Surface 3: Connect flow Install step (InstallStep) --------------------
|
||||
|
||||
function SeededInstallStep({
|
||||
access,
|
||||
accessAgentIds,
|
||||
initialMode,
|
||||
initialInstall,
|
||||
}: {
|
||||
access: "all" | "specific";
|
||||
accessAgentIds: Set<string>;
|
||||
initialMode: "none" | "specific" | "all";
|
||||
initialInstall: Set<string>;
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
return c;
|
||||
}, []);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [ids, setIds] = useState(initialInstall);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="bg-background p-6">
|
||||
<InstallStep
|
||||
appName="Gmail"
|
||||
companyId={COMPANY}
|
||||
access={access}
|
||||
accessAgentIds={accessAgentIds}
|
||||
installMode={mode}
|
||||
setInstallMode={setMode}
|
||||
installAgentIds={ids}
|
||||
setInstallAgentIds={setIds}
|
||||
submitting={false}
|
||||
onBack={() => {}}
|
||||
onFinish={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConnectInstallSpecific: Story = {
|
||||
name: "3 · Connect — Install step (specific + auto-extend)",
|
||||
render: () => (
|
||||
<SeededInstallStep
|
||||
access="specific"
|
||||
accessAgentIds={new Set(["a-sage", "a-atlas"])}
|
||||
initialMode="specific"
|
||||
initialInstall={new Set(["a-sage", "a-orion"])}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ConnectInstallAll: Story = {
|
||||
name: "3 · Connect — Install step (all agents)",
|
||||
render: () => (
|
||||
<SeededInstallStep
|
||||
access="all"
|
||||
accessAgentIds={new Set()}
|
||||
initialMode="all"
|
||||
initialInstall={new Set()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolCatalogEntry, ToolConnection, ToolPolicy } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { PoliciesTab } from "@/pages/tools/PoliciesTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
function makeTool(
|
||||
id: string,
|
||||
toolName: string,
|
||||
cap: "read" | "write" | "destructive",
|
||||
title: string,
|
||||
applicationId: string,
|
||||
connectionId: string,
|
||||
): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
applicationId,
|
||||
connectionId,
|
||||
entryKind: "tool",
|
||||
name: toolName,
|
||||
toolName,
|
||||
title,
|
||||
description: title,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: cap,
|
||||
isReadOnly: cap === "read",
|
||||
isWrite: cap !== "read",
|
||||
isDestructive: cap === "destructive",
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
versionHash: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const GMAIL: ToolCatalogEntry[] = [
|
||||
makeTool("g-list", "gmail.list_messages", "read", "List messages", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-read", "gmail.get_message", "read", "Read a message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-send", "gmail.send_message", "write", "Send a message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-draft", "gmail.create_draft", "write", "Create a draft", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-trash", "gmail.trash_message", "destructive", "Move to trash", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-delete", "gmail.delete_message", "destructive", "Permanently delete", "app-gmail", "conn-gmail"),
|
||||
];
|
||||
|
||||
const SLACK: ToolCatalogEntry[] = [
|
||||
makeTool("s-list", "slack.list_channels", "read", "List channels", "app-slack", "conn-slack"),
|
||||
makeTool("s-post", "slack.post_message", "write", "Post a message", "app-slack", "conn-slack"),
|
||||
makeTool("s-archive", "slack.archive_channel", "destructive", "Archive a channel", "app-slack", "conn-slack"),
|
||||
];
|
||||
|
||||
const CATALOG: ToolCatalogEntry[] = [...GMAIL, ...SLACK];
|
||||
|
||||
const CONNECTIONS: ToolConnection[] = [
|
||||
{
|
||||
id: "conn-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
name: "Gmail",
|
||||
connectionKind: "managed",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolConnection,
|
||||
{
|
||||
id: "conn-slack",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-slack",
|
||||
name: "Slack",
|
||||
connectionKind: "managed",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolConnection,
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "agent-fable", name: "Fable" },
|
||||
{ id: "agent-sage", name: "Sage" },
|
||||
{ id: "agent-atlas", name: "Atlas" },
|
||||
];
|
||||
|
||||
const PROJECTS = [
|
||||
{ id: "project-launch", name: "Launch" },
|
||||
{ id: "project-support", name: "Support" },
|
||||
];
|
||||
|
||||
function rule(partial: Partial<ToolPolicy> & { id: string; policyType: ToolPolicy["policyType"]; priority: number }): ToolPolicy {
|
||||
return {
|
||||
id: partial.id,
|
||||
companyId: COMPANY,
|
||||
name: partial.name ?? partial.id,
|
||||
description: partial.description ?? null,
|
||||
policyType: partial.policyType,
|
||||
priority: partial.priority,
|
||||
enabled: partial.enabled ?? true,
|
||||
selectors: partial.selectors ?? {},
|
||||
conditions: partial.conditions ?? null,
|
||||
config: partial.config ?? null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-11T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
const POLICIES: ToolPolicy[] = [
|
||||
rule({
|
||||
id: "p1",
|
||||
name: "Block destructive actions everywhere",
|
||||
policyType: "block",
|
||||
priority: 50,
|
||||
selectors: { riskLevel: "destructive" },
|
||||
}),
|
||||
rule({
|
||||
id: "p2",
|
||||
name: "Ask first when Fable sends mail",
|
||||
policyType: "require_approval",
|
||||
priority: 100,
|
||||
selectors: { agentId: "agent-fable", toolName: "gmail.send_message" },
|
||||
}),
|
||||
rule({
|
||||
id: "p3",
|
||||
name: "Limit Slack posts",
|
||||
policyType: "rate_limit",
|
||||
priority: 150,
|
||||
selectors: { toolName: "slack.post_message" },
|
||||
config: { rateLimit: { limit: 25, windowSeconds: 3600, keyBy: ["agent", "tool"] } },
|
||||
}),
|
||||
rule({
|
||||
id: "p4",
|
||||
name: "Allow Sage to use Gmail",
|
||||
policyType: "allow",
|
||||
priority: 200,
|
||||
selectors: { agentId: "agent-sage", applicationId: "app-gmail" },
|
||||
}),
|
||||
rule({
|
||||
id: "p5",
|
||||
name: "Block critical actions",
|
||||
policyType: "block",
|
||||
priority: 300,
|
||||
selectors: { riskLevel: "critical" },
|
||||
}),
|
||||
];
|
||||
|
||||
const TRUST_RULES: ToolPolicy[] = [
|
||||
rule({
|
||||
id: "trust-1",
|
||||
policyType: "trust_rule",
|
||||
priority: 1000,
|
||||
selectors: { agentId: "agent-fable", toolName: "gmail.send_message" },
|
||||
}),
|
||||
rule({
|
||||
id: "trust-2",
|
||||
policyType: "trust_rule",
|
||||
priority: 1000,
|
||||
selectors: { agentId: "agent-sage", toolName: "slack.post_message" },
|
||||
}),
|
||||
];
|
||||
|
||||
const AUDIT_HITS = [
|
||||
{ policyId: "p1", count: 14 },
|
||||
{ policyId: "p2", count: 6 },
|
||||
{ policyId: "p3", count: 22 },
|
||||
{ policyId: "p4", count: 3 },
|
||||
{ policyId: "p5", count: 1 },
|
||||
];
|
||||
|
||||
function buildAudit() {
|
||||
const now = Date.now();
|
||||
const rows: Array<{ createdAt: string; details: { matchedPolicyIds: string[] } }> = [];
|
||||
for (const hit of AUDIT_HITS) {
|
||||
for (let i = 0; i < hit.count; i += 1) {
|
||||
rows.push({
|
||||
createdAt: new Date(now - i * 60_000).toISOString(),
|
||||
details: { matchedPolicyIds: [hit.policyId] },
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function seededClient({
|
||||
policies,
|
||||
trustRules,
|
||||
}: {
|
||||
policies: ToolPolicy[];
|
||||
trustRules: ToolPolicy[];
|
||||
}) {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
},
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies });
|
||||
c.setQueryData(queryKeys.tools.trustRules(COMPANY), { trustRules });
|
||||
c.setQueryData(queryKeys.tools.audit(COMPANY, 250), buildAudit());
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
c.setQueryData(queryKeys.projects.list(COMPANY), PROJECTS);
|
||||
c.setQueryData(queryKeys.tools.applications(COMPANY), {
|
||||
applications: [
|
||||
{ id: "app-gmail", name: "Gmail" },
|
||||
{ id: "app-slack", name: "Slack" },
|
||||
],
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: CONNECTIONS });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK });
|
||||
return c;
|
||||
}
|
||||
|
||||
function findButtonByText(label: string): HTMLButtonElement | null {
|
||||
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("button"));
|
||||
return buttons.find((b) => (b.textContent ?? "").trim() === label) ?? null;
|
||||
}
|
||||
|
||||
function PoliciesHost({
|
||||
policies = POLICIES,
|
||||
trustRules = TRUST_RULES,
|
||||
autoClick,
|
||||
}: {
|
||||
policies?: ToolPolicy[];
|
||||
trustRules?: ToolPolicy[];
|
||||
autoClick?: "new-rule" | "test-rule";
|
||||
}) {
|
||||
const client = useMemo(() => seededClient({ policies, trustRules }), [policies, trustRules]);
|
||||
const triggered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoClick || triggered.current) return;
|
||||
let cancelled = false;
|
||||
const tryClick = (attempt: number) => {
|
||||
if (cancelled || triggered.current) return;
|
||||
const label = autoClick === "new-rule" ? "New rule" : "Test a rule";
|
||||
const button = findButtonByText(label);
|
||||
if (button) {
|
||||
triggered.current = true;
|
||||
button.click();
|
||||
return;
|
||||
}
|
||||
if (attempt < 30) window.setTimeout(() => tryClick(attempt + 1), 50);
|
||||
};
|
||||
tryClick(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [autoClick]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<PoliciesTab companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Rules (PAP-11049)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const IndexPopulated: Story = {
|
||||
name: "Rules index — populated",
|
||||
render: () => <PoliciesHost />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: "Rules — empty state",
|
||||
render: () => <PoliciesHost policies={[]} trustRules={[]} />,
|
||||
};
|
||||
|
||||
export const BuilderNewRule: Story = {
|
||||
name: "Rule builder — after New rule",
|
||||
render: () => <PoliciesHost autoClick="new-rule" />,
|
||||
};
|
||||
|
||||
export const TestRuleSlideover: Story = {
|
||||
name: "Test a rule — slide-over",
|
||||
render: () => <PoliciesHost autoClick="test-rule" />,
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolConnection,
|
||||
ToolRuntimeAlertRecommendation,
|
||||
ToolRuntimeHealthSummary,
|
||||
ToolRuntimeSlot,
|
||||
} from "@paperclipai/shared";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { RuntimeTab } from "@/pages/tools/RuntimeTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
function slot(overrides: Partial<ToolRuntimeSlot> = {}): ToolRuntimeSlot {
|
||||
return {
|
||||
id: "slot-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-gmail",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
ownerScopeType: "company",
|
||||
ownerScopeId: null,
|
||||
runtimeKind: "local_stdio",
|
||||
slotKey: "gmail-stdio-local",
|
||||
status: "running",
|
||||
reuseKey: null,
|
||||
workspaceScope: null,
|
||||
credentialScopeHash: null,
|
||||
provider: null,
|
||||
providerRef: null,
|
||||
processId: 41832,
|
||||
commandTemplateKey: "gmail",
|
||||
healthStatus: "healthy",
|
||||
lastHealthCheckAt: null,
|
||||
idleExpiresAt: null,
|
||||
startedAt: new Date("2026-06-13T10:46:00Z"),
|
||||
stoppedAt: null,
|
||||
lastUsedAt: new Date("2026-06-13T12:55:00Z"),
|
||||
lastError: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-13T10:46:00Z"),
|
||||
updatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolRuntimeSlot;
|
||||
}
|
||||
|
||||
function connection(overrides: Partial<ToolConnection> = {}): ToolConnection {
|
||||
return {
|
||||
id: "conn-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
name: "Gmail",
|
||||
connectionKind: "managed",
|
||||
transport: "local_stdio",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-13T10:46:00Z"),
|
||||
updatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolConnection;
|
||||
}
|
||||
|
||||
const SLOTS: ToolRuntimeSlot[] = [
|
||||
slot(),
|
||||
slot({
|
||||
id: "slot-sheets",
|
||||
applicationId: "app-sheets",
|
||||
connectionId: "conn-sheets",
|
||||
slotKey: "sheets-stdio-local",
|
||||
commandTemplateKey: "google_sheets",
|
||||
processId: 41877,
|
||||
lastUsedAt: new Date("2026-06-13T12:51:00Z"),
|
||||
}),
|
||||
slot({
|
||||
id: "slot-slack",
|
||||
applicationId: "app-slack",
|
||||
connectionId: "conn-slack",
|
||||
runtimeKind: "remote_session",
|
||||
slotKey: "slack-remote",
|
||||
commandTemplateKey: null,
|
||||
providerRef: "slack",
|
||||
processId: null,
|
||||
lastUsedAt: new Date("2026-06-13T12:42:00Z"),
|
||||
}),
|
||||
slot({
|
||||
id: "slot-github",
|
||||
applicationId: "app-github",
|
||||
connectionId: "conn-github",
|
||||
slotKey: "github-stdio-local",
|
||||
commandTemplateKey: "github",
|
||||
processId: 41901,
|
||||
lastUsedAt: new Date("2026-06-13T12:59:00Z"),
|
||||
}),
|
||||
];
|
||||
|
||||
const CONNECTIONS: ToolConnection[] = [
|
||||
connection(),
|
||||
connection({ id: "conn-sheets", applicationId: "app-sheets", name: "Google Sheets" }),
|
||||
connection({ id: "conn-slack", applicationId: "app-slack", name: "Slack", transport: "remote_http" }),
|
||||
connection({ id: "conn-github", applicationId: "app-github", name: "GitHub" }),
|
||||
];
|
||||
|
||||
function alert(overrides: Partial<ToolRuntimeAlertRecommendation> = {}): ToolRuntimeAlertRecommendation {
|
||||
return {
|
||||
name: "mcp_runtime_stuck_running_slot",
|
||||
severity: "critical",
|
||||
status: "firing",
|
||||
threshold: "Any running slot with no progress for 5 minutes.",
|
||||
observed: "1 stuck running slot(s).",
|
||||
description: "A runtime slot is running but has not recorded progress inside the supervisor stuck-slot window.",
|
||||
firstResponderAction: "Inspect recent audit events and active tool calls; restart the slot only after confirming no healthy call is still in progress.",
|
||||
runbookSection: "runbook.md#stuck-running-slot",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function health(overrides: Partial<ToolRuntimeHealthSummary> = {}): ToolRuntimeHealthSummary {
|
||||
return {
|
||||
status: "ok",
|
||||
generatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
runbookPath: "docs/runbooks/mcp-runtime.md",
|
||||
metrics: {
|
||||
windowStartedAt: new Date("2026-06-13T12:00:00Z"),
|
||||
windowEndedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
activeSlots: 4,
|
||||
startingSlots: 0,
|
||||
runningSlots: 4,
|
||||
idleSlots: 0,
|
||||
failedSlots: 0,
|
||||
stoppedSlots: 0,
|
||||
stuckStartingSlots: 0,
|
||||
stuckRunningSlots: 0,
|
||||
capacityDeferralsLastHour: 0,
|
||||
restartAttemptsLastHour: 0,
|
||||
restartSuppressionsLastHour: 0,
|
||||
idleEvictionsLastHour: 0,
|
||||
toolCallsLastHour: 128,
|
||||
toolTimeoutsLastHour: 0,
|
||||
toolFailuresLastHour: 0,
|
||||
timeoutRateLastHour: 0,
|
||||
failureRateLastHour: 0,
|
||||
averageToolLatencyMsLastHour: 1200,
|
||||
p95ToolLatencyMsLastHour: 2100,
|
||||
missingSecretFailuresLastHour: 0,
|
||||
auditWriteFailuresLastHour: 0,
|
||||
activeConnections: 4,
|
||||
disabledConnections: 0,
|
||||
degradedConnections: 0,
|
||||
remoteHttpConnections: 1,
|
||||
localStdioConnections: 3,
|
||||
},
|
||||
supportMatrix: {
|
||||
remoteHttp: { supported: true, note: "" },
|
||||
localStdio: { supported: true, note: "" },
|
||||
},
|
||||
alerts: [],
|
||||
recommendations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Seeded({
|
||||
slots,
|
||||
connections,
|
||||
summary,
|
||||
}: {
|
||||
slots: ToolRuntimeSlot[];
|
||||
connections: ToolConnection[];
|
||||
summary: ToolRuntimeHealthSummary;
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.runtimeSlots(COMPANY), { runtimeSlots: slots });
|
||||
c.setQueryData(queryKeys.tools.runtimeHealth(COMPANY), summary);
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
|
||||
return c;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<RuntimeTab companyId={COMPANY} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Health (runtime)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const AllGood: Story = {
|
||||
name: "All good",
|
||||
render: () => <Seeded slots={SLOTS} connections={CONNECTIONS} summary={health()} />,
|
||||
};
|
||||
|
||||
export const NeedsAttention: Story = {
|
||||
name: "Needs attention",
|
||||
render: () => (
|
||||
<Seeded
|
||||
slots={[
|
||||
slot({ healthStatus: "error", lastError: "MCP request timed out after 30000ms" }),
|
||||
...SLOTS.slice(1),
|
||||
]}
|
||||
connections={[connection({ healthStatus: "degraded" }), ...CONNECTIONS.slice(1)]}
|
||||
summary={health({
|
||||
status: "degraded",
|
||||
metrics: {
|
||||
...health().metrics,
|
||||
runningSlots: 3,
|
||||
degradedConnections: 1,
|
||||
toolFailuresLastHour: 11,
|
||||
toolTimeoutsLastHour: 3,
|
||||
timeoutRateLastHour: 12,
|
||||
averageToolLatencyMsLastHour: 2400,
|
||||
p95ToolLatencyMsLastHour: 5200,
|
||||
},
|
||||
alerts: [alert(), alert({ name: "mcp_runtime_connection_health_degraded", observed: "1 degraded connection(s), 0 disabled connection(s)." })],
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RowExpanded: Story = {
|
||||
name: "Row expanded (use the chevron)",
|
||||
render: () => <Seeded slots={SLOTS} connections={CONNECTIONS} summary={health()} />,
|
||||
};
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestDecision,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
|
||||
|
||||
const CONNECTION = "conn-sheets";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tool(
|
||||
id: string,
|
||||
toolName: string,
|
||||
title: string,
|
||||
description: string,
|
||||
read: boolean,
|
||||
inputSchema: Record<string, unknown> | null = null,
|
||||
): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-storybook",
|
||||
applicationId: "app-sheets",
|
||||
connectionId: CONNECTION,
|
||||
entryKind: "tool",
|
||||
name: toolName,
|
||||
toolName,
|
||||
title,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: read ? "read" : "write",
|
||||
isReadOnly: read,
|
||||
isWrite: !read,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
versionHash: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const READ_SHEET_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
spreadsheetId: { type: "string", title: "Spreadsheet" },
|
||||
sheet: { type: "string", title: "Sheet" },
|
||||
range: { type: "string", title: "Range", description: "e.g. A1:F50" },
|
||||
},
|
||||
required: ["spreadsheetId", "sheet"],
|
||||
};
|
||||
|
||||
const APPEND_ROW_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
spreadsheetId: { type: "string", title: "Spreadsheet" },
|
||||
sheet: { type: "string", title: "Sheet" },
|
||||
values: { type: "string", title: "Values", description: "Comma-separated cell values" },
|
||||
},
|
||||
required: ["spreadsheetId", "sheet", "values"],
|
||||
};
|
||||
|
||||
const DELETE_ROW_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
spreadsheetId: { type: "string", title: "Spreadsheet" },
|
||||
rowNumber: { type: "number", title: "Row number" },
|
||||
},
|
||||
required: ["spreadsheetId", "rowNumber"],
|
||||
};
|
||||
|
||||
const CATALOG: ToolCatalogEntry[] = [
|
||||
tool("r1", "list_spreadsheets", "List spreadsheets", "See the spreadsheets in this account.", true),
|
||||
tool("r2", "read_sheet", "Read a sheet", "Get rows and cell values from a sheet.", true, READ_SHEET_SCHEMA),
|
||||
tool("r3", "search_rows", "Search rows", "Find rows that match a query.", true),
|
||||
tool("r4", "get_cell", "Get a cell", "Read a single cell value.", true),
|
||||
tool("r5", "list_sheets", "List sheets", "See the tabs in a spreadsheet.", true),
|
||||
tool("r6", "get_metadata", "Get spreadsheet info", "Read a spreadsheet's title and tabs.", true),
|
||||
tool("r7", "describe_columns", "Describe columns", "List the column headers in a sheet.", true),
|
||||
tool("w1", "append_row", "Append a row", "Add a new row to the bottom of a sheet.", false, APPEND_ROW_SCHEMA),
|
||||
tool("w2", "update_cell", "Update a cell", "Change a single cell value.", false),
|
||||
tool("w3", "create_sheet", "Add a sheet", "Create a new tab in a spreadsheet.", false),
|
||||
tool("w4", "clear_range", "Clear a range", "Empty the cells in a range.", false),
|
||||
tool("w5", "delete_row", "Delete a row", "Remove a row from a sheet permanently.", false, DELETE_ROW_SCHEMA),
|
||||
];
|
||||
|
||||
function decisionTool(entry: ToolCatalogEntry, decision: ToolConnectionTestDecision) {
|
||||
return {
|
||||
toolName: entry.toolName,
|
||||
gatewayToolName: `sheets__${entry.toolName}`,
|
||||
displayName: entry.title,
|
||||
risk: (entry.isReadOnly ? "read" : "write") as "read" | "write" | "destructive",
|
||||
decision,
|
||||
reasonCode: null,
|
||||
matchedPolicyIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Decisions: 9 allowed (7 read + 2 write), 2 ask first, 1 off.
|
||||
const DECISIONS: Record<string, ToolConnectionTestDecision> = {
|
||||
list_spreadsheets: "allowed",
|
||||
read_sheet: "allowed",
|
||||
search_rows: "allowed",
|
||||
get_cell: "allowed",
|
||||
list_sheets: "allowed",
|
||||
get_metadata: "allowed",
|
||||
describe_columns: "allowed",
|
||||
append_row: "ask_first",
|
||||
update_cell: "allowed",
|
||||
create_sheet: "allowed",
|
||||
clear_range: "ask_first",
|
||||
delete_row: "off",
|
||||
};
|
||||
|
||||
function buildAgent(id: string, name: string, decisions: Record<string, ToolConnectionTestDecision>): ToolConnectionTestAgent {
|
||||
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
effectiveAccess: {
|
||||
connectionId: CONNECTION,
|
||||
toolCount: tools.length,
|
||||
allowedCount: tools.filter((t) => t.decision === "allowed").length,
|
||||
askFirstCount: tools.filter((t) => t.decision === "ask_first").length,
|
||||
offCount: tools.filter((t) => t.decision === "off").length,
|
||||
lastChangedAt: null,
|
||||
lastChangedByAgentId: null,
|
||||
lastChangedByName: null,
|
||||
tools,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const AGENTS: ToolConnectionTestAgent[] = [
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS),
|
||||
buildAgent("agent-codex", "CodexCoder", {
|
||||
...DECISIONS,
|
||||
create_sheet: "ask_first",
|
||||
clear_range: "off",
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM driver — expand a row, fill its fields, press Run.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Step =
|
||||
| { kind: "expand"; title: string }
|
||||
| { kind: "fill"; values: string[] }
|
||||
| { kind: "click"; label: string };
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function runScript(steps: Step[]) {
|
||||
let index = 0;
|
||||
const tick = (attempt: number) => {
|
||||
if (index >= steps.length) return;
|
||||
const step = steps[index];
|
||||
if (step.kind === "expand") {
|
||||
const btn = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes(step.title),
|
||||
);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
index += 1;
|
||||
window.setTimeout(() => tick(0), 60);
|
||||
return;
|
||||
}
|
||||
} else if (step.kind === "fill") {
|
||||
const inputs = Array.from(document.querySelectorAll("input")).filter(
|
||||
(i) => i.getAttribute("aria-label") !== "Find an action" && i.getAttribute("aria-label") !== "Search agents",
|
||||
);
|
||||
if (inputs.length >= step.values.length) {
|
||||
step.values.forEach((value, i) => setInputValue(inputs[i] as HTMLInputElement, value));
|
||||
index += 1;
|
||||
window.setTimeout(() => tick(0), 60);
|
||||
return;
|
||||
}
|
||||
} else if (step.kind === "click") {
|
||||
const btn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === step.label,
|
||||
);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
index += 1;
|
||||
window.setTimeout(() => tick(0), 60);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (attempt < 40) window.setTimeout(() => tick(attempt + 1), 50);
|
||||
};
|
||||
tick(0);
|
||||
}
|
||||
|
||||
function seededClient(agents: ToolConnectionTestAgent[]): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), { agents });
|
||||
return client;
|
||||
}
|
||||
|
||||
function TestHost({
|
||||
script,
|
||||
runResult,
|
||||
runDelayMs = 120,
|
||||
agents = AGENTS,
|
||||
quarantined = [],
|
||||
}: {
|
||||
script?: Step[];
|
||||
runResult?: ToolConnectionTestCallResult;
|
||||
runDelayMs?: number;
|
||||
agents?: ToolConnectionTestAgent[];
|
||||
quarantined?: ToolCatalogEntry[];
|
||||
}) {
|
||||
const client = useMemo(() => seededClient(agents), [agents]);
|
||||
const fetchPatched = useRef(false);
|
||||
|
||||
// Stub the test-call POST so the run states render without a backend.
|
||||
useEffect(() => {
|
||||
if (fetchPatched.current) return;
|
||||
fetchPatched.current = true;
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url.includes("/test-calls")) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, runDelayMs));
|
||||
return Response.json(runResult ?? { decision: "allowed", invocationId: "inv-storybook", result: {} });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
return () => {
|
||||
window.fetch = original;
|
||||
};
|
||||
}, [runResult, runDelayMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!script) return;
|
||||
const timer = window.setTimeout(() => runScript(script), 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [script]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<TestPanel connectionId={CONNECTION} appName="Google Sheets" active={CATALOG} quarantined={quarantined} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/Test tab (PAP-11350)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Default: Story = {
|
||||
name: "Default — grouped actions + access badges",
|
||||
render: () => <TestHost />,
|
||||
};
|
||||
|
||||
export const AllowedResult: Story = {
|
||||
name: "Allowed — result panel",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runResult={{
|
||||
decision: "allowed",
|
||||
invocationId: "inv-allowed",
|
||||
result: [
|
||||
{ Name: "Acme Co.", Stage: "Demo", Owner: "Dotta", Updated: "Tue" },
|
||||
{ Name: "Globex", Stage: "Trial", Owner: "Dotta", Updated: "Mon" },
|
||||
{ Name: "Initech", Stage: "Closed", Owner: "CodexCoder", Updated: "Sun" },
|
||||
{ Name: "Soylent", Stage: "Discovery", Owner: "QA", Updated: "Fri" },
|
||||
{ Name: "Umbrella", Stage: "Trial", Owner: "Dotta", Updated: "Thu" },
|
||||
],
|
||||
}}
|
||||
script={[
|
||||
{ kind: "expand", title: "Read a sheet" },
|
||||
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AskFirstResult: Story = {
|
||||
name: "Ask first — sent for approval",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runResult={{ decision: "ask_first", invocationId: "inv-ask", actionRequestId: "req-1" }}
|
||||
script={[
|
||||
{ kind: "expand", title: "Append a row" },
|
||||
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads", "Wayne Industries, Demo, Dotta"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorResult: Story = {
|
||||
name: "Error — what the app said + what to try",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runResult={{
|
||||
decision: "allowed",
|
||||
invocationId: "inv-error",
|
||||
error: { message: "Requested entity was not found.", reasonCode: "NOT_FOUND" },
|
||||
}}
|
||||
script={[
|
||||
{ kind: "expand", title: "Read a sheet" },
|
||||
{ kind: "fill", values: ["1AbCxyz…NotFound", "Leads"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RunningState: Story = {
|
||||
name: "Running — in-flight card",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runDelayMs={20000}
|
||||
script={[
|
||||
{ kind: "expand", title: "Read a sheet" },
|
||||
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const OffAction: Story = {
|
||||
name: "Off — explanation + open Permissions",
|
||||
render: () => <TestHost script={[{ kind: "expand", title: "Delete a row" }]} />,
|
||||
};
|
||||
|
||||
// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
|
||||
|
||||
const AGENTS_WITH_AUDIT: ToolConnectionTestAgent[] = AGENTS.map((agent, i) =>
|
||||
i === 0
|
||||
? {
|
||||
...agent,
|
||||
effectiveAccess: {
|
||||
...agent.effectiveAccess,
|
||||
lastChangedAt: new Date("2026-06-17T09:00:00Z").toISOString(),
|
||||
lastChangedByAgentId: "agent-admin",
|
||||
lastChangedByName: "Dotta",
|
||||
},
|
||||
}
|
||||
: agent,
|
||||
);
|
||||
|
||||
export const OffAuditHint: Story = {
|
||||
name: "Off — last-changed audit hint (PAP-11404)",
|
||||
render: () => (
|
||||
<TestHost agents={AGENTS_WITH_AUDIT} script={[{ kind: "expand", title: "Delete a row" }]} />
|
||||
),
|
||||
};
|
||||
|
||||
const QUARANTINED: ToolCatalogEntry[] = [
|
||||
{ ...tool("q1", "rename_sheet", "Rename a sheet", "Change a sheet's name.", false), status: "quarantined" } as ToolCatalogEntry,
|
||||
];
|
||||
|
||||
export const QuarantinedAction: Story = {
|
||||
name: "New — quarantined action not yet on (PAP-11404)",
|
||||
render: () => (
|
||||
<TestHost quarantined={QUARANTINED} script={[{ kind: "expand", title: "Rename a sheet" }]} />
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BACKEND = "http://paperclip-dev:3100";
|
||||
|
||||
// Mock the /tools/gallery endpoint (which only exists on PAP-10341 branch)
|
||||
// so we can visually test the "recognized domain" shortcut against a populated gallery.
|
||||
const MOCK_GALLERY = {
|
||||
apps: [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Automate things",
|
||||
authKind: "api_key",
|
||||
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
|
||||
logoUrl: null,
|
||||
credentialFields: [
|
||||
{ configPath: "credentials.authorization", label: "API key", required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const galleryMockPlugin = {
|
||||
name: "qa-mock-gallery",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url && /\/api\/companies\/[^/]+\/tools\/gallery(\?|$)/.test(req.url)) {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(MOCK_GALLERY));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [galleryMockPlugin, react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5179,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: BACKEND,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on("proxyReq", (proxyReq) => {
|
||||
proxyReq.setHeader("origin", BACKEND);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user