feat(apps): add connection intent setup experience (#12347)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The backend now turns agent requests into durable connection
intents.
> - Operators need a clear path to inspect, configure, and finish those
requests.
> - The experience must preserve identity, agent access, and interrupted
setup state.
> - This pull request adds the connection intent setup experience across
the app UI.
> - The benefit is one guided flow from agent request to governed
connection.

## Linked Issues or Issue Description

Refs #11965

This is stack 9 of 11. It depends on stack 8 and replaces another
reviewable part of #11965.

## What Changed

- Add connection intent cards and setup flow integration.
- Add browse, connection, app detail, and sidebar experience updates.
- Preserve exact draft identity and access choices across resume and
OAuth recovery.
- Add focused UI, architecture, policy, and end-to-end coverage.
- Keep transient retained-connection lookup failures retryable instead
of misclassifying them as missing targets.
- Align the dark-mode E2E contract with the intentionally hidden
Gateways and Profiles sidebar tabs.

## Verification

- `pnpm -r typecheck`
- Focused UI result: 372 tests passed across 20 files.
- AppsConnect regression suite: 80/80 passed, including failed
connection and application lookups during retained reconnect.
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/AppsSidebar.test.tsx` (1 passed)
- `pnpm check:token-gates`
- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm build`

## Risks

- An interrupted OAuth flow can leave a durable draft that needs resume.
- The UI resumes the exact draft and keeps its identity and agent access
settings.
- Retained reconnect retries refetch connections and applications
together to avoid mixing partial snapshots.
- Gateways and Profiles remain route-accessible but intentionally absent
from the sidebar until their existing ship gate is lifted.
- The change does not add a database migration.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## 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 linked the public source pull request with `Refs #`
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-29 12:08:35 -05:00 committed by GitHub
parent d387cc0ff0
commit c90d904779
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 10106 additions and 4182 deletions

View File

@ -123,7 +123,9 @@ test.describe.serial("not-connected app page", () => {
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
await page.getByRole("button", { name: "Check link" }).click();
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 20_000 });
// Reconnect retains the previous identity and application, so the generic
// check can commit the restored connection transactionally.
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 30_000 });
const apps = await request.get(`/api/companies/${seed.companyId}/tools/applications`);
const appsBody = await apps.json();
@ -180,6 +182,7 @@ test.describe.serial("not-connected app page", () => {
await page.goto(`/${seed.prefix}/apps/app/${secondBody.application.id}/advanced`);
await expect(page.getByText("Danger zone")).toBeVisible({ timeout: 30_000 });
await page.getByText("Danger zone", { exact: true }).click();
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-04-app-page-danger.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();

View File

@ -23,7 +23,7 @@ test("captures the current app removal confirmations", async ({ page }) => {
await page.goto(`/${prefix}/apps/app/${application.id}/advanced`);
await expect(page.getByRole("heading", { name: "Demo Notes" })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Danger zone")).toBeVisible();
await page.getByRole("button", { name: "Danger zone" }).click();
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
await page.screenshot({ path: "test-results/pap-10817-delete-dialog.png", fullPage: true });
@ -41,6 +41,7 @@ test("captures the current app removal confirmations", async ({ page }) => {
await page.goto(`/${prefix}/apps/${connection.id}/advanced`);
await expect(page.getByRole("heading", { name: "Primary connection" })).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Danger zone" }).click();
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
await page.screenshot({ path: "test-results/pap-10817-delete-dialog-guarded.png", fullPage: true });

View File

@ -130,12 +130,8 @@ test.describe.serial("applications lifecycle", () => {
await page.goto(`/${seed.prefix}/apps/${connection.id}/setup`);
await expect(page.getByRole("heading", { name: appName })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Agents can use this app" })).toBeVisible();
await page.getByRole("switch", { name: "Pause this app" }).click();
await expect(page.getByRole("heading", { name: "This app is paused" })).toBeVisible({ timeout: 15_000 });
await page.getByRole("switch", { name: "Resume this app" }).click();
await expect(page.getByRole("heading", { name: "Agents can use this app" })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "Account" })).toBeVisible();
await expect(page.getByText("Anyone in your company can use this connection")).toBeVisible();
await page.getByRole("button", { name: "Rename app" }).click();
await page.getByLabel("App name").fill(renamed);
@ -143,8 +139,15 @@ test.describe.serial("applications lifecycle", () => {
await expect(page.getByRole("heading", { name: renamed })).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-detail.png`, fullPage: true });
await page.goto(`/${seed.prefix}/apps/${connection.id}/advanced`);
await expect(page.getByText("Danger zone")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Danger zone" }).click();
const pauseConnection = page.getByRole("switch", { name: "Pause connection" });
await pauseConnection.click();
await expect(pauseConnection).toBeChecked({ timeout: 15_000 });
await expect(page.getByText("App paused").first()).toBeVisible();
await pauseConnection.click();
await expect(pauseConnection).not.toBeChecked({ timeout: 15_000 });
await expect(page.getByText("App resumed").first()).toBeVisible();
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-connected.png`, fullPage: true });
@ -161,7 +164,7 @@ test.describe.serial("applications lifecycle", () => {
await page.goto(`/${seed.prefix}/apps/app/${cleanApp.id}/advanced`);
await expect(page.getByRole("heading", { name: cleanAppName })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("Danger zone")).toBeVisible();
await page.getByRole("button", { name: "Danger zone" }).click();
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-not-connected.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();

View File

@ -157,12 +157,12 @@ test.describe.serial("dark-mode Apps surfaces", () => {
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-04-advanced-paste-dark.png`, fullPage: true });
});
test("developer tabs share the merged Apps sidebar", async ({ page }) => {
test("developer routes share the merged Apps sidebar without hidden tabs", async ({ page }) => {
await forceDark(page);
await page.goto(`/${seed.prefix}/apps/advanced/profiles`);
await expect(page.getByRole("heading", { name: "Access profiles" })).toBeVisible({ timeout: 30_000 });
await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toBeVisible();
await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toBeVisible();
await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0);
await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0);
await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible();
await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0);
// Apps section lives in the same sidebar now.
@ -180,6 +180,7 @@ test.describe.serial("dark-mode Apps surfaces", () => {
await page.getByLabel("App name").fill("QA Renamed App");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("heading", { name: "QA Renamed App" })).toBeVisible({ timeout: 20_000 });
await page.getByText("Danger zone", { exact: true }).click();
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-06-danger-zone-dark.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();

View File

@ -166,9 +166,9 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
// Submit (button label is "Check link").
await page.getByRole("button", { name: /Check link/i }).click();
// Link-mode setup uses the safe organization/any-agent defaults, enables
// discovered actions, and applies risk-based ask-first defaults in one
// commit. Classification remains covered by the server suite.
// The Access choice was captured before credentials. A successful generic
// probe now commits discovered actions and risk defaults transactionally,
// so the key check lands directly on success.
await expect(page.getByRole("heading", { name: /is ready\.$/i })).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-05-success.png`, fullPage: true });

View File

@ -0,0 +1,373 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { createServer, type Server } from "node:http";
import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
type Json = Record<string, unknown>;
type Seed = { companyId: string; prefix: string };
type Agent = { id: string; name: string };
async function json<T = Json>(
response: Awaited<ReturnType<APIRequestContext["get"]>>,
): Promise<T> {
expect(
response.ok(),
`${response.url()} failed ${response.status()}: ${await response.text()}`,
).toBe(true);
return (await response.json()) as T;
}
async function newCompany(request: APIRequestContext): Promise<Seed> {
const company = await json<{ id: string; issuePrefix: string }>(
await request.post("/api/companies", {
data: { name: `Connection intent E2E ${Date.now()}` },
}),
);
await json(
await request.patch("/api/instance/settings/experimental", {
data: { enableApps: true },
}),
);
return { companyId: company.id, prefix: company.issuePrefix };
}
async function createAgent(
request: APIRequestContext,
companyId: string,
name: string,
): Promise<Agent> {
return await json<Agent>(
await request.post(`/api/companies/${companyId}/agents`, {
data: {
name,
role: "qa",
title: "Connection intent fixture agent",
capabilities: "Exercises deterministic connection intent wiring.",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["--input-type=module", "-e", "process.exit(0)"],
},
},
}),
);
}
async function startFakeProvider() {
const captures: Array<{ method: string; toolName: string | null }> = [];
const server: Server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const payload = JSON.parse(
Buffer.concat(chunks).toString("utf8") || "{}",
) as {
id?: string | number;
method?: string;
params?: { name?: string };
};
captures.push({
method: String(payload.method ?? "<unknown>"),
toolName: payload.params?.name ?? null,
});
res.writeHead(200, { "Content-Type": "application/json" });
if (payload.method === "tools/list") {
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: payload.id ?? null,
result: {
tools: [
{
name: "notion:list_pages",
title: "List fixture pages",
description:
"Reads deterministic pages from the fake Notion provider.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
},
],
},
}),
);
return;
}
if (payload.method === "tools/call") {
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: payload.id ?? null,
result: {
content: [{ type: "text", text: "Fixture page inventory" }],
},
}),
);
return;
}
res.end(
JSON.stringify({ jsonrpc: "2.0", id: payload.id ?? null, result: {} }),
);
});
const port = await listenOnFetchAllowedPort(server);
return {
url: `http://127.0.0.1:${port}/`,
captures,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
function connectionAwareScript(connectionId: string) {
return `
const post = async (url, body, token = process.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN) => {
const response = await fetch(url, {
method: "POST",
headers: { authorization: \`Bearer \${token}\`, "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) throw new Error(\`\${response.status}: \${await response.text()}\`);
return await response.json();
};
const search = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL, { query: "notion" });
const notion = search.results.find((result) => result.service === "notion");
if (!notion) throw new Error("Notion was not advertised");
if (notion.state !== "ready") {
const requested = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL, { service: notion.service });
if (requested.state !== "needs_user_action") throw new Error("Expected a user-action request");
console.log("waiting for connection intent");
process.exit(0);
}
const apiHeaders = { authorization: \`Bearer \${process.env.PAPERCLIP_API_KEY}\`, "content-type": "application/json" };
const sessionResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/sessions\`, {
method: "POST",
headers: apiHeaders,
body: JSON.stringify({ runId: process.env.PAPERCLIP_RUN_ID, ttlMs: 60000 })
});
if (!sessionResponse.ok) throw new Error(await sessionResponse.text());
const session = await sessionResponse.json();
const toolsResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools\`, {
headers: { "x-paperclip-tool-gateway-token": session.token }
});
const tools = await toolsResponse.json();
const tool = tools.find((entry) => entry.connectionId === ${JSON.stringify(connectionId)} && entry.upstreamToolName === "notion:list_pages");
if (!tool) throw new Error("Continuation did not receive the installed Notion tool");
const call = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools/call\`, {
method: "POST",
headers: { "x-paperclip-tool-gateway-token": session.token, "content-type": "application/json" },
body: JSON.stringify({ tool: tool.name, parameters: {} })
});
if (!call.ok) throw new Error(await call.text());
console.log(await call.text());
`;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function waitForAgentRun(
request: APIRequestContext,
companyId: string,
agentId: string,
) {
let terminalRun: { id: string; status: string } | null = null;
await expect
.poll(
async () => {
const runs = await json<Array<{ id: string; status: string }>>(
await request.get(
`/api/companies/${companyId}/heartbeat-runs?agentId=${agentId}&limit=10`,
),
);
terminalRun =
runs.find((run) => !["queued", "running"].includes(run.status)) ??
null;
return terminalRun?.status ?? null;
},
{ timeout: 45_000 },
)
.toBe("succeeded");
if (!terminalRun)
throw new Error("Agent run completed without a run receipt");
return terminalRun;
}
test("store setup and task connection intent share one fake provider through continuation", async ({
page,
request,
}) => {
test.setTimeout(180_000);
const provider = await startFakeProvider();
try {
const seed = await newCompany(request);
const holder = await createAgent(
request,
seed.companyId,
"Existing access holder",
);
// Entry point one: connect and test the provider through the Connections store.
await page.goto(`/${seed.prefix}/apps`);
await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: /Connect your own tool/i }).click();
await page
.getByPlaceholder("https://example.com/actions")
.fill(provider.url);
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Check link/i }).click();
// A no-auth read-only provider can complete the access/install defaults in
// one commit. Other methods exercise the same intermediate steps in the
// shared-flow component suite.
await expect(page.getByRole("heading", { name: /is ready/i })).toBeVisible({
timeout: 30_000,
});
const connections = await json<{
connections: Array<{ id: string; name: string; config: Json }>;
}>(await request.get(`/api/companies/${seed.companyId}/tools/connections`));
expect(connections.connections).toHaveLength(1);
const connection = connections.connections[0]!;
const connectionId = connection.id;
await json(
await request.patch(`/api/tool-connections/${connectionId}`, {
data: {
config: {
...connection.config,
url: provider.url,
sourceTemplateKey: "notion",
},
},
}),
);
await json(
await request.put(`/api/tool-connections/${connectionId}/installs`, {
data: {
installs: [{ targetType: "agent", targetId: holder.id }],
},
}),
);
await page.goto(`/${seed.prefix}/apps/${connectionId}/test`);
await expect(
page.getByRole("heading", { name: "Test an action" }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /List fixture pages/i }).click();
await page.getByRole("button", { name: "Run", exact: true }).click();
await expect(page.getByText("Fixture page inventory")).toBeVisible({
timeout: 30_000,
});
// Entry point two: a scripted agent requests Notion, then the same shared
// provider is reused from the task dialog and appears in the fresh run.
const scout = await createAgent(
request,
seed.companyId,
"Connection requester",
);
await json(
await request.patch(`/api/agents/${scout.id}`, {
data: {
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: [
"--input-type=module",
"-e",
connectionAwareScript(connectionId),
],
},
replaceAdapterConfig: true,
},
}),
);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: {
title: "Read our Notion pages",
status: "in_progress",
assigneeAgentId: scout.id,
},
}),
);
// Assigning an in-progress task is the production wake path. Waiting for
// that run avoids creating a second artificial request from an explicit
// heartbeat invocation.
const firstRun = await waitForAgentRun(request, seed.companyId, scout.id);
const taskUrl = `/${seed.prefix}/issues/${issue.identifier}`;
await page.goto(taskUrl);
await expect(
page.getByText("Connection requester needs Notion"),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Connect / Use existing" }).click();
await expect(
page.getByRole("heading", { name: "Use an existing connection" }),
).toBeVisible();
await page
.getByRole("button", { name: new RegExp(escapeRegExp(connection.name)) })
.click();
await expect(page.getByText("Notion connected")).toBeVisible({
timeout: 30_000,
});
await expect(page).toHaveURL(new RegExp(`${taskUrl}$`));
await expect(
page
.getByTestId("connection-intent-focus-target")
.filter({ hasText: "Notion connected" }),
).toBeFocused();
expect(await page.locator("body").innerText()).not.toMatch(
/\/authorize\?|authorizationUrl/,
);
await expect
.poll(
async () => {
const runs = await json<Array<{ id: string; status: string }>>(
await request.get(
`/api/companies/${seed.companyId}/heartbeat-runs?agentId=${scout.id}&limit=10`,
),
);
return runs.find((run) => run.id !== firstRun.id)?.status ?? null;
},
{ timeout: 45_000 },
)
.toBe("succeeded");
await expect
.poll(() =>
provider.captures.some(
(capture) =>
capture.method === "tools/call" &&
capture.toolName === "notion:list_pages",
),
)
.toBe(true);
const interactions = await json<Array<{ kind: string; status: string }>>(
await request.get(`/api/issues/${issue.id}/interactions`),
);
const connectionIntents = interactions.filter(
(interaction) => interaction.kind === "connection_intent",
);
expect(
connectionIntents.filter(
(interaction) => interaction.status === "accepted",
),
).toHaveLength(1);
expect(
connectionIntents.filter(
(interaction) => interaction.status === "pending",
),
).toHaveLength(0);
expect(
connectionIntents.every((interaction) =>
["accepted", "expired"].includes(interaction.status),
),
).toBe(true);
expect(holder.id).not.toBe(scout.id);
} finally {
await provider.close();
}
});

View File

@ -250,6 +250,9 @@ describe("Apps routes", () => {
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connections" element={<Connections />} />');
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
expect(appSource).toContain('path="apps/vercel-connect"');
expect(appSource).toContain('<AppsConnectEntryRoute credentialSource="vercel_connect" />');
expect(appSource).toContain('<AppsConnect credentialSource={credentialSource} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');

View File

@ -1,4 +1,5 @@
import { lazy, Suspense } from "react";
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
import { Navigate, Outlet, Route, Routes, useActiveCompanyPrefix, useLocation, useParams } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n";
@ -157,6 +158,10 @@ function boardRoutes() {
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Connections />} />
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
<Route
path="apps/vercel-connect"
element={<AppsConnectEntryRoute credentialSource="vercel_connect" />}
/>
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
@ -350,10 +355,16 @@ function boardRoutes() {
);
}
function AppsConnectEntryRoute() {
function AppsConnectEntryRoute({
credentialSource = "paperclip_vault",
}: {
credentialSource?: ToolConnectionCredentialSource;
} = {}) {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
return canEnterAppsConnect(searchParams) ? <AppsConnect /> : <Navigate to="/apps" replace />;
return canEnterAppsConnect(searchParams)
? <AppsConnect credentialSource={credentialSource} />
: <Navigate to="/apps" replace />;
}
function InboxRootRedirect() {

View File

@ -9,6 +9,14 @@ export const connectionIntentsApi = {
api.get<ConnectionIntentSetupOptions>(
`/connection-intents/${interactionId}/setup-options`,
),
setPhase: (
interactionId: string,
phase: ConnectionIntentInteraction["payload"]["phase"],
) =>
api.post<ConnectionIntentInteraction>(
`/connection-intents/${interactionId}/phase`,
{ phase },
),
complete: (interactionId: string, connectionId: string) =>
api.post<ConnectionIntentInteraction>(
`/connection-intents/${interactionId}/complete`,

View File

@ -6,12 +6,12 @@ import type {
} from "@/pages/apps/composio-services";
import type {
ToolApplication,
ConnectToolApp,
ToolConnection,
ToolConnectionInstall,
ToolConnectionInstallSnapshot,
ToolConnectionRemovalSummary,
ConnectToolAppResult,
ConnectToolApp,
FinishToolAppResult,
ToolCatalogEntry,
ToolRuntimeSlot,
@ -61,9 +61,11 @@ import type {
CreateToolTrustRuleFromActionRequest,
ToolRedactedValueSummary,
ConnectionGrant,
ConnectionGrantKind,
ConnectionGrantDelegation,
ConnectionGrantsResponse,
ToolConnectionCreateCapabilities,
ToolAppMetadataPreflightResult,
} from "@paperclipai/shared";
import { api } from "./client";
@ -87,6 +89,15 @@ export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
export type ToolGalleryResponse = {
apps: AppDefinition[];
capabilities: ToolConnectionCreateCapabilities;
credentialSources: {
vercelConnect: {
available: boolean;
enabled: boolean;
authentication: "workload_oidc" | "access_token" | null;
manageUrl: string;
reason: string | null;
};
};
};
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
@ -268,12 +279,30 @@ export const toolsApi = {
// --- Applications ---
listGallery: (companyId: string) =>
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
preflightAppMetadata: (companyId: string, galleryKey: string, methodKey?: string | null) => {
const query = methodKey ? `?methodKey=${encodeURIComponent(methodKey)}` : "";
return api.get<ToolAppMetadataPreflightResult>(
`/companies/${companyId}/tools/apps/${encodeURIComponent(galleryKey)}/preflight${query}`,
);
},
connectApp: (companyId: string, input: ConnectToolApp) =>
api.post<ConnectToolAppResult>(`/companies/${companyId}/tools/apps/connect`, input),
startOAuth: (connectionId: string, interactionId?: string) =>
api.post<ToolOAuthStartResult>(
`/tools/oauth/${connectionId}/start`,
interactionId ? { interactionId } : {},
startOAuth: (
connectionId: string,
input: {
asCurrentUser?: boolean;
interactionId?: string;
} = {},
) =>
api.post<ToolOAuthStartResult>(`/tools/oauth/${connectionId}/start`, input),
finalizeOAuthAccess: (
companyId: string,
connectionId: string,
input: { grantKind: ConnectionGrantKind },
) =>
api.post<FinishToolAppResult>(
`/companies/${companyId}/tools/apps/${connectionId}/finalize-oauth-access`,
input,
),
finishApp: (companyId: string, connectionId: string, input: {
enabledCatalogEntryIds: string[];

View File

@ -20,6 +20,7 @@ import { AppLogo } from "@/pages/apps/AppLogo";
import {
appApplicationSourceSlug,
appConnectionSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
@ -106,6 +107,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
brandKey={brandKey}
logoUrl={appDefinitionLogoUrl(logoEntry)}
allowRemoteFallback={!applicationsQuery.isPending}
darkLogoUrl={appDefinitionDarkLogoUrl(logoEntry)}
size={28}
/>
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>

View File

@ -146,8 +146,11 @@ describe("AppsSidebar", () => {
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ label: "Applications" }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles", end: true }),
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/gateways", label: "Gateways" }),
);
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/profiles", label: "Profiles" }),
);
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" }));
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Health" }));

View File

@ -12,7 +12,7 @@ import { SidebarNavItem } from "./SidebarNavItem";
* PAP-13254 / U3).
*
* Back · APPS: Browse / Review (n)
* DEVELOPER: Connections / Gateways / Profiles / Activity
* DEVELOPER: Connections / Activity
*
* "Browse" is the store and "Review" holds decisions waiting on the user's
* OK. Connection management lives with the Developer tools.
@ -30,9 +30,12 @@ export function AppsSidebar() {
const reviewCount = useReviewCount();
const { enabled: smokeLabEnabled } = useSmokeLabEnabled();
const developerTabs = DEVELOPER_TABS.filter(
(tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled,
);
const developerTabs = DEVELOPER_TABS.filter((tab) => {
// Temporarily hide Gateways and Profiles until they are ready to ship.
// Keep their tab definitions and routes intact so we can bring them back later.
if (tab.key === "gateways" || tab.key === "profiles") return false;
return !isExperimentalToolTab(tab.key) || smokeLabEnabled;
});
return (
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">

View File

@ -1,178 +0,0 @@
import { useEffect, useState } from "react";
import { CheckCircle2, Clock, Loader2, Plug, XCircle } from "lucide-react";
import type {
ConnectionIntentInteraction,
ConnectionIntentSetupOptions,
} from "@paperclipai/shared";
import { connectionIntentsApi } from "@/api/connection-intents";
import { Link } from "@/lib/router";
import { AppLogo } from "@/pages/apps/AppLogo";
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "@/pages/apps/app-connect-policy";
import { Button } from "./ui/button";
export interface ConnectionIntentInteractionBodyProps {
interaction: ConnectionIntentInteraction;
currentUserId?: string | null;
addresseeLabel: string;
}
export function ConnectionIntentInteractionBody({
interaction,
currentUserId,
addresseeLabel,
}: ConnectionIntentInteractionBodyProps) {
const [current, setCurrent] = useState(interaction);
const [options, setOptions] = useState<ConnectionIntentSetupOptions | null>(null);
const [expanded, setExpanded] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => setCurrent(interaction), [interaction]);
const isAddressee = Boolean(currentUserId && current.addresseeUserId === currentUserId);
const connectHref = isMcpDirectOAuthConnectSlug(current.payload.serviceSlug)
? appSourceConnectHref(current.payload.serviceSlug, current.id)
: `/apps/connect?${new URLSearchParams({
byo: "1",
appKey: current.payload.serviceSlug,
intent: current.id,
}).toString()}`;
async function loadOptions() {
setExpanded(true);
setPendingAction("load");
setError(null);
try {
setOptions(await connectionIntentsApi.setupOptions(current.id));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Couldnt load connection options.");
} finally {
setPendingAction(null);
}
}
async function complete(connectionId: string) {
setPendingAction(connectionId);
setError(null);
try {
setCurrent(await connectionIntentsApi.complete(current.id, connectionId));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Couldnt use this connection.");
} finally {
setPendingAction(null);
}
}
async function decline() {
setPendingAction("decline");
setError(null);
try {
setCurrent(await connectionIntentsApi.decline(current.id));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Couldnt decline this request.");
} finally {
setPendingAction(null);
}
}
if (current.status === "accepted" || current.status === "rejected" || current.status === "expired") {
const connected = current.status === "accepted";
const StatusIcon = connected ? CheckCircle2 : XCircle;
const title = connected
? `${current.payload.serviceName} connected`
: current.status === "rejected"
? "Connection declined"
: "Connection request expired";
return (
<div className="flex items-start gap-3" data-testid="connection-intent-terminal">
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-foreground">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">
{connected
? `${current.payload.requestingAgentName} can use this connection on its continuation run.`
: `${current.payload.requestingAgentName} can continue without this connection.`}
</p>
</div>
</div>
);
}
if (!isAddressee) {
return (
<div className="flex items-start gap-3" data-testid="connection-intent-waiting">
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-foreground">Waiting for {addresseeLabel}</p>
<p className="mt-1 text-sm text-muted-foreground">
Only the addressed person can choose or create a connection.
</p>
</div>
</div>
);
}
return (
<div data-testid="connection-intent-actions">
<div className="flex items-start gap-3">
<AppLogo
name={current.payload.serviceName}
brandKey={current.payload.serviceSlug}
logoUrl={current.payload.serviceLogoUrl}
size={40}
/>
<div>
<p className="font-medium text-foreground">
{current.payload.requestingAgentName} needs {current.payload.serviceName}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Reuse an eligible connection or connect a new identity for this agent.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button type="button" onClick={() => void loadOptions()} disabled={pendingAction !== null}>
{pendingAction === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
Connect / Use existing
</Button>
<Button
type="button"
variant="ghost"
onClick={() => void decline()}
disabled={pendingAction !== null}
>
Not now
</Button>
</div>
{expanded && pendingAction === "load" ? (
<p className="mt-3 text-sm text-muted-foreground">Loading connection options</p>
) : null}
{expanded && options ? (
<div className="mt-3 space-y-2 rounded-md border border-border bg-muted/30 p-3">
{options.existingConnections.map((connection) => (
<Button
key={connection.id}
type="button"
variant="outline"
className="w-full justify-start"
onClick={() => void complete(connection.id)}
disabled={pendingAction !== null}
>
{pendingAction === connection.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
Use {connection.name}
</Button>
))}
<Button asChild type="button" variant="outline" className="w-full justify-start">
<Link to={connectHref}>Connect a new {current.payload.serviceName} identity</Link>
</Button>
<p className="text-xs text-muted-foreground">
Finishing setup will grant the new identity and resolve this request automatically.
</p>
</div>
) : null}
{error ? <p className="mt-3 text-sm text-destructive" role="alert">{error}</p> : null}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { act as reactAct, type ComponentProps, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { ApiError } from "../api/client";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
@ -48,6 +49,8 @@ import {
companyCappedRequestConfirmationInteraction,
legacyRestrictedRequestConfirmationInteraction,
pendingConnectionIntentInteraction,
retryConnectionIntentInteraction,
connectedConnectionIntentInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
let root: Root | null = null;
@ -89,17 +92,22 @@ function renderCard(
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
act(() => {
root?.render(
<TooltipProvider>
<ThemeProvider>
<IssueThreadInteractionCard
interaction={pendingAskUserQuestionsInteraction}
{...props}
/>
</ThemeProvider>
</TooltipProvider>,
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<ThemeProvider>
<IssueThreadInteractionCard
interaction={pendingAskUserQuestionsInteraction}
{...props}
/>
</ThemeProvider>
</TooltipProvider>
</QueryClientProvider>,
);
});
@ -116,7 +124,7 @@ afterEach(() => {
});
describe("IssueThreadInteractionCard", () => {
it("offers connection resolution actions to the addressed user", async () => {
it("opens the shared connection setup for the addressed user", async () => {
connectionIntentsApiMocks.setupOptions.mockResolvedValue({ existingConnections: [] });
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
@ -133,12 +141,15 @@ describe("IssueThreadInteractionCard", () => {
loadButton?.click();
await Promise.resolve();
});
const connectLink = Array.from(host.querySelectorAll("a")).find((link) =>
link.textContent === "Connect a new Notion identity",
);
expect(connectLink?.getAttribute("href")).toBe(
"/apps/connect?source=notion&intent=interaction-connection-intent-default",
await vi.waitFor(() =>
expect(connectionIntentsApiMocks.setupOptions).toHaveBeenCalledWith(
"interaction-connection-intent-default",
),
);
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
expect(dialog?.textContent).toContain("Connect Notion");
expect(dialog?.textContent).toContain("Complete connection setup without leaving this task.");
});
it("keeps connection resolution controls exclusive to the addressed user", () => {
@ -1391,6 +1402,55 @@ describe("IssueThreadInteractionCard connection-authorization card", () => {
});
});
describe("IssueThreadInteractionCard connection-intent card", () => {
const USER_LABELS = new Map([[issueThreadInteractionFixtureMeta.currentUserId, "Carol"]]);
it("offers the addressed user the shared setup entry point and Not now", () => {
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
});
expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull();
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
expect(labels).toContain("Connect / Use existing");
expect(labels).toContain("Not now");
expect(host.textContent).toContain("Access is added only for this agent");
});
it("shows another viewer only who it is waiting for", () => {
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
currentUserId: "user-someone-else",
userLabelMap: USER_LABELS,
});
expect(host.querySelector('[data-testid="connection-intent-waiting"]')?.textContent)
.toContain("Waiting for Carol");
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeNull();
expect(host.querySelectorAll("button")).toHaveLength(0);
expect(host.innerHTML).not.toContain("authorizationUrl");
});
it("renders retry and connected terminal states without generic confirmation actions", () => {
const retry = renderCard({
interaction: retryConnectionIntentInteraction,
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
});
expect(retry.textContent).toContain("Authorization didnt finish");
expect(retry.textContent).toContain("Try again");
act(() => root?.unmount());
retry.remove();
root = null;
const connected = renderCard({
interaction: connectedConnectionIntentInteraction,
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
});
expect(connected.querySelector('[data-testid="connection-intent-terminal"]')?.textContent)
.toContain("Notion connected");
expect(connected.textContent).not.toContain("Approve");
});
});
describe("IssueThreadInteractionCard resolver audience", () => {
it("shows an open audience on a pending card created without a restriction", () => {
const host = renderCard({ interaction: pendingRequestConfirmationInteraction });

View File

@ -41,7 +41,7 @@ import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { ProposalJustification } from "../pages/secrets/proposal-review";
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody";
const OTHER_ANSWER_ID = "__paperclip_other__";
@ -200,6 +200,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return "Checkbox confirmation";
case "request_item_verdicts":
return "Item verdicts";
case "connection_intent":
return "Connection request";
default:
return kind;
}
@ -4082,7 +4084,7 @@ export function IssueThreadInteractionCard({
below, because the closing sentence depends on whether the
reader is the person who may consent. Rendering the summary here
as well would be the second body PAP-17859 removed. */}
{interaction.summary && !connectionAuthorization ? (
{interaction.summary && !connectionAuthorization && interaction.kind !== "connection_intent" ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
{interaction.summary}
</p>
@ -4162,18 +4164,18 @@ export function IssueThreadInteractionCard({
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
) : interaction.kind === "request_item_verdicts" ? (
<RequestItemVerdictsCard
interaction={interaction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
externalReferences={externalReferences}
/>
) : interaction.kind === "connection_intent" ? (
<ConnectionIntentInteractionBody
interaction={interaction}
currentUserId={currentUserId}
addresseeLabel={addresseeLabel ?? "the addressed person"}
/>
) : interaction.kind === "request_item_verdicts" ? (
<RequestItemVerdictsCard
interaction={interaction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
externalReferences={externalReferences}
/>
) : (
<RequestConfirmationCard
interaction={interaction}

View File

@ -644,7 +644,7 @@ describe("Layout", () => {
// 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", "connections", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
it.each(["browse", "connections", "vercel-connect", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
currentPathname = `/PAP/apps/${route}`;
const root = createRoot(container);
const queryClient = new QueryClient({

View File

@ -64,6 +64,7 @@ const RESERVED_APP_SUBPATHS = new Set([
"browse",
"connections",
"connect",
"vercel-connect",
"review",
"attention",
"gateways",

View File

@ -0,0 +1,347 @@
// @vitest-environment jsdom
import { act as reactAct, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ConnectionIntentInteraction,
ToolConnection,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
connectedConnectionIntentInteraction,
issueThreadInteractionFixtureMeta,
pendingConnectionIntentInteraction,
retryConnectionIntentInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
const setupOptionsMock = vi.hoisted(() => vi.fn());
const completeMock = vi.hoisted(() => vi.fn());
const declineMock = vi.hoisted(() => vi.fn());
const setPhaseMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/connection-intents", () => ({
connectionIntentsApi: {
setupOptions: (...args: unknown[]) => setupOptionsMock(...args),
complete: (...args: unknown[]) => completeMock(...args),
decline: (...args: unknown[]) => declineMock(...args),
setPhase: (...args: unknown[]) => setPhaseMock(...args),
},
}));
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
existingConnections?: ToolConnection[];
onUseExisting?: (id: string) => Promise<void>;
onComplete?: (completion: { connectionId: string }) => void;
onPhaseChange?: (phase: "needs_retry") => void;
onCancel?: () => void;
}) => (
<div data-testid="shared-connection-setup">
<span data-testid="requested-agent">{props.requestedAgentId}</span>
<span data-testid="existing-count">
{props.existingConnections?.length ?? 0}
</span>
{props.existingConnections?.map((connection) => (
<button
key={connection.id}
onClick={() => void props.onUseExisting?.(connection.id)}
>
Use {connection.name}
</button>
))}
<button
onClick={() => props.onComplete?.({ connectionId: "connection-new" })}
>
Connect new
</button>
<button onClick={() => props.onPhaseChange?.("needs_retry")}>
Simulate retry
</button>
<button onClick={props.onCancel}>Cancel setup</button>
</div>
),
}));
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
let host: HTMLDivElement | null = null;
let queryClient: QueryClient;
async function act(callback: () => void | Promise<void>) {
await reactAct(callback);
}
async function flush() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function terminal(
status: ConnectionIntentInteraction["status"],
outcome: "declined" | "superseded" | "expired",
): ConnectionIntentInteraction {
return {
...pendingConnectionIntentInteraction,
id: `interaction-${outcome}`,
status,
resolvedAt: new Date("2026-08-26T12:00:00.000Z"),
result: { version: 1, outcome },
} as ConnectionIntentInteraction;
}
function renderBody(
interaction: ConnectionIntentInteraction = pendingConnectionIntentInteraction,
currentUserId:
string | null = issueThreadInteractionFixtureMeta.currentUserId,
) {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
void act(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<ConnectionIntentInteractionBody
interaction={interaction}
currentUserId={currentUserId}
addresseeLabel="Carol"
/>
</QueryClientProvider>,
);
});
return host;
}
function button(label: string) {
return Array.from(document.body.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
beforeEach(() => {
setupOptionsMock.mockReset();
completeMock.mockReset();
declineMock.mockReset();
setPhaseMock.mockReset();
setupOptionsMock.mockResolvedValue({
requestedAgentId:
pendingConnectionIntentInteraction.payload.requestingAgentId,
existingConnections: [],
});
completeMock.mockResolvedValue({});
declineMock.mockResolvedValue({});
setPhaseMock.mockResolvedValue({});
});
afterEach(async () => {
if (root) await act(() => root?.unmount());
host?.remove();
document.body
.querySelectorAll("[data-radix-focus-guard]")
.forEach((node) => node.remove());
root = null;
host = null;
});
describe("ConnectionIntentInteractionBody states and audience", () => {
it.each([
[pendingConnectionIntentInteraction, "Connect / Use existing"],
[
{
...pendingConnectionIntentInteraction,
payload: {
...pendingConnectionIntentInteraction.payload,
phase: "authorizing",
},
},
"Authorizing…",
],
[retryConnectionIntentInteraction, "Try again"],
[connectedConnectionIntentInteraction, "Notion connected"],
[terminal("rejected", "declined"), "Connection declined"],
[terminal("expired", "superseded"), "Request superseded"],
[terminal("expired", "expired"), "Connection request expired"],
] as const)("renders the %s state", (interaction, expected) => {
renderBody(interaction as ConnectionIntentInteraction);
expect(document.body.textContent).toContain(expected);
});
it("shows non-addressees only the waiting state and never loads setup", () => {
renderBody(pendingConnectionIntentInteraction, "other-user");
expect(document.body.textContent).toContain("Waiting for Carol");
expect(button("Connect / Use existing")).toBeUndefined();
expect(button("Not now")).toBeUndefined();
expect(setupOptionsMock).not.toHaveBeenCalled();
expect(document.body.innerHTML).not.toMatch(
/authorizationUrl|bearer|credential/i,
);
});
});
describe("ConnectionIntentInteractionBody dialog behavior", () => {
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
let resolveSetup!: (value: unknown) => void;
setupOptionsMock.mockReturnValue(
new Promise((resolve) => {
resolveSetup = resolve;
}),
);
renderBody();
await act(() => button("Connect / Use existing")?.click());
expect(document.body.textContent).toContain("Loading connection options…");
await act(async () => {
resolveSetup({
requestedAgentId: "agent-requesting",
existingConnections: [
{ id: "connection-one", name: "Carol's Notion" },
{ id: "connection-two", name: "Team Notion" },
],
});
});
await flush();
expect(
document.querySelector('[data-testid="requested-agent"]')?.textContent,
).toBe("agent-requesting");
expect(
document.querySelector('[data-testid="existing-count"]')?.textContent,
).toBe("2");
expect(button("Use Carol's Notion")).toBeDefined();
expect(button("Use Team Notion")).toBeDefined();
expect(button("Connect new")).toBeDefined();
});
it("keeps a load failure open and recovers through the query retry", async () => {
setupOptionsMock.mockRejectedValueOnce(new Error("setup unavailable"));
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
expect(document.body.textContent).toContain(
"Couldnt load connection setup",
);
expect(document.body.textContent).toContain("setup unavailable");
setupOptionsMock.mockResolvedValueOnce({
requestedAgentId: "agent-requesting",
existingConnections: [],
});
await act(() => button("Try again")?.click());
await flush();
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).not.toBeNull();
});
it("completes an existing connection, closes, restores focus, and invalidates each task query once", async () => {
setupOptionsMock.mockResolvedValue({
requestedAgentId: "agent-requesting",
existingConnections: [{ id: "connection-one", name: "Carol's Notion" }],
});
renderBody();
const trigger = button("Connect / Use existing")!;
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
await act(() => trigger.click());
await flush();
await act(() => button("Use Carol's Notion")?.click());
await flush();
expect(completeMock).toHaveBeenCalledWith(
pendingConnectionIntentInteraction.id,
"connection-one",
);
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).toBeNull();
await waitForAssertion(() =>
expect(document.activeElement).toBe(
document.querySelector(
'[data-testid="connection-intent-focus-target"]',
),
),
);
expect(invalidation).toHaveBeenCalledTimes(2);
expect(invalidation).toHaveBeenCalledWith({
queryKey: ["issues", "interactions"],
});
expect(invalidation).toHaveBeenCalledWith({
queryKey: ["issues", "detail"],
});
});
it("keeps the dialog open and surfaces completion failures", async () => {
completeMock.mockRejectedValue(new Error("install commit failed"));
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Connect new")?.click());
await flush();
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).not.toBeNull();
expect(
document.body.querySelector('[role="alert"]')?.textContent,
).toContain("install commit failed");
});
it("declines once with pending controls disabled and invalidates each task query once", async () => {
let finishDecline!: () => void;
declineMock.mockReturnValue(
new Promise<void>((resolve) => {
finishDecline = resolve;
}),
);
renderBody();
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
const decline = button("Not now")!;
await act(() => decline.click());
await flush();
expect(decline.disabled).toBe(true);
decline.click();
expect(declineMock).toHaveBeenCalledTimes(1);
await act(async () => finishDecline());
await flush();
expect(invalidation).toHaveBeenCalledTimes(2);
});
it("turns shared-flow retry signals into the server-authored retry phase", async () => {
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Simulate retry")?.click());
await flush();
expect(setPhaseMock).toHaveBeenCalledWith(
pendingConnectionIntentInteraction.id,
"needs_retry",
);
});
});

View File

@ -0,0 +1,334 @@
import { useCallback, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
Clock,
Loader2,
Plug,
RotateCcw,
XCircle,
} from "lucide-react";
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
import { connectionIntentsApi } from "@/api/connection-intents";
import { AppLogo } from "@/pages/apps/AppLogo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
ConnectionSetupFlow,
type ConnectionSetupCompletion,
} from "./ConnectionSetupFlow";
export interface ConnectionIntentInteractionBodyProps {
interaction: ConnectionIntentInteraction;
currentUserId?: string | null;
addresseeLabel: string;
}
export function ConnectionIntentInteractionBody({
interaction,
currentUserId,
addresseeLabel,
}: ConnectionIntentInteractionBodyProps) {
const [open, setOpen] = useState(false);
const focusTargetRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const isAddressee = Boolean(
currentUserId && interaction.addresseeUserId === currentUserId,
);
const isPending = interaction.status === "pending";
const invalidateTask = async (
updatedInteraction?: ConnectionIntentInteraction,
) => {
if (updatedInteraction) {
queryClient.setQueriesData<ConnectionIntentInteraction[]>(
{ queryKey: ["issues", "interactions"] },
(current) =>
current?.map((candidate) =>
candidate.id === updatedInteraction.id
? updatedInteraction
: candidate,
),
);
}
await Promise.all([
// Task routes may key these caches by either UUID or human identifier.
// Prefix invalidation reaches the mounted task without requiring that
// routing identity to leak into the reusable interaction card.
queryClient.invalidateQueries({ queryKey: ["issues", "interactions"] }),
queryClient.invalidateQueries({ queryKey: ["issues", "detail"] }),
]);
};
const returnFocusToCard = () => {
window.requestAnimationFrame(() => focusTargetRef.current?.focus());
};
const setupQuery = useQuery({
queryKey: ["connection-intent", interaction.id, "setup-options"],
queryFn: () => connectionIntentsApi.setupOptions(interaction.id),
enabled: open && isAddressee && isPending,
refetchInterval: open && isPending ? 2_000 : false,
});
const completeMutation = useMutation({
mutationFn: (connectionId: string) =>
connectionIntentsApi.complete(interaction.id, connectionId),
onSuccess: async (updatedInteraction) => {
await invalidateTask(updatedInteraction);
setOpen(false);
returnFocusToCard();
},
});
const declineMutation = useMutation({
mutationFn: () => connectionIntentsApi.decline(interaction.id),
onSuccess: async (updatedInteraction) => {
await invalidateTask(updatedInteraction);
setOpen(false);
returnFocusToCard();
},
});
const phaseMutation = useMutation({
mutationFn: (phase: ConnectionIntentInteraction["payload"]["phase"]) =>
connectionIntentsApi.setPhase(interaction.id, phase),
onSuccess: invalidateTask,
});
const mutatePhase = phaseMutation.mutate;
const handlePhaseChange = useCallback(
(phase: ConnectionIntentInteraction["payload"]["phase"]) =>
mutatePhase(phase),
[mutatePhase],
);
const finishNewConnection = async (completion: ConnectionSetupCompletion) => {
if (completion.resolvedByCallback) {
await invalidateTask();
setOpen(false);
returnFocusToCard();
return;
}
completeMutation.mutate(completion.connectionId);
};
const resultOutcome = interaction.result?.outcome;
const status =
interaction.status === "accepted"
? {
icon: CheckCircle2,
title: `${interaction.payload.serviceName} connected`,
body: `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`,
}
: interaction.status === "rejected"
? {
icon: XCircle,
title: "Connection declined",
body: `${interaction.payload.requestingAgentName} was notified and can continue without it.`,
}
: interaction.status === "expired"
? {
icon: Clock,
title:
resultOutcome === "superseded"
? "Request superseded"
: "Connection request expired",
body:
resultOutcome === "superseded"
? "A newer run requested this connection. Use the latest card instead."
: "This request is no longer active.",
}
: null;
const StatusIcon = status?.icon;
if (status && StatusIcon) {
return (
<div
ref={focusTargetRef}
tabIndex={-1}
data-testid="connection-intent-focus-target"
>
<div
className="flex items-start gap-3"
data-testid="connection-intent-terminal"
>
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-foreground">{status.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{status.body}</p>
</div>
</div>
</div>
);
}
if (!isAddressee) {
return (
<div
ref={focusTargetRef}
tabIndex={-1}
data-testid="connection-intent-focus-target"
>
<div
className="flex items-start gap-3"
data-testid="connection-intent-waiting"
>
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-foreground">
Waiting for {addresseeLabel}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Only the addressed person can choose an identity or authorize this
connection.
</p>
</div>
</div>
</div>
);
}
const needsRetry = interaction.payload.phase === "needs_retry";
const authorizing = interaction.payload.phase === "authorizing";
return (
<div
ref={focusTargetRef}
tabIndex={-1}
data-testid="connection-intent-focus-target"
>
<div data-testid="connection-intent-actions">
<div className="flex items-start gap-3">
<AppLogo
name={interaction.payload.serviceName}
logoUrl={interaction.payload.serviceLogoUrl}
darkLogoUrl={interaction.payload.serviceDarkLogoUrl}
size={40}
/>
<div>
<p className="font-medium text-foreground">
{interaction.payload.requestingAgentName} needs{" "}
{interaction.payload.serviceName}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Connect your identity or reuse an eligible connection. Access is
added only for this agent.
</p>
</div>
</div>
{needsRetry ? (
<p className="mt-4 flex items-center gap-2 text-sm text-destructive">
<RotateCcw className="h-4 w-4" />
Authorization didnt finish. Your previous choices are safe; try
again.
</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" disabled={authorizing}>
{authorizing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plug className="h-4 w-4" />
)}
{authorizing
? "Authorizing…"
: needsRetry
? "Try again"
: "Connect / Use existing"}
</Button>
</DialogTrigger>
<DialogContent
className="!max-w-(--pct-90) max-h-(--sz-85vh) w-full overflow-y-auto sm:max-w-5xl"
onCloseAutoFocus={(event) => {
event.preventDefault();
focusTargetRef.current?.focus();
}}
>
<DialogHeader className="sr-only">
<DialogTitle>
Connect {interaction.payload.serviceName}
</DialogTitle>
<DialogDescription>
Complete connection setup without leaving this task.
</DialogDescription>
</DialogHeader>
{setupQuery.isLoading ? (
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
connection options
</div>
) : setupQuery.isError ? (
<div className="py-8 text-center">
<p className="font-medium text-foreground">
Couldnt load connection setup
</p>
<p className="mt-1 text-sm text-muted-foreground">
{setupQuery.error instanceof Error
? setupQuery.error.message
: "Try again."}
</p>
<Button
className="mt-4"
variant="outline"
onClick={() => setupQuery.refetch()}
>
Try again
</Button>
</div>
) : setupQuery.data ? (
<ConnectionSetupFlow
host="dialog"
serviceSlug={interaction.payload.serviceSlug}
requestedAgentId={setupQuery.data.requestedAgentId}
interactionId={interaction.id}
existingConnections={setupQuery.data.existingConnections}
onUseExisting={async (connectionId) => {
await completeMutation.mutateAsync(connectionId);
}}
onComplete={(completion) => {
void finishNewConnection(completion);
}}
onOAuthDeclined={() => declineMutation.mutate()}
onPhaseChange={handlePhaseChange}
onCancel={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
<Button
type="button"
variant="ghost"
disabled={declineMutation.isPending || authorizing}
onClick={() => declineMutation.mutate()}
>
Not now
</Button>
</div>
{completeMutation.isError ||
declineMutation.isError ||
phaseMutation.isError ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{(completeMutation.error ??
declineMutation.error ??
phaseMutation.error) instanceof Error
? (
completeMutation.error ??
declineMutation.error ??
phaseMutation.error
)?.message
: "Couldnt update this connection request."}
</p>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
function source(relativePath: string) {
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
}
describe("shared connection setup architecture", () => {
it("keeps both hosts thin and routes provider setup through ConnectionSetupFlow", () => {
const pageHost = source("../../pages/apps/AppsConnect.tsx");
const taskHost = source("./ConnectionIntentInteractionBody.tsx");
for (const host of [pageHost, taskHost]) {
expect(host).toContain("ConnectionSetupFlow");
expect(host).not.toContain("CONNECTABLE_APP_DEFINITIONS");
expect(host).not.toContain("ProviderCredentialField");
expect(host).not.toContain("getAvailableConnectionMethods");
expect(host).not.toContain("toolsApi.connectApp");
expect(host).not.toContain("toolsApi.startOAuth");
}
expect(pageHost).not.toContain("connectionIntentsApi");
expect(taskHost).not.toContain("connect-helpers");
expect(taskHost).not.toContain("connect-ui");
});
});

View File

@ -0,0 +1,226 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
import { getConnectableAppDefinition } from "@paperclipai/shared";
import {
isConnectionDefinitionUnavailable,
isVercelConnectUnavailable,
readConnectionIntentOAuthOutcome,
retainedReconnectMatches,
requestedConnectionSetupResolution,
requestedConnectionEntry,
} from "./ConnectionSetupFlow";
const origin = "https://paperclip.test";
const interactionId = "interaction-123";
function event(data: unknown, eventOrigin = origin) {
return { origin: eventOrigin, data };
}
describe("connection intent OAuth window messages", () => {
it.each(["connected", "declined", "failed"] as const)(
"accepts a matching %s outcome",
(outcome) => {
expect(
readConnectionIntentOAuthOutcome(
event({
type: "paperclip.connection-intent.oauth",
interactionId,
outcome,
}),
origin,
interactionId,
),
).toBe(outcome);
},
);
it.each([
[
"foreign origin",
event(
{
type: "paperclip.connection-intent.oauth",
interactionId,
outcome: "connected",
},
"https://attacker.test",
),
],
[
"wrong interaction",
event({
type: "paperclip.connection-intent.oauth",
interactionId: "other",
outcome: "connected",
}),
],
[
"wrong message type",
event({ type: "other", interactionId, outcome: "connected" }),
],
[
"unknown outcome",
event({
type: "paperclip.connection-intent.oauth",
interactionId,
outcome: "authorized",
}),
],
["non-object payload", event("connected")],
])("ignores a %s message", (_label, candidate) => {
expect(
readConnectionIntentOAuthOutcome(candidate, origin, interactionId),
).toBeNull();
});
});
describe("retained reconnect definition lookup", () => {
const connection = { applicationId: "app-1" } as ToolConnection;
it("restores a hidden provider only for its exact retained application", () => {
const githubApplication = {
id: "app-1",
applicationKey: "github",
metadata: { sourceTemplateKey: "github" },
} as unknown as ToolApplication;
expect(requestedConnectionEntry({
requestedAppKey: "github",
galleryApps: [],
reconnectConnection: connection,
applications: [githubApplication],
})?.slug).toBe("github");
expect(requestedConnectionEntry({
requestedAppKey: "notion",
galleryApps: [],
reconnectConnection: connection,
applications: [githubApplication],
})).toBeNull();
});
it("ends unavailable retained reconnects instead of leaving them loading", () => {
expect(requestedConnectionSetupResolution({
reconnectConnectionId: "connection-1",
hasRequestedEntry: false,
supportedMethodCount: 0,
unsupportedOAuth: false,
vercelUnavailable: false,
definitionUnavailable: false,
})).toBe("reconnect_unavailable");
expect(requestedConnectionSetupResolution({
reconnectConnectionId: "connection-1",
hasRequestedEntry: true,
supportedMethodCount: 0,
unsupportedOAuth: false,
vercelUnavailable: false,
definitionUnavailable: false,
})).toBe("reconnect_unavailable");
expect(requestedConnectionSetupResolution({
reconnectConnectionId: null,
hasRequestedEntry: false,
supportedMethodCount: 0,
unsupportedOAuth: false,
vercelUnavailable: false,
definitionUnavailable: false,
})).toBe("fallback");
expect(requestedConnectionSetupResolution({
reconnectConnectionId: "connection-1",
hasRequestedEntry: true,
supportedMethodCount: 1,
unsupportedOAuth: false,
vercelUnavailable: false,
definitionUnavailable: false,
})).toBe("ready");
});
it("does not expose a hidden provider without an exact reconnect target", () => {
expect(requestedConnectionEntry({
requestedAppKey: "github",
galleryApps: [],
reconnectConnection: null,
applications: [],
})).toBeNull();
const visibleNotion = getConnectableAppDefinition("notion")!;
expect(requestedConnectionEntry({
requestedAppKey: "notion",
galleryApps: [visibleNotion],
reconnectConnection: null,
applications: [],
})).toBe(visibleNotion);
});
it("does not apply fresh-setup availability to an exact retained reconnect", () => {
expect(isVercelConnectUnavailable({
credentialSource: "vercel_connect",
available: false,
retainedReconnectMatches: true,
})).toBe(false);
expect(isVercelConnectUnavailable({
credentialSource: "vercel_connect",
available: false,
retainedReconnectMatches: false,
})).toBe(true);
expect(isConnectionDefinitionUnavailable({
available: false,
reconnectConnectionId: "connection-1",
reconnectSourceMatches: true,
})).toBe(false);
expect(isConnectionDefinitionUnavailable({
available: false,
reconnectConnectionId: "connection-1",
reconnectSourceMatches: false,
})).toBe(true);
expect(isConnectionDefinitionUnavailable({
available: false,
reconnectConnectionId: undefined,
reconnectSourceMatches: true,
})).toBe(true);
});
});
describe("retained reconnect target matching", () => {
const connection = { applicationId: "app-1" } as ToolConnection;
const application = { id: "app-1", applicationKey: "custom-mcp" } as ToolApplication;
it("accepts a generic reconnect only for its exact retained application", () => {
expect(retainedReconnectMatches({
requestedAppKey: undefined,
byo: true,
applicationId: "app-1",
reconnectConnection: connection,
reconnectApplication: application,
})).toBe(true);
expect(retainedReconnectMatches({
requestedAppKey: undefined,
byo: true,
applicationId: "app-other",
reconnectConnection: connection,
reconnectApplication: application,
})).toBe(false);
});
it("keeps curated reconnects bound to their provider", () => {
const curated = {
id: "app-1",
applicationKey: "github",
metadata: { sourceTemplateKey: "github" },
} as unknown as ToolApplication;
expect(retainedReconnectMatches({
requestedAppKey: "github",
byo: false,
applicationId: "app-1",
reconnectConnection: connection,
reconnectApplication: curated,
})).toBe(true);
expect(retainedReconnectMatches({
requestedAppKey: "notion",
byo: false,
applicationId: "app-1",
reconnectConnection: connection,
reconnectApplication: curated,
})).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
import { legacyIssueThreadInteractionResolverPolicyAlias } from "@paperclipai/shared";
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
import type { LiveRunForIssue } from "../api/heartbeats";
import type {
IssueChatComment,
@ -8,6 +7,7 @@ import type {
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
import type {
AskUserQuestionsInteraction,
ConnectionIntentInteraction,
IssueThreadInteractionBase,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
@ -24,45 +24,6 @@ export const issueThreadInteractionFixtureMeta = {
currentUserId: "user-board",
} as const;
export const pendingConnectionIntentInteraction: ConnectionIntentInteraction = {
id: "interaction-connection-intent-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "connection_intent",
title: "Connect Notion",
summary: "Researcher needs this connection to continue.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "11111111-1111-4111-8111-111111111111",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
createdAt: new Date("2026-04-20T15:08:00.000Z"),
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
serviceSlug: "notion",
serviceName: "Notion",
serviceLogoUrl: null,
serviceDarkLogoUrl: null,
requestingAgentId: "11111111-1111-4111-8111-111111111111",
requestingAgentName: "Researcher",
phase: "requested",
},
result: null,
resolverPolicy: "human_only",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "governed_action",
legacyResolverPolicyAliases: {
requested: "board_only",
effective: "board_only",
},
};
/**
* Resolver-audience snapshot fields shared by every interaction fixture.
*
@ -910,6 +871,105 @@ export const resolvedConnectionAuthorizationInteraction = createConnectionAuthor
result: { version: 1, outcome: "accepted" },
});
function createConnectionIntentInteraction(
overrides: Partial<ConnectionIntentInteraction> = {},
): ConnectionIntentInteraction {
return {
id: "interaction-connection-intent-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "connection_intent",
title: "Connect Notion",
summary: "Researcher needs this connection to continue.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "11111111-1111-4111-8111-111111111111",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
createdAt: new Date("2026-04-20T15:08:00.000Z"),
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
serviceSlug: "notion",
serviceName: "Notion",
serviceLogoUrl: null,
requestingAgentId: "11111111-1111-4111-8111-111111111111",
requestingAgentName: "Researcher",
phase: "requested",
},
result: null,
resolverPolicy: "human_only",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "governed_action",
legacyResolverPolicyAliases: {
requested: "board_only",
effective: "board_only",
},
...overrides,
};
}
export const pendingConnectionIntentInteraction = createConnectionIntentInteraction();
export const retryConnectionIntentInteraction = createConnectionIntentInteraction({
id: "interaction-connection-intent-retry",
payload: {
version: 1,
serviceSlug: "notion",
serviceName: "Notion",
serviceLogoUrl: null,
requestingAgentId: "11111111-1111-4111-8111-111111111111",
requestingAgentName: "Researcher",
phase: "needs_retry",
},
});
export const authorizingConnectionIntentInteraction = createConnectionIntentInteraction({
id: "interaction-connection-intent-authorizing",
payload: {
version: 1,
serviceSlug: "notion",
serviceName: "Notion",
serviceLogoUrl: null,
requestingAgentId: "11111111-1111-4111-8111-111111111111",
requestingAgentName: "Researcher",
phase: "authorizing",
},
});
export const connectedConnectionIntentInteraction = createConnectionIntentInteraction({
id: "interaction-connection-intent-connected",
status: "accepted",
result: {
version: 1,
outcome: "connected",
connectionId: "22222222-2222-4222-8222-222222222222",
},
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
});
export const declinedConnectionIntentInteraction = createConnectionIntentInteraction({
id: "interaction-connection-intent-declined",
status: "rejected",
result: { version: 1, outcome: "declined", reason: "Not right now" },
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
});
export const supersededConnectionIntentInteraction = createConnectionIntentInteraction({
id: "interaction-connection-intent-superseded",
status: "expired",
result: { version: 1, outcome: "superseded" },
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
});
export const expiredConnectionIntentInteraction = createConnectionIntentInteraction({
id: "interaction-connection-intent-expired",
status: "expired",
result: { version: 1, outcome: "expired" },
resolvedAt: new Date("2026-04-20T15:12:00.000Z"),
});
export const executedSecretProposalInteraction = createSecretProposalConfirmationInteraction({
id: "interaction-secret-proposal-executed",
status: "accepted",

View File

@ -68,7 +68,7 @@ const APP_COPY: Record<string, AppCopy> = {
},
posthog: {
tagline: "Explore product usage, errors, flags, and experiments.",
short: "Choose one project and the analytics tools your agents can use.",
short: "Sign in with PostHog. Project pinning and access controls are optional.",
},
linear: {
tagline: "Create, update and read tickets.",
@ -76,12 +76,40 @@ const APP_COPY: Record<string, AppCopy> = {
},
"google-sheets": {
tagline: "Read and update selected spreadsheets.",
short: "Share each sheet with the robot email, then paste the links.",
short: "Read spreadsheets or update the files you choose.",
},
gmail: {
tagline: "Read mail and create drafts for your review.",
short: "Read mail and create drafts for your review.",
},
"google-drive": {
tagline: "Find, read, and create files in Drive.",
short: "Find, read, and create files in Drive.",
},
"google-docs": {
tagline: "Read and update documents.",
short: "Read and update documents.",
},
"google-slides": {
tagline: "Read and update presentations.",
short: "Read and update presentations.",
},
"google-calendar": {
tagline: "Review calendars and manage events.",
short: "Review calendars and manage events.",
},
"google-chat": {
tagline: "Read conversations and send messages.",
short: "Read conversations and send messages.",
},
"google-people": {
tagline: "Look up contacts and people in your directory.",
short: "Look up contacts and people in your directory.",
},
"google-workspace-search": {
tagline: "Search across your Google workspace.",
short: "Search across your Google workspace.",
},
hubspot: {
tagline: "Look up contacts and update deal stages.",
short: "Look up contacts and update deal stages.",

View File

@ -12,13 +12,71 @@ import {
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
ConnectionIntentInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "./issue-thread-interactions";
import { pendingConnectionIntentInteraction } from "../fixtures/issueThreadInteractionFixtures";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { registerUIAdapter, unregisterUIAdapter } from "../adapters/registry";
describe("connection intents in the task feed", () => {
it("orders the card chronologically and collapses live/polling snapshots into one updated message", () => {
const pending = {
...pendingConnectionIntentInteraction,
createdAt: new Date("2026-08-26T12:02:00.000Z"),
updatedAt: new Date("2026-08-26T12:02:00.000Z"),
};
const connected: ConnectionIntentInteraction = {
...pending,
status: "accepted",
updatedAt: new Date("2026-08-26T12:04:00.000Z"),
resolvedAt: new Date("2026-08-26T12:04:00.000Z"),
result: {
version: 1,
outcome: "connected",
connectionId: "connection-1",
},
};
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "before",
body: "Before",
createdAt: new Date("2026-08-26T12:01:00.000Z"),
}),
createComment({
id: "after",
body: "After",
createdAt: new Date("2026-08-26T12:03:00.000Z"),
}),
],
// Models the brief overlap where live invalidation and polling each
// supplied a snapshot of the same interaction id.
interactions: [pending, connected],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
});
expect(messages.map((message) => message.id)).toEqual([
"before",
`interaction:${pending.id}`,
"after",
]);
const interactionMessages = messages.filter(
(message) => message.id === `interaction:${pending.id}`,
);
expect(interactionMessages).toHaveLength(1);
const custom = interactionMessages[0]?.metadata?.custom as {
interaction?: ConnectionIntentInteraction;
};
expect(custom.interaction?.status).toBe("accepted");
expect(custom.interaction?.result?.outcome).toBe("connected");
});
});
function createAgent(id: string, name: string): Agent {
return {
id,
@ -46,7 +104,9 @@ function createAgent(id: string, name: string): Agent {
} as Agent;
}
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
function createComment(
overrides: Partial<IssueChatComment> = {},
): IssueChatComment {
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-1",
@ -99,9 +159,12 @@ function createInteraction(
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
?? { requested: "board_or_agents", effective: "board_or_agents" },
effectiveResolverPolicySource:
overrides.effectiveResolverPolicySource ?? "requested",
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases ?? {
requested: "board_or_agents",
effective: "board_or_agents",
},
};
}
@ -134,18 +197,29 @@ function createRequestConfirmation(
requestedResolverPolicy: overrides.requestedResolverPolicy ?? "anyone",
effectiveResolverPolicy: overrides.effectiveResolverPolicy ?? "anyone",
resolverPolicyProvenance: overrides.resolverPolicyProvenance ?? "inherited",
effectiveResolverPolicySource: overrides.effectiveResolverPolicySource ?? "requested",
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases
?? { requested: "board_or_agents", effective: "board_or_agents" },
effectiveResolverPolicySource:
overrides.effectiveResolverPolicySource ?? "requested",
legacyResolverPolicyAliases: overrides.legacyResolverPolicyAliases ?? {
requested: "board_or_agents",
effective: "board_or_agents",
},
};
}
describe("buildAssistantPartsFromTranscript", () => {
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " },
{
kind: "assistant",
ts: "2026-04-06T12:00:00.000Z",
text: "Working on it. ",
},
{ kind: "assistant", ts: "2026-04-06T12:00:01.000Z", text: "Done." },
{ kind: "thinking", ts: "2026-04-06T12:00:02.000Z", text: "Need to inspect files." },
{
kind: "thinking",
ts: "2026-04-06T12:00:02.000Z",
text: "Need to inspect files.",
},
{
kind: "tool_call",
ts: "2026-04-06T12:00:03.000Z",
@ -160,12 +234,22 @@ describe("buildAssistantPartsFromTranscript", () => {
content: "file contents",
isError: false,
},
{ kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" },
{
kind: "stderr",
ts: "2026-04-06T12:00:05.000Z",
text: "warn: noisy setup output",
},
]);
expect(result.parts).toHaveLength(3);
expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." });
expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." });
expect(result.parts[0]).toMatchObject({
type: "text",
text: "Working on it. Done.",
});
expect(result.parts[1]).toMatchObject({
type: "reasoning",
text: "Need to inspect files.",
});
expect(result.parts[2]).toMatchObject({
type: "tool-call",
toolCallId: "tool-1",
@ -194,7 +278,11 @@ describe("buildAssistantPartsFromTranscript", () => {
content: "ok",
isError: false,
},
{ kind: "thinking", ts: "2026-04-06T12:00:04.000Z", text: "Need one more check." },
{
kind: "thinking",
ts: "2026-04-06T12:00:04.000Z",
text: "Need one more check.",
},
{
kind: "tool_call",
ts: "2026-04-06T12:00:05.000Z",
@ -213,16 +301,30 @@ describe("buildAssistantPartsFromTranscript", () => {
expect(result.parts).toMatchObject([
{ type: "text", text: "First." },
{ type: "tool-call", toolCallId: "tool-1", toolName: "read_file", result: "ok" },
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "read_file",
result: "ok",
},
{ type: "text", text: "Second." },
{ type: "reasoning", text: "Need one more check." },
{ type: "tool-call", toolCallId: "tool-2", toolName: "write_file", result: "saved" },
{
type: "tool-call",
toolCallId: "tool-2",
toolName: "write_file",
result: "saved",
},
]);
});
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
{
kind: "thinking",
ts: "2026-04-06T12:00:00.000Z",
text: "Checking the task.",
},
{
kind: "tool_call",
ts: "2026-04-06T12:00:01.000Z",
@ -237,7 +339,11 @@ describe("buildAssistantPartsFromTranscript", () => {
content: "search completed",
isError: false,
},
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
{
kind: "assistant",
ts: "2026-04-06T12:00:03.000Z",
text: "Found the relevant code.",
},
]);
expect(result.parts).toMatchObject([
@ -251,28 +357,36 @@ describe("buildAssistantPartsFromTranscript", () => {
},
{ type: "text", text: "Found the relevant code." },
]);
expect(result.segments).toEqual([{
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
}]);
expect(result.segments).toEqual([
{
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
},
]);
});
it("marks only the latest chain-of-thought segment active while a run is live", () => {
expect(isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 0,
segmentCount: 2,
})).toBe(false);
expect(isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 1,
segmentCount: 2,
})).toBe(true);
expect(isCoTSegmentActive({
isMessageRunning: false,
segmentIndex: 1,
segmentCount: 2,
})).toBe(false);
expect(
isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 0,
segmentCount: 2,
}),
).toBe(false);
expect(
isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 1,
segmentCount: 2,
}),
).toBe(true);
expect(
isCoTSegmentActive({
isMessageRunning: false,
segmentIndex: 1,
segmentCount: 2,
}),
).toBe(false);
});
it("keeps run errors while suppressing init and system transcript noise", () => {
@ -318,10 +432,29 @@ describe("buildAssistantPartsFromTranscript", () => {
it("preserves diff transcript output as a fenced diff block", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Applied the patch." },
{ kind: "diff", ts: "2026-04-06T12:00:01.000Z", changeType: "file_header", text: "ui/src/lib/issue-chat-messages.ts" },
{ kind: "diff", ts: "2026-04-06T12:00:02.000Z", changeType: "add", text: "+function formatDiffBlock(lines: string[]) {" },
{ kind: "diff", ts: "2026-04-06T12:00:03.000Z", changeType: "add", text: "+ return ````diff`;" },
{
kind: "assistant",
ts: "2026-04-06T12:00:00.000Z",
text: "Applied the patch.",
},
{
kind: "diff",
ts: "2026-04-06T12:00:01.000Z",
changeType: "file_header",
text: "ui/src/lib/issue-chat-messages.ts",
},
{
kind: "diff",
ts: "2026-04-06T12:00:02.000Z",
changeType: "add",
text: "+function formatDiffBlock(lines: string[]) {",
},
{
kind: "diff",
ts: "2026-04-06T12:00:03.000Z",
changeType: "add",
text: "+ return ````diff`;",
},
]);
expect(result.parts).toMatchObject([
@ -374,7 +507,10 @@ describe("buildIssueChatMessages", () => {
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" },
resultJson: {
operatorInterrupted: true,
interruptionSource: "issue_comment_interrupt",
},
},
{
runId: "run-plain",
@ -389,10 +525,18 @@ describe("buildIssueChatMessages", () => {
liveRuns: [],
});
const interrupted = messages.find((message) => message.id === "run-assistant:run-int");
const plain = messages.find((message) => message.id === "run-assistant:run-plain");
expect(interrupted?.metadata?.custom).toMatchObject({ runOperatorInterrupted: true });
expect(plain?.metadata?.custom).toMatchObject({ runOperatorInterrupted: false });
const interrupted = messages.find(
(message) => message.id === "run-assistant:run-int",
);
const plain = messages.find(
(message) => message.id === "run-assistant:run-plain",
);
expect(interrupted?.metadata?.custom).toMatchObject({
runOperatorInterrupted: true,
});
expect(plain?.metadata?.custom).toMatchObject({
runOperatorInterrupted: false,
});
});
it("redacts deleted comment bodies while preserving tombstone metadata", () => {
@ -437,7 +581,9 @@ describe("buildIssueChatMessages", () => {
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap: new Map([["agent-1", createAgent("agent-1", "Low Trust Reviewer")]]),
agentMap: new Map([
["agent-1", createAgent("agent-1", "Low Trust Reviewer")],
]),
});
expect(messages[0]?.metadata.custom.sourceTrust).toMatchObject({
@ -448,7 +594,9 @@ describe("buildIssueChatMessages", () => {
});
it("prefers derived agent attribution when a board-authored comment is proven to come from a run", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const agentMap = new Map<string, Agent>([
["agent-1", createAgent("agent-1", "Claude")],
]);
const messages = buildIssueChatMessages({
comments: [
createComment({
@ -481,7 +629,9 @@ describe("buildIssueChatMessages", () => {
});
it("does not reattribute a genuine board/user comment that has no derived agent", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const agentMap = new Map<string, Agent>([
["agent-1", createAgent("agent-1", "Claude")],
]);
const messages = buildIssueChatMessages({
comments: [
createComment({
@ -515,7 +665,9 @@ describe("buildIssueChatMessages", () => {
});
it("renders a comment as agent-authored when runAgentId is set from activity log", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const agentMap = new Map<string, Agent>([
["agent-1", createAgent("agent-1", "Claude")],
]);
const messages = buildIssueChatMessages({
comments: [
createComment({
@ -548,7 +700,9 @@ describe("buildIssueChatMessages", () => {
});
it("orders events before comments and appends active live runs as running assistant messages", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const agentMap = new Map<string, Agent>([
["agent-1", createAgent("agent-1", "CodexCoder")],
]);
const comments = [
createComment(),
createComment({
@ -607,7 +761,13 @@ describe("buildIssueChatMessages", () => {
transcriptsByRunId: new Map([
[
"run-live-1",
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
[
{
kind: "assistant",
ts: "2026-04-06T12:04:01.000Z",
text: "Streaming reply",
},
],
],
]),
hasOutputForRun: (runId) => runId === "run-live-1",
@ -666,8 +826,16 @@ describe("buildIssueChatMessages", () => {
currentUserId: "user-1",
});
expect(terminalMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toBeUndefined();
expect(liveMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toMatchObject({
expect(
terminalMessages.find(
(message) => message.id === "run-assistant:run-live-terminal",
),
).toBeUndefined();
expect(
liveMessages.find(
(message) => message.id === "run-assistant:run-live-terminal",
),
).toMatchObject({
status: { type: "running" },
metadata: { custom: { waitingText: "Working..." } },
});
@ -708,7 +876,13 @@ describe("buildIssueChatMessages", () => {
transcriptsByRunId: new Map([
[
"run-live-1",
[{ kind: "assistant", ts: "2026-04-06T12:03:01.000Z", text: "Working on it." }],
[
{
kind: "assistant",
ts: "2026-04-06T12:03:01.000Z",
text: "Working on it.",
},
],
],
]),
hasOutputForRun: (runId) => runId === "run-live-1",
@ -756,7 +930,10 @@ describe("buildIssueChatMessages", () => {
effectiveResolverPolicy: "anyone",
resolverPolicyProvenance: "inherited",
effectiveResolverPolicySource: "requested",
legacyResolverPolicyAliases: { requested: "board_or_agents", effective: "board_or_agents" },
legacyResolverPolicyAliases: {
requested: "board_or_agents",
effective: "board_or_agents",
},
payload: { version: 1, questions },
result: null,
} as AskUserQuestionsInteraction;
@ -774,7 +951,12 @@ describe("buildIssueChatMessages", () => {
// A truly unanswerable card (no options, no free-text) — must be
// filtered out entirely.
askInteraction("interaction-degenerate", [
{ id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
{
id: "q1",
prompt: "Anything?",
selectionMode: "single",
options: [],
},
]),
// A legitimate yes/no question survives.
askInteraction("interaction-legit", [
@ -797,7 +979,10 @@ describe("buildIssueChatMessages", () => {
const ids = messages.map((message) => `${message.role}:${message.id}`);
// The legit card is present; the degenerate one leaves no message at all.
expect(ids).toEqual(["user:comment-1", "system:interaction:interaction-legit"]);
expect(ids).toEqual([
"user:comment-1",
"system:interaction:interaction-legit",
]);
expect(ids).not.toContain("system:interaction:interaction-degenerate");
});
@ -972,7 +1157,9 @@ describe("buildIssueChatMessages", () => {
});
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const agentMap = new Map<string, Agent>([
["agent-1", createAgent("agent-1", "CodexCoder")],
]);
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
@ -991,8 +1178,16 @@ describe("buildIssueChatMessages", () => {
[
"run-history-1",
[
{ kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." },
{ kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." },
{
kind: "thinking",
ts: "2026-04-06T12:01:10.000Z",
text: "Checking the current issue thread.",
},
{
kind: "assistant",
ts: "2026-04-06T12:02:30.000Z",
text: "Updated the thread renderer.",
},
],
],
]),
@ -1072,16 +1267,22 @@ describe("buildIssueChatMessages", () => {
});
expect(messages).toHaveLength(1);
const textParts = messages[0]?.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text) ?? [];
const textParts =
messages[0]?.content
.filter(
(part): part is { type: "text"; text: string } =>
part.type === "text",
)
.map((part) => part.text) ?? [];
expect(textParts.join("\n")).not.toContain("Older update 1");
expect(messages[0]?.content).toContainEqual(expect.objectContaining({
type: "tool-call",
toolCallId: "tool-keep",
toolName: "search",
result: "search completed",
}));
expect(messages[0]?.content).toContainEqual(
expect.objectContaining({
type: "tool-call",
toolCallId: "tool-keep",
toolName: "search",
result: "search completed",
}),
);
});
it("honors a wider transcript window declared by an adapter's UI module", () => {
@ -1096,7 +1297,10 @@ describe("buildIssueChatMessages", () => {
parseStdoutLine: () => [],
ConfigFields: () => null,
buildAdapterConfig: () => ({}),
transcriptPresentation: { maxVisibleEntries: 400, liveReasoningView: "scrollLog" },
transcriptPresentation: {
maxVisibleEntries: 400,
liveReasoningView: "scrollLog",
},
});
try {
@ -1154,9 +1358,13 @@ describe("buildIssueChatMessages", () => {
});
expect(messages).toHaveLength(1);
const textParts = messages[0]?.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text) ?? [];
const textParts =
messages[0]?.content
.filter(
(part): part is { type: "text"; text: string } =>
part.type === "text",
)
.map((part) => part.text) ?? [];
expect(textParts.join("\n")).toContain("Older update 1");
expect(textParts.join("\n")).toContain("Recent update 79");
} finally {
@ -1184,7 +1392,16 @@ describe("buildIssueChatMessages", () => {
},
],
transcriptsByRunId: new Map([
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
[
"run-1",
[
{
kind: "assistant",
ts: "2026-04-06T12:01:05.000Z",
text: "Working on it.",
},
],
],
]),
hasOutputForRun: (runId) => runId === "run-1",
currentUserId: "user-1",
@ -1206,7 +1423,16 @@ describe("buildIssueChatMessages", () => {
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
[
"run-1",
[
{
kind: "assistant",
ts: "2026-04-06T12:01:05.000Z",
text: "Working on it.",
},
],
],
]),
hasOutputForRun: (runId) => runId === "run-1",
currentUserId: "user-1",
@ -1214,7 +1440,10 @@ describe("buildIssueChatMessages", () => {
expect(liveMessages).toHaveLength(1);
expect(cancelledMessages).toHaveLength(1);
expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } });
expect(liveMessages[0]).toMatchObject({
id: "run-assistant:run-1",
status: { type: "running" },
});
expect(cancelledMessages[0]).toMatchObject({
id: "run-assistant:run-1",
status: { type: "complete", reason: "stop" },
@ -1240,7 +1469,16 @@ describe("buildIssueChatMessages", () => {
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-paused", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
[
"run-paused",
[
{
kind: "assistant",
ts: "2026-04-06T12:01:05.000Z",
text: "Working on it.",
},
],
],
]),
hasOutputForRun: (runId) => runId === "run-paused",
currentUserId: "user-1",
@ -1272,7 +1510,16 @@ describe("buildIssueChatMessages", () => {
],
liveRuns: [],
transcriptsByRunId: new Map([
["run-interrupted", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
[
"run-interrupted",
[
{
kind: "assistant",
ts: "2026-04-06T12:01:05.000Z",
text: "Working on it.",
},
],
],
]),
hasOutputForRun: (runId) => runId === "run-interrupted",
currentUserId: "user-1",
@ -1324,56 +1571,66 @@ describe("buildIssueChatMessages", () => {
describe("stabilizeThreadMessages", () => {
it("reveals live streamed additions at word boundaries instead of character boundaries", () => {
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing the pla",
)).toBe("Writing the ");
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing the plan ",
)).toBe("Writing the plan ");
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing the plan.",
)).toBe("Writing the plan.");
expect(preserveReadableStreamingRetraction(
"Writing ",
"Writing draft",
)).toBe("Writing draft");
expect(
preserveReadableStreamingRetraction("Writing ", "Writing the pla"),
).toBe("Writing the ");
expect(
preserveReadableStreamingRetraction("Writing ", "Writing the plan "),
).toBe("Writing the plan ");
expect(
preserveReadableStreamingRetraction("Writing ", "Writing the plan."),
).toBe("Writing the plan.");
expect(
preserveReadableStreamingRetraction("Writing ", "Writing draft"),
).toBe("Writing draft");
});
it("holds sliding-window removals until an older paragraph or group boundary drops", () => {
expect(preserveReadableStreamingRetraction(
"First sentence. Second sentence is visible",
"irst sentence. Second sentence is visible now ",
)).toBe("irst sentence. Second sentence is visible now ");
expect(preserveReadableStreamingRetraction(
"First sentence. Second sentence is visible",
"Second sentence is visible now ",
)).toBe("Second sentence is visible now ");
expect(preserveReadableStreamingRetraction(
"Paragraph one.\n\nParagraph two is visible",
"Paragraph two is visible now ",
)).toBe("Paragraph two is visible now ");
expect(preserveReadableStreamingRetraction(
"The answer is 42",
"42 is the answer",
)).toBe("42 is the answer");
expect(preserveReadableStreamingRetraction(
"The quick brown fox jumps over the lazy dog",
"quick brown fox jumps over the lazy dog near the river",
)).toBe("quick brown fox jumps over the lazy dog near the river");
expect(
preserveReadableStreamingRetraction(
"First sentence. Second sentence is visible",
"irst sentence. Second sentence is visible now ",
),
).toBe("irst sentence. Second sentence is visible now ");
expect(
preserveReadableStreamingRetraction(
"First sentence. Second sentence is visible",
"Second sentence is visible now ",
),
).toBe("Second sentence is visible now ");
expect(
preserveReadableStreamingRetraction(
"Paragraph one.\n\nParagraph two is visible",
"Paragraph two is visible now ",
),
).toBe("Paragraph two is visible now ");
expect(
preserveReadableStreamingRetraction(
"The answer is 42",
"42 is the answer",
),
).toBe("42 is the answer");
expect(
preserveReadableStreamingRetraction(
"The quick brown fox jumps over the lazy dog",
"quick brown fox jumps over the lazy dog near the river",
),
).toBe("quick brown fox jumps over the lazy dog near the river");
});
it("keeps live streamed retractions readable until a whole line disappears", () => {
expect(preserveReadableStreamingRetraction(
"First line\nSecond line\nThird line is complete",
"First line\nSecond line\nThird line",
)).toBe("First line\nSecond line\nThird line is complete");
expect(preserveReadableStreamingRetraction(
"First line\nSecond line\nThird line is complete",
"First line\nSecond line",
)).toBe("First line\nSecond line");
expect(
preserveReadableStreamingRetraction(
"First line\nSecond line\nThird line is complete",
"First line\nSecond line\nThird line",
),
).toBe("First line\nSecond line\nThird line is complete");
expect(
preserveReadableStreamingRetraction(
"First line\nSecond line\nThird line is complete",
"First line\nSecond line",
),
).toBe("First line\nSecond line");
const liveRun: LiveRunForIssue = {
id: "run-live-retract",
@ -1387,20 +1644,28 @@ describe("stabilizeThreadMessages", () => {
agentName: "CodexCoder",
adapterType: "codex_local",
};
const buildLiveMessages = (text: string) => buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [liveRun],
transcriptsByRunId: new Map([
["run-live-retract", [{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text }]],
]),
hasOutputForRun: (runId) => runId === "run-live-retract",
currentUserId: "user-1",
});
const buildLiveMessages = (text: string) =>
buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [],
liveRuns: [liveRun],
transcriptsByRunId: new Map([
[
"run-live-retract",
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text }],
],
]),
hasOutputForRun: (runId) => runId === "run-live-retract",
currentUserId: "user-1",
});
const fullText = "First line\nSecond line\nThird line is complete";
const firstStable = stabilizeThreadMessages(buildLiveMessages(fullText), [], new Map());
const firstStable = stabilizeThreadMessages(
buildLiveMessages(fullText),
[],
new Map(),
);
const partialRetractionStable = stabilizeThreadMessages(
buildLiveMessages("First line\nSecond line\nThird line"),
firstStable.messages,

View File

@ -274,6 +274,30 @@ function sortByCreated<T extends { createdAt: Date | string; id: string }>(items
});
}
function dedupeInteractionsById(
interactions: readonly IssueThreadInteraction[],
): IssueThreadInteraction[] {
const byId = new Map<string, IssueThreadInteraction>();
for (const interaction of interactions) {
const previous = byId.get(interaction.id);
if (!previous) {
byId.set(interaction.id, interaction);
continue;
}
const previousUpdatedAt = toTimestamp(previous.updatedAt);
const nextUpdatedAt = toTimestamp(interaction.updatedAt);
if (
nextUpdatedAt > previousUpdatedAt
|| (nextUpdatedAt === previousUpdatedAt
&& previous.status === "pending"
&& interaction.status !== "pending")
) {
byId.set(interaction.id, interaction);
}
}
return [...byId.values()];
}
export function latestSameRunHandoffTimestamp(args: {
interactionCreatedAtMs: number;
sourceRunId: string;
@ -1105,7 +1129,11 @@ export function buildIssueChatMessages(args: {
});
}
for (const interaction of sortByCreated(interactions)) {
// A live-event cache patch and the polling response can briefly contain the
// same interaction. Collapse by id before constructing messages, preferring
// the newest/terminal snapshot so a resolved connection card updates in its
// existing thread slot instead of flashing a duplicate beside itself.
for (const interaction of sortByCreated(dedupeInteractionsById(interactions))) {
// A card IssueThreadInteractionCard never renders — a degenerate
// `ask_user_questions` (e.g. the onboarding `Test / A` placeholder) or a
// stale sibling superseded by a newer question (PAP-437) — is skipped here so

View File

@ -5,6 +5,9 @@ export type {
AskUserQuestionsQuestion,
AskUserQuestionsQuestionOption,
AskUserQuestionsResult,
ConnectionIntentInteraction,
ConnectionIntentPayload,
ConnectionIntentResult,
IssueThreadInteraction,
IssueThreadInteractionActorFields,
IssueThreadInteractionBase,
@ -40,6 +43,7 @@ import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
ConnectionIntentInteraction,
IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
@ -73,6 +77,7 @@ export function isIssueThreadInteraction(
|| candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
|| candidate.kind === "request_item_verdicts"
|| candidate.kind === "connection_intent"
);
}
@ -240,13 +245,14 @@ export function buildIssueThreadInteractionSummary(
}
if (interaction.kind === "connection_intent") {
const serviceName = interaction.payload.serviceName;
const outcome = interaction.result?.outcome;
if (outcome === "connected") return `Connected ${serviceName}`;
if (outcome === "declined") return `Declined ${serviceName} connection`;
if (outcome === "superseded") return `${serviceName} connection request was superseded`;
if (outcome === "expired") return `${serviceName} connection request expired`;
return `Requested a ${serviceName} connection`;
if (interaction.status === "accepted") return `${interaction.payload.serviceName} connected`;
if (interaction.status === "rejected") return `${interaction.payload.serviceName} declined`;
if (interaction.status === "expired") {
return interaction.result?.outcome === "superseded"
? `${interaction.payload.serviceName} request superseded`
: `${interaction.payload.serviceName} request expired`;
}
return `Connect ${interaction.payload.serviceName}`;
}
const count = interaction.payload.questions.length;

View File

@ -0,0 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import type { Root } from "react-dom/client";
import {
getOrCreatePaperclipReactRoot,
type PaperclipReactRootHost,
} from "./react-root";
describe("getOrCreatePaperclipReactRoot", () => {
it("reuses the existing root when the entry module runs again", () => {
const host: PaperclipReactRootHost = {};
const container = {} as Parameters<typeof getOrCreatePaperclipReactRoot>[1];
const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root;
const createRoot = vi.fn(() => root);
expect(getOrCreatePaperclipReactRoot(host, container, createRoot)).toBe(root);
expect(getOrCreatePaperclipReactRoot(host, container, createRoot)).toBe(root);
expect(createRoot).toHaveBeenCalledTimes(1);
expect(createRoot).toHaveBeenCalledWith(container);
});
});

24
ui/src/lib/react-root.ts Normal file
View File

@ -0,0 +1,24 @@
import { createRoot, type Root } from "react-dom/client";
export interface PaperclipReactRootHost {
__paperclipReactRoot?: Root;
}
type CreateRoot = (container: Parameters<typeof createRoot>[0]) => Root;
/**
* Keep one React root per browser window even if Vite evaluates the entry
* module more than once during a development reload.
*/
export function getOrCreatePaperclipReactRoot(
host: object,
container: Parameters<typeof createRoot>[0],
create: CreateRoot = createRoot,
): Root {
const rootHost = host as PaperclipReactRootHost;
if (rootHost.__paperclipReactRoot) return rootHost.__paperclipReactRoot;
const root = create(container);
rootHost.__paperclipReactRoot = root;
return root;
}

View File

@ -1,7 +1,6 @@
import * as React from "react";
import { StrictMode } from "react";
import * as ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "@/lib/router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
@ -20,6 +19,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { initPluginBridge } from "./plugins/bridge-init";
import { PluginLauncherProvider } from "./plugins/launchers";
import { startPerfMeasureReaper } from "./lib/perf-measure-reaper";
import { getOrCreatePaperclipReactRoot } from "./lib/react-root";
import { startServiceWorkerUpdates } from "./lib/service-worker-updates";
import "@mdxeditor/editor/style.css";
import "./index.css";
@ -57,7 +57,10 @@ function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNod
return <BreadcrumbProvider companyName={selectedCompany?.name ?? null}>{children}</BreadcrumbProvider>;
}
createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Paperclip root element is missing");
getOrCreatePaperclipReactRoot(window, rootElement).render(
<StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>

View File

@ -137,10 +137,18 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
import { InlineEditor } from "@/components/InlineEditor";
import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { AppLogo } from "@/pages/apps/AppLogo";
import { IssueReferencePill } from "@/components/IssueReferencePill";
import { MembershipAction } from "@/components/MembershipAction";
import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection";
import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import {
connectedConnectionIntentInteraction,
issueThreadInteractionFixtureMeta,
pendingConnectionIntentInteraction,
retryConnectionIntentInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
import {
EnvInputsList,
@ -1346,6 +1354,21 @@ export function DesignGuide() {
</SubSection>
</Section>
<Section title="App logos">
<SubSection title="Official marks and runtime fallback">
<div className="flex items-center gap-3">
<AppLogo
name="Notion"
logoUrl="/brands/apps/notion.svg"
darkLogoUrl="/brands/apps/notion-dark.svg"
size={36}
/>
<AppLogo name="Jira" logoUrl="/brands/apps/jira.svg" darkLogoUrl="/brands/apps/jira-dark.svg" size={44} />
<AppLogo name="Fallback" logoUrl="/brands/apps/does-not-exist.svg" size={36} />
</div>
</SubSection>
</Section>
{/* ============================================================ */}
{/* IDENTITY */}
{/* ============================================================ */}
@ -2056,6 +2079,28 @@ export function DesignGuide() {
<EnvironmentVariablesEditorShowcase />
</Section>
<Section title="Connection Intent">
<p className="text-sm text-muted-foreground">
The task card is the dialog host for the shared connection setup flow. Provider forms,
validation, OAuth, access selection, and completion come from the same feature module as
the full-page Apps setup; this card owns only audience, dialog, and task refresh behavior.
</p>
<div className="grid gap-4 xl:grid-cols-3">
<IssueThreadInteractionCard
interaction={pendingConnectionIntentInteraction}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
/>
<IssueThreadInteractionCard
interaction={retryConnectionIntentInteraction}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
/>
<IssueThreadInteractionCard
interaction={connectedConnectionIntentInteraction}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
/>
</div>
</Section>
<Section title="Resizable Panels">
<p className="text-sm text-muted-foreground">
Design-system wrapper over <span className="font-mono">react-resizable-panels</span>{" "}

View File

@ -20,6 +20,7 @@ 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 finalizeOAuthAccessMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const refreshCatalogMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
@ -55,11 +56,15 @@ vi.mock("@/api/tools", () => ({
updateConnectionMock(connectionId, input),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
finishAppMock(companyId, connectionId, input),
finalizeOAuthAccess: (companyId: string, connectionId: string, input: unknown) =>
finalizeOAuthAccessMock(companyId, connectionId, input),
putConnectionInstalls: (connectionId: string, installs: unknown) =>
putConnectionInstallsMock(connectionId, installs),
archiveConnection: vi.fn(),
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
startOAuth: (connectionId: string, input?: unknown) => input === undefined
? startOAuthMock(connectionId)
: startOAuthMock(connectionId, input),
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
revokeConnectionGrant: (connectionId: string, grantId: string) =>
revokeConnectionGrantMock(connectionId, grantId),
@ -365,6 +370,7 @@ describe("AppDetail", () => {
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
finishAppMock.mockResolvedValue({});
finalizeOAuthAccessMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] });
startOAuthMock.mockResolvedValue({
@ -412,8 +418,8 @@ describe("AppDetail", () => {
// Services (PAP-17865) sits below Test rather than above it, so the
// Setup→Test adjacency this test exists to protect still holds.
"services",
"review",
"permissions",
"review",
"activity",
]);
});
@ -421,10 +427,19 @@ describe("AppDetail", () => {
it("pauses the app by flipping the connection enabled flag", async () => {
await renderAppDetail();
const dangerZone = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Danger zone"));
expect(dangerZone).toBeTruthy();
await act(async () => {
dangerZone!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const toggle = container.querySelector<HTMLButtonElement>(
'button[role="switch"][aria-label="Pause this app"]',
'button[role="switch"][aria-label="Pause connection"]',
);
expect(toggle).toBeTruthy();
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
toggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -486,9 +501,9 @@ describe("AppDetail", () => {
});
it.each([
["setup", "Agents can use this app", false],
["setup", "Danger zone", false],
["review", "Review 1 new action", true],
["permissions", "Action permissions", true],
["permissions", "Agent access", true],
["activity", "No activity yet.", false],
])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => {
mockParams.tab = tab;
@ -512,14 +527,31 @@ describe("AppDetail", () => {
});
});
it("renders setup without waiting for tool discovery", async () => {
it("renders setup while its permission summary loads", async () => {
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
await renderAppDetail();
expect(container.textContent).toContain("Agents can use this app");
expect(container.textContent).toContain("Danger zone");
expect(container.textContent).toContain("Loading permissions…");
expect(container.textContent).not.toContain("Loading tools");
expect(listCatalogMock).not.toHaveBeenCalled();
expect(listCatalogMock).toHaveBeenCalledWith("conn-1");
});
it("shows permission totals on Setup and opens Permissions", async () => {
await renderAppDetail();
const summary = "Allowed for 1 action · Ask first for 1 action · Off for 0";
expect(container.textContent).toContain(summary);
const summaryButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes(summary));
expect(summaryButton).toBeTruthy();
await act(async () => {
summaryButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions");
});
it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => {
@ -557,6 +589,24 @@ describe("AppDetail", () => {
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/test", { replace: true });
});
it("normalizes the retired post-OAuth identity choice back to fixed Setup", async () => {
mockParams.tab = "setup";
mockSearchParams.value = new URLSearchParams("oauth=choose-access");
getConnectionMock.mockResolvedValue(connection({
name: "Notion",
authKind: "oauth",
credentialPolicy: "per_user",
config: { sourceTemplateKey: "notion" },
}));
await renderAppDetail();
expect(container.textContent).toContain("Only you can use this connection");
expect(container.textContent).not.toContain("Who can use this connection?");
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup", { replace: true });
expect(finalizeOAuthAccessMock).not.toHaveBeenCalled();
});
it("hides secret URL parameters in setup technical details", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(
@ -568,6 +618,12 @@ describe("AppDetail", () => {
);
await renderAppDetail();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Connection details"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain(
"https://mcp.zapier.com/api/v1/connect?token=REDACTED&region=us",
@ -643,15 +699,38 @@ describe("AppDetail", () => {
expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block");
});
it("keeps setup focused while including technical details and the danger zone", async () => {
it("keeps setup focused with secondary details folded away", 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).toContain("Technical details");
expect(container.textContent).not.toContain("Give agents a governed way to inspect repositories and pull requests.");
expect(container.textContent).toContain("Connection details");
expect(container.textContent).toContain("Danger zone");
expect(container.textContent).not.toContain("This connection always acts as the identity chosen during setup.");
expect(container.textContent).not.toContain("Remote HTTP");
expect(container.textContent).not.toContain("Pause connection");
expect(container.textContent).not.toContain("Stored securely.");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Connection details"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Remote HTTP");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Danger zone"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Pause connection");
expect(container.textContent).toContain("Reconnect");
expect(container.textContent).toContain("Replace the stored credential.");
expect(container.textContent).not.toContain("Read repo");
expect(container.textContent).not.toContain("Action permissions");
expect(container.querySelector("section.bg-card")).toBeNull();
@ -672,10 +751,10 @@ describe("AppDetail", () => {
await renderAppDetail();
// The old generic "Connect with <provider>" block is gone: identity is now
// expressed per-identity, and a connection with no organization grant offers
// an explicit connect action instead of a single ambiguous button.
expect(container.textContent).toContain("Identities");
// The old generic "Connect with <provider>" block is gone: the connection's
// fixed identity type is explicit even before that identity is connected.
expect(container.textContent).toContain("Account");
expect(container.textContent).toContain("Anyone in your company can use this connection");
expect(container.textContent).toContain("Organization identity");
expect(container.textContent).toContain("Not connected");
expect(
@ -734,15 +813,16 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("Dottas Notion");
expect(container.textContent).toContain("Connected by");
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
// Identity is legible per identity, and the shared one names its audience.
// "workspace authorization" is deliberately gone: it could only ever
// describe one shared identity (PAP-17835).
expect(container.textContent).toContain("Organization identity");
expect(container.textContent).toContain("Notion workspace");
expect(container.textContent).toContain("All organization members");
expect(container.textContent).not.toContain("Connected by");
expect(container.textContent).toContain("Anyone in your company can use this connection");
expect(container.textContent).not.toContain("workspace authorization");
expect(findButton("Reconnect")).toBeUndefined();
await act(async () => {
findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Reconnect",
@ -812,8 +892,8 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("Read only");
expect(container.textContent).toContain("Can make changes");
expect(container.textContent).toContain("Read (1)");
expect(container.textContent).toContain("Write (1)");
expect(container.textContent).toContain("Read repo");
expect(container.textContent).toContain("Write issue");
expect(container.textContent).toContain("Review 1 new action");
@ -869,7 +949,7 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("Available to agents");
expect(container.textContent).toContain("Agent access");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Choose agents"))
@ -889,13 +969,7 @@ describe("AppDetail", () => {
]);
});
/**
* PAP-17859: agent availability is stated once. The tab used to stack a
* legacy "Who can use it" editor its own Change button, per-agent remove
* buttons and Save on top of "Available to agents", so the same fact had
* two visible editors and the reader had to guess which one won.
*/
it("shows one agent-availability model on Permissions, not the legacy access editor", async () => {
it("persists agent access independently from always-installed agents", async () => {
mockParams.tab = "permissions";
listProfilesMock.mockResolvedValue({
profiles: [{
@ -910,12 +984,88 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("Available to agents");
const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]');
const anyAgent = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? [])
.find((radio) => radio.textContent?.includes("Any agent"));
expect(anyAgent).toBeTruthy();
await act(async () => {
anyAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
askFirstCatalogEntryIds: ["catalog-write"],
access: "all_agents",
});
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
});
it("keeps always-installed agents in the access allowlist", async () => {
mockParams.tab = "permissions";
getConnectionInstallsMock.mockResolvedValue({
connectionId: "conn-1",
installs: [{
id: "install-agent-1",
companyId: "company-1",
connectionId: "conn-1",
targetType: "agent",
targetId: "agent-1",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date(),
}],
});
await renderAppDetail();
const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]');
const pickedAgents = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? [])
.find((radio) => radio.textContent?.includes("Agents I pick"));
await act(async () => {
pickedAgents?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
askFirstCatalogEntryIds: ["catalog-write"],
access: { agentIds: ["agent-1"] },
});
});
it("separates agent access from agents that always install the app", async () => {
mockParams.tab = "permissions";
listProfilesMock.mockResolvedValue({
profiles: [{
profileKey: "app:conn-1",
entries: [
{ effect: "include", catalogEntryId: "catalog-read" },
{ effect: "include", catalogEntryId: "catalog-write" },
],
bindings: [{ targetType: "agent", targetId: "agent-1" }],
}],
});
await renderAppDetail();
expect(container.textContent).toContain("Agent access");
expect(container.textContent).toContain("Always installed");
expect(container.textContent).toContain("Agent access only makes it available when needed.");
const alwaysInstalledHeading = Array.from(container.querySelectorAll("h2"))
.find((heading) => heading.textContent === "Always installed");
const agentAccessHeading = Array.from(container.querySelectorAll("h2"))
.find((heading) => heading.textContent === "Agent access");
expect(alwaysInstalledHeading).toBeTruthy();
expect(agentAccessHeading).toBeTruthy();
expect(
alwaysInstalledHeading!.compareDocumentPosition(agentAccessHeading!)
& Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(container.textContent).not.toContain("Who can use it");
expect(container.textContent).not.toContain("Only specific agents");
expect(container.querySelector('button[aria-label="Remove Coder access"]')).toBeNull();
// Exactly one radiogroup on the tab: the install model.
expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(1);
expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(2);
expect(
Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Change",
@ -947,8 +1097,8 @@ describe("AppDetail", () => {
await renderAppDetail();
// State is still legible.
expect(container.textContent).toContain("Available to agents");
expect(container.textContent).toContain("Action permissions");
expect(container.textContent).toContain("Agent access");
expect(container.textContent).toContain("Actions");
expect(container.textContent).toContain("Read repo");
// Nothing to mutate: no radios, no permission selects, no refresh, no save.
@ -1130,7 +1280,7 @@ describe("AppDetail", () => {
expect(container.textContent).toContain("Needs attention");
expect(container.textContent).toContain("This app needs reconnecting");
expect(container.textContent).toContain("Token expired.");
expect(container.textContent).toContain("Available to agents");
expect(container.textContent).toContain("Agent access");
});
it("shows terminal OAuth failures as reconnect-required sign-in", async () => {
@ -1158,6 +1308,83 @@ describe("AppDetail", () => {
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
});
it("reconnects an OAuth warning through the connection's existing personal identity", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
credentialPolicy: "per_user",
createdByUserId: "user-1",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
await renderAppDetail();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("conn-1", { asCurrentUser: true });
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
});
it("does not offer a personal reconnect to someone other than its fixed owner", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
credentialPolicy: "per_user",
createdByUserId: "user-2",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })],
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
currentUserId: "user-1",
members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }],
});
await renderAppDetail();
expect(container.textContent).toContain("Reconnect required");
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
expect(findButton("Reconnect")).toBeUndefined();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("does not offer personal key replacement to someone other than its fixed owner", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(connection({
authKind: "api_key",
credentialPolicy: "per_user",
createdByUserId: "user-2",
healthStatus: "failed",
healthMessage: "The key was rejected.",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })],
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
currentUserId: "user-1",
members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }],
});
await renderAppDetail();
await act(async () => {
findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Reconnect");
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
expect(Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Reconnect",
)).toHaveLength(0);
});
/**
* PAP-17099 the server refuses to hand out an unsafe authorization endpoint,
* but this is the boundary where one would actually execute, so the board must
@ -1214,14 +1441,15 @@ describe("AppDetail", () => {
.find((button) => button.textContent?.trim() === label);
}
it("answers 'who does this act as' in the header on every tab", async () => {
it("keeps the app header concise on every tab", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
await renderAppDetail();
expect(container.textContent).toContain("Acts as each person");
expect(container.textContent).toContain("Each person connects their own account.");
expect(container.textContent).toContain("Connected");
expect(container.textContent).not.toContain("This connection is for one person.");
expect(container.textContent).not.toContain("Connected by");
});
it("lets a regular member connect their own identity and never someone else's", async () => {
@ -1232,8 +1460,12 @@ describe("AppDetail", () => {
await renderAppDetail();
// Missing personal identity is explicit, never a silent fallback.
expect(container.textContent).toContain("Your identity");
expect(container.textContent).toContain("You have not connected your account.");
expect(container.textContent).toContain("Account");
expect(container.textContent).toContain("Only you can use this connection");
expect(container.textContent).toContain("Personal account");
expect(container.textContent).toContain("Not connected");
expect(container.textContent).not.toContain("Organization identity");
expect(findButton("Connect organization identity")).toBeUndefined();
await act(async () => {
findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -1249,12 +1481,12 @@ describe("AppDetail", () => {
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
});
it("lets the personal identity owner grant a named agent autonomous access", async () => {
it("opens Permissions from app access instead of personal identity delegations", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(perUserConnection());
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [personalGrant({ delegations: [] })],
grants: [personalGrant({ delegations: [{ id: "delegation-1", agentId: "agent-1" }] })],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [{ userId: "user-1", name: "Dotta", email: "dotta@example.com" }],
@ -1262,34 +1494,18 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(findButton("Allow autonomous access")).toBeTruthy();
expect(findButton("Every agent")).toBeTruthy();
expect(findButton("No agents")).toBeUndefined();
await act(async () => {
findButton("Allow autonomous access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
findButton("Every agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const agentCheckbox = document.querySelector<HTMLInputElement>('button[role="checkbox"][aria-label="Allow Coder"]');
expect(agentCheckbox).toBeTruthy();
await act(async () => {
agentCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(createConnectionGrantDelegationMock).toHaveBeenCalledWith(
"conn-1",
"grant-user",
"agent-1",
);
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions");
});
it("keeps a viewer read-only across identities and installs", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(perUserConnection());
getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "shared" }));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [organizationGrant({ capabilities: { canRevoke: false, canEditAudience: false } })],
@ -1308,20 +1524,21 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("Anyone in your company can use this connection");
// State stays legible...
expect(container.textContent).toContain("Organization identity");
expect(container.textContent).toContain("Connected");
expect(container.textContent).toContain("All organization members");
expect(container.textContent).toContain("Anyone in your company can use this connection");
expect(container.textContent).not.toContain("Personal account");
// ...and every mutation control is absent rather than disabled.
expect(findButton("Connect as me")).toBeUndefined();
expect(findButton("Manage audience")).toBeUndefined();
expect(findButton("Manage access")).toBeUndefined();
expect(findButton("Revoke")).toBeUndefined();
expect(findButton("Connect organization identity")).toBeUndefined();
});
it("gives a manager oversight of other people's identities", async () => {
it("shows one fixed personal identity without an organization switch", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(perUserConnection());
getConnectionMock.mockResolvedValue(perUserConnection({ createdByUserId: "user-2" }));
revokeConnectionGrantMock.mockResolvedValue({ id: "grant-other", kind: "user" });
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
@ -1339,28 +1556,22 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("Other personal identities · 1");
expect(container.textContent).toContain("Only you can use this connection");
expect(container.textContent).toContain("Carol");
expect(container.textContent).not.toContain("Organization identity");
expect(container.textContent).not.toContain("Other personal identities");
expect(findButton("Connect organization identity")).toBeUndefined();
expect(findButton("Agents")).toBeUndefined();
// A manager can still revoke the displayed identity from the folded danger
// zone, but cannot reconnect as Carol or switch the identity type.
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Other personal identities"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
findButton("Danger zone")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Carol");
// Scope to Carol's own row: the organization identity also offers Revoke,
// and picking the first match would silently test the wrong grant.
const carolRow = Array.from(container.querySelectorAll("div"))
.filter((row) => row.textContent?.includes("Carol")
&& Array.from(row.querySelectorAll("button")).some((b) => b.textContent?.trim() === "Revoke"))
.at(-1);
expect(carolRow).toBeTruthy();
await act(async () => {
Array.from(carolRow!.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Revoke")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
findButton("Revoke")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -1399,10 +1610,10 @@ describe("AppDetail", () => {
await renderAppDetail();
expect(container.textContent).toContain("1 selected member");
expect(container.textContent).toContain("Anyone in your company can use this connection");
await act(async () => {
findButton("Manage audience")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -1444,7 +1655,7 @@ describe("AppDetail", () => {
await renderAppDetail();
await act(async () => {
findButton("Manage audience")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();

View File

@ -34,19 +34,23 @@ import { UnverifiedServerBadge } from "./UnverifiedServerBadge";
import {
appApplicationSourceSlug,
appConnectionSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
import { SetupPanel, connectionProviderName } from "./app-detail/SetupPanel";
import { SetupPanel } from "./app-detail/SetupPanel";
import { ServicesPanel } from "./app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./ComposioProvenanceChip";
import { ConnectionProvenanceChip } from "./ComposioProvenanceChip";
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
import { actsAsSummary } from "./connection-identity";
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
import { TestPanel } from "./app-detail/TestPanel";
import {
formatActionPermissionSummary,
summarizeActionPermissions,
} from "./app-detail/action-permission-summary";
import { ReviewPanel } from "./app-detail/ReviewPanel";
import { ActivityPanel } from "./app-detail/ActivityPanel";
import {
@ -58,10 +62,8 @@ import {
} from "./app-detail/AdvancedPanel";
import type { AccessDraft } from "./app-detail/types";
import {
ConnectionOwnerIdentity,
connectionDisplayNameForOwner,
connectionOwnerProfile,
type ConnectionOwnerProfile,
} from "./connection-owner";
export { DangerZone, connectionAddress, connectionTransportLabel };
@ -76,7 +78,7 @@ export function AppDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;
const needsCatalog = activeTab === "review" || activeTab === "permissions" || activeTab === "test";
const needsCatalog = activeTab === "setup" || activeTab === "review" || activeTab === "permissions" || activeTab === "test";
const connectionQuery = useQuery({
queryKey: queryKeys.tools.connection(connectionId),
@ -111,12 +113,16 @@ export function AppDetail() {
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
enabled: !!selectedCompanyId && (
activeTab === "setup" || activeTab === "review" || activeTab === "permissions"
),
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listPolicies(selectedCompanyId!),
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
enabled: !!selectedCompanyId && (
activeTab === "setup" || activeTab === "review" || activeTab === "permissions"
),
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
@ -141,19 +147,57 @@ export function AppDetail() {
queryFn: () => authApi.getSession(),
enabled: activeTab === "activity",
});
// Identity grants drive the Setup tab's Identities section and the header's
// "acts as" sentence, and Permissions reads `capabilities` from the same
// response so install controls follow one server verdict (PAP-17835).
// Identity grants drive reconnect authorization on every tab as well as the
// Setup identities and Permissions controls. A personal reconnect belongs to
// one fixed user, so the banner must not offer that action to anyone else.
const grantsQuery = useQuery({
queryKey: queryKeys.tools.connectionGrants(connectionId),
queryFn: () => toolsApi.listConnectionGrants(connectionId),
enabled: !!connectionId && (activeTab === "setup" || activeTab === "permissions"),
enabled: !!connectionId && !!activeTab,
});
const connection = connectionQuery.data;
const application = connection
? (applicationsQuery.data?.applications ?? []).find((candidate) => candidate.id === connection.applicationId)
: undefined;
const grantRows = grantsQuery.data?.grants ?? [];
const retainedPersonalGrant = connection?.credentialPolicy === "per_user"
? grantRows.find((grant) => (
grant.kind === "user" && grant.subjectUserId === connection.createdByUserId
))
?? grantRows.find((grant) => grant.kind === "user" && grant.status === "active")
?? grantRows.find((grant) => grant.kind === "user")
?? null
: null;
const currentUserPersonalGrant = grantRows.find((grant) => (
grant.kind === "user" && grant.subjectUserId === grantsQuery.data?.currentUserId
)) ?? null;
const retainedOrganizationGrant = grantRows.find((grant) => (
grant.kind === "organization" && grant.isDefault
)) ?? grantRows.find((grant) => grant.kind === "organization") ?? null;
const managedIdentityGrant = connection?.credentialPolicy === "per_user"
? retainedPersonalGrant
: connection?.credentialPolicy === "per_user_with_fallback"
? currentUserPersonalGrant ?? retainedOrganizationGrant
: retainedOrganizationGrant;
const managedPersonalUserId = managedIdentityGrant?.kind === "user"
? managedIdentityGrant.subjectUserId ?? connection?.createdByUserId ?? null
: null;
const canReconnect = managedIdentityGrant?.kind === "user"
? Boolean(
managedPersonalUserId
&& managedPersonalUserId === grantsQuery.data?.currentUserId
&& grantsQuery.data?.capabilities.canConnectAsCurrentUser,
)
: grantsQuery.data?.capabilities.canConfigure === true;
const reconnectUnavailableMessage = grantsQuery.isLoading
? "Checking who can reconnect this identity…"
: grantsQuery.isError
? "We couldn't verify who can reconnect this identity. Reload the page to try again."
: managedIdentityGrant?.kind === "user"
&& managedPersonalUserId !== grantsQuery.data?.currentUserId
? "The person this connection belongs to must reconnect it."
: "You don't have permission to reconnect this identity.";
const composioChildConnectionCount = (connectionsQuery.data?.connections ?? []).filter(
(candidate) => candidate.status !== "archived"
&& candidate.config?.provider === "composio"
@ -179,6 +223,14 @@ export function AppDetail() {
: "App";
const successNoticeShownFor = useRef<string | null>(null);
useEffect(() => {
if (activeTab !== "setup" || searchParams.get("oauth") !== "choose-access") return;
// Older OAuth states may still return to the retired post-authorization
// identity screen. Identity is now selected before consent, so normalize
// the stale URL without asking a contradictory second question.
navigate(appTabHref(connectionId, "setup"), { replace: true });
}, [activeTab, connectionId, navigate, searchParams]);
useEffect(() => {
if (
activeTab !== "test"
@ -392,49 +444,6 @@ export function AppDetail() {
}),
});
const replaceDelegations = useMutation({
mutationFn: async ({
grantId,
currentDelegations,
agentIds,
}: {
grantId: string;
currentDelegations: Array<{ id: string; agentId: string }>;
agentIds: string[];
}) => {
const desired = new Set(agentIds);
const existing = new Map(currentDelegations.map((delegation) => [delegation.agentId, delegation]));
await Promise.all([
...currentDelegations
.filter((delegation) => !desired.has(delegation.agentId))
.map((delegation) => toolsApi.revokeConnectionGrantDelegation(
connectionId,
grantId,
delegation.id,
)),
...agentIds
.filter((agentId) => !existing.has(agentId))
.map((agentId) => toolsApi.createConnectionGrantDelegation(connectionId, grantId, agentId)),
]);
},
onSuccess: () => {
invalidateGrants();
pushToast({
title: "Autonomous access saved",
body: "Only the agents you selected can use your identity in autonomous runs.",
tone: "success",
});
},
onError: (error) => {
invalidateGrants();
pushToast({
title: "Couldn't save autonomous access",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
});
},
});
// A denied or conflicting audience save keeps the dialog open with the
// selection intact, so the error is surfaced inline rather than as a toast.
const [audienceError, setAudienceError] = useState<string | null>(null);
@ -575,20 +584,32 @@ export function AppDetail() {
}
const status = statusFor(connection);
const providerName = connectionProviderName(logoEntry, baseAppName);
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 = catalogQuery.data ? active.length : null;
const setupPermissionsLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
const setupPermissionsSummary = setupPermissionsLoading || catalogQuery.isError
|| profilesQuery.isError || policiesQuery.isError
? null
: formatActionPermissionSummary(summarizeActionPermissions(active, enabledIds, askFirstIds));
// Setup summarizes app access, not personal-identity delegations. Identity
// delegation answers who an agent may act as; the profile binding below is
// the source of truth for which agents may use the connection at all.
const setupAgentsSummary = access.mode === "all"
? "Every agent"
: access.agentIds.size === 0
? "No agents"
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"}`;
const reviewLoading = catalogQuery.isLoading || profilesQuery.isLoading || policiesQuery.isLoading;
const permissionsLoading = reviewLoading || installsQuery.isLoading || agentsQuery.isLoading;
const reviewFailed = catalogQuery.isError || profilesQuery.isError || policiesQuery.isError;
const permissionsFailed = reviewFailed || installsQuery.isError || agentsQuery.isError;
return (
<div className="max-w-3xl space-y-6 pb-12">
<div className="max-w-4xl space-y-10 pb-12">
<AppDetailHeader
appName={appName}
connection={connection}
@ -596,8 +617,7 @@ export function AppDetail() {
brandKey={brandKey}
allowRemoteLogo={!applicationsQuery.isPending}
status={status}
actionCount={actionCount}
owner={owner}
actionCount={activeTab === "setup" ? null : actionCount}
renaming={renaming}
nameDraft={nameDraft}
renamePending={rename.isPending}
@ -617,6 +637,8 @@ export function AppDetail() {
<ReconnectCard
connection={connection}
galleryEntry={logoEntry}
canReconnect={canReconnect}
reconnectUnavailableMessage={reconnectUnavailableMessage}
onReconnected={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
@ -626,69 +648,76 @@ export function AppDetail() {
)}
{activeTab === "setup" && (
<div className="space-y-8">
<SetupPanel
connection={connection}
galleryEntry={logoEntry}
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
onToggleApp={() => toggleEnabled.mutate()}
configUpdateDisabled={updateConfig.isPending}
onUpdateConfig={(config) => updateConfig.mutate(config)}
identities={
<IdentitiesSection
appName={appName}
providerName={providerName}
credentialPolicy={connection.credentialPolicy}
grantsQuery={grantsQuery.data}
agents={agents}
agentsLoading={agentsQuery.isLoading}
agentsError={agentsQuery.isError}
loading={grantsQuery.isLoading}
error={grantsQuery.isError}
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
revokePending={revokeGrant.isPending}
delegationPending={replaceDelegations.isPending}
audiencePending={replaceAudience.isPending}
audienceError={audienceError}
audienceGrantId={audienceOpenGrantId}
onOpenAudience={(grantId) => {
setAudienceError(null);
setAudienceOpenGrantId(grantId);
}}
onCloseAudience={() => {
setAudienceOpenGrantId(null);
setAudienceError(null);
}}
onConnectAsMe={() => startPersonalAuth.mutate()}
// The organization identity is a shared credential, so it goes
// through the connection-level OAuth start, not a personal one.
onConnectOrganization={() => startOAuth.mutate()}
onReconnectOrganization={() => startOAuth.mutate()}
onRevokeGrant={(grant) => revokeGrant.mutate(grant.id)}
onReplaceDelegations={(grant, agentIds) => replaceDelegations.mutate({
grantId: grant.id,
currentDelegations: grant.delegations ?? [],
agentIds,
})}
onReplaceAudience={(grant, memberUserIds) =>
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
/>
}
/>
<AdvancedPanel
connection={connection}
appName={appName}
galleryEntry={logoEntry}
childConnectionCount={composioChildConnectionCount}
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>
<div className="space-y-12">
<SetupPanel
connection={connection}
galleryEntry={logoEntry}
configUpdateDisabled={updateConfig.isPending}
onUpdateConfig={(config) => updateConfig.mutate(config)}
agentsSummary={setupAgentsSummary}
permissionsSummary={setupPermissionsSummary}
permissionsLoading={setupPermissionsLoading}
onOpenPermissions={() => navigate(appTabHref(connectionId, "permissions"))}
identities={
<IdentitiesSection
appName={appName}
credentialPolicy={connection.credentialPolicy}
ownerUserId={connection.createdByUserId}
connectedUser={owner}
grantsQuery={grantsQuery.data}
loading={grantsQuery.isLoading}
error={grantsQuery.isError}
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
audiencePending={replaceAudience.isPending}
audienceError={audienceError}
audienceGrantId={audienceOpenGrantId}
onOpenAudience={(grantId) => {
setAudienceError(null);
setAudienceOpenGrantId(grantId);
}}
onCloseAudience={() => {
setAudienceOpenGrantId(null);
setAudienceError(null);
}}
onConnectAsMe={() => startPersonalAuth.mutate()}
// The organization identity is a shared credential, so it goes
// through the connection-level OAuth start, not a personal one.
onConnectOrganization={() => startOAuth.mutate()}
onReplaceAudience={(grant, memberUserIds) =>
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
/>
}
/>
<AdvancedPanel
connection={connection}
appName={appName}
galleryEntry={logoEntry}
childConnectionCount={composioChildConnectionCount}
removing={removeApp.isPending}
onRemove={() => removeApp.mutate()}
canReplaceCredential={canReconnect}
credentialUnavailableMessage={reconnectUnavailableMessage}
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
onToggleApp={() => toggleEnabled.mutate()}
identityGrant={managedIdentityGrant}
identityCurrentUserId={grantsQuery.data?.currentUserId ?? null}
identityProviderName={baseAppName}
credentialPolicy={connection.credentialPolicy}
identityActionPending={
startPersonalAuth.isPending || startOAuth.isPending || revokeGrant.isPending
}
onReconnectIdentity={managedIdentityGrant ? () => {
if (managedIdentityGrant.kind === "user") startPersonalAuth.mutate();
else startOAuth.mutate();
} : undefined}
onRevokeIdentity={(grant) => revokeGrant.mutate(grant.id)}
onReplaced={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
}}
/>
</div>
)}
{activeTab === "services" && (
<ServicesPanel connectionId={connectionId} appName={appName} />
@ -722,7 +751,9 @@ export function AppDetail() {
? <ToolsLoading />
: <PermissionsPanel
capabilities={grantsQuery.data?.capabilities}
appName={appName}
agents={agents}
access={access}
install={install}
readOnly={readOnly}
canChange={canChange}
@ -732,6 +763,7 @@ export function AppDetail() {
pending={pending}
installPending={persistInstall.isPending}
refreshPending={refreshTools.isPending}
onSaveAccess={(next) => apply({ access: accessIncludingInstalls(next, install) })}
onSaveInstall={(next) => persistInstall.mutate(next)}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
@ -770,7 +802,6 @@ function AppDetailHeader({
allowRemoteLogo,
status,
actionCount,
owner,
renaming,
nameDraft,
renamePending,
@ -786,7 +817,6 @@ function AppDetailHeader({
allowRemoteLogo: boolean;
status: StatusInfo;
actionCount: number | null;
owner: ConnectionOwnerProfile | null;
renaming: boolean;
nameDraft: string;
renamePending: boolean;
@ -796,19 +826,18 @@ function AppDetailHeader({
onRenameSubmit: (value: string) => void;
}) {
const unverifiedHost = unverifiedRemoteHost(connection);
const actsAs = actsAsSummary(connection.credentialPolicy);
return (
<header className="flex flex-wrap items-start justify-between gap-4">
<header>
<div className="flex items-center gap-3">
<AppLogo
name={appName}
brandKey={brandKey}
logoUrl={appDefinitionLogoUrl(logoEntry)}
darkLogoUrl={appDefinitionDarkLogoUrl(logoEntry)}
allowRemoteFallback={allowRemoteLogo}
size={44}
/>
<div>
<div className="min-w-0">
{renaming ? (
<form
className="flex items-center gap-2"
@ -833,7 +862,7 @@ function AppDetailHeader({
</form>
) : (
<div className="flex items-center gap-1.5">
<h1 className="text-2xl font-bold tracking-tight">{appName}</h1>
<h1 className="truncate text-xl font-bold">{appName}</h1>
<Button
variant="ghost"
size="icon"
@ -845,32 +874,20 @@ function AppDetailHeader({
</Button>
</div>
)}
{connectionDisplaySecondaryHint(connection) && (
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
)}
{/* One sentence, not a cluster of badges: whether an agent acts as you
or as the organization is the first thing every tab has to answer
(PAP-17835). It lives in the header so it carries across tabs. */}
<p className="mt-0.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{actsAs.title}</span>
{" · "}
{actsAs.detail}
</p>
{owner && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Connected by</span>
<ConnectionOwnerIdentity owner={owner} />
</div>
)}
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
<ComposioProvenanceChip connection={connection} className="mt-1" />
<div className="mt-1 flex items-center gap-2">
<div className="mt-2 flex flex-wrap items-center gap-2">
<StatusBadge status={status} />
{actionCount !== null && (
<span className="text-xs text-muted-foreground">
{actionCount} {actionCount === 1 ? "action" : "actions"} available
</span>
)}
{connectionDisplaySecondaryHint(connection) ? (
<span className="text-xs text-muted-foreground">
{connectionDisplaySecondaryHint(connection)}
</span>
) : null}
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} /> : null}
<ConnectionProvenanceChip connection={connection} />
</div>
</div>
</div>
@ -973,11 +990,10 @@ function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<s
* Who may use this connection, read back from the app profile's bindings.
*
* `finishApp` replaces a profile's whole binding set, so every save from this
* page including an action-permission toggle has to restate this. Since
* PAP-17859 there is no user-facing editor for it: the Permissions tab shows
* agent availability once, as installs. This function therefore only ever
* *preserves* what the server already has, and falls back to the install state
* when the profile has no bindings yet.
* page including an action-permission toggle has to restate this. The
* Permissions tab exposes the bindings as Agent access, while installs remain
* a separate "always loaded" choice. If the profile has no bindings yet, the
* install state remains the safest legacy fallback.
*
* That fallback is the important part. It used to return "all agents" for an
* unbound profile, which turned any unrelated save into a silent company-wide
@ -1000,6 +1016,16 @@ function accessFrom(
: { mode: "specific", agentIds: new Set(install.agentIds) };
}
function accessIncludingInstalls(next: AccessDraft, install: InstallState): AccessDraft {
if (install.onAll || next.mode === "all") {
return { mode: "all", agentIds: new Set() };
}
return {
mode: "specific",
agentIds: new Set([...next.agentIds, ...install.agentIds]),
};
}
function galleryEntryFor(
apps: AppGalleryDisplayEntry[],
connection: ToolConnection | undefined,

View File

@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetAppBrandManifestCacheForTests } from "@/lib/app-brand-assets";
import { AppLogo } from "./AppLogo";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("AppLogo", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
resetAppBrandManifestCacheForTests();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ schemaVersion: 1, providers: [] }),
}));
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("renders decorative local light and dark provider marks", async () => {
await act(async () => {
root.render(
<AppLogo
name="Notion"
logoUrl="/brands/apps/notion.svg"
darkLogoUrl="/brands/apps/notion-dark.svg"
size={36}
/>,
);
await Promise.resolve();
await Promise.resolve();
});
const images = Array.from(container.querySelectorAll("img"));
expect(images).toHaveLength(2);
expect(images.map((image) => image.getAttribute("src"))).toEqual([
"/brands/apps/notion.svg",
"/brands/apps/notion-dark.svg",
]);
expect(images.every((image) => image.alt === "")).toBe(true);
expect(images[0]?.className).toContain("dark:hidden");
expect(images[1]?.className).toContain("dark:block");
});
it("uses the deterministic letter tile only after a runtime image failure", async () => {
await act(async () => {
root.render(<AppLogo name="Jira" logoUrl="/brands/apps/missing.svg" />);
await Promise.resolve();
await Promise.resolve();
});
const image = container.querySelector("img");
expect(image).toBeTruthy();
act(() => image?.dispatchEvent(new Event("error")));
expect(container.querySelector("img")).toBeNull();
expect(container.textContent).toBe("J");
});
});

View File

@ -24,19 +24,21 @@ interface AppLogoProps {
brandKey?: string | null;
logoUrl?: string | null;
allowRemoteFallback?: boolean;
darkLogoUrl?: 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.
* official local provider mark when available, including a dark-mode variant.
* The deterministic letter tile is reserved for runtime image failures.
*/
export function AppLogo({
name,
brandKey,
logoUrl,
darkLogoUrl,
allowRemoteFallback = true,
size = 36,
className,
@ -57,17 +59,19 @@ export function AppLogo({
const resolvedLogoUrl = localLookupComplete
? localAssets?.light ?? (allowRemoteFallback ? logoUrl : null)
: null;
const lightLogoUrl = resolvedLogoUrl && !failedLogoUrls.has(resolvedLogoUrl)
const resolvedDarkLogoUrl = localLookupComplete
? localAssets?.dark ?? (allowRemoteFallback ? darkLogoUrl : null)
: null;
const lightLogoUrlForRender = resolvedLogoUrl && !failedLogoUrls.has(resolvedLogoUrl)
? resolvedLogoUrl
: null;
const requestedDarkLogoUrl = localAssets?.dark ?? null;
const darkLogoUrl = requestedDarkLogoUrl && !failedLogoUrls.has(requestedDarkLogoUrl)
? requestedDarkLogoUrl
const darkLogoUrlForRender = resolvedDarkLogoUrl && !failedLogoUrls.has(resolvedDarkLogoUrl)
? resolvedDarkLogoUrl
: null;
const hasDistinctThemeLogos = Boolean(
resolvedLogoUrl && requestedDarkLogoUrl && resolvedLogoUrl !== requestedDarkLogoUrl,
resolvedLogoUrl && resolvedDarkLogoUrl && resolvedLogoUrl !== resolvedDarkLogoUrl,
);
const fallbackLogoUrl = lightLogoUrl ?? darkLogoUrl;
const fallbackLogoUrl = lightLogoUrlForRender ?? darkLogoUrlForRender;
useEffect(() => {
let active = true;
@ -85,7 +89,7 @@ export function AppLogo({
useEffect(() => {
setFailedLogoUrls(new Set());
}, [resolvedLogoUrl, localAssets?.dark]);
}, [resolvedDarkLogoUrl, resolvedLogoUrl]);
const markLogoFailed = (url: string) => {
setFailedLogoUrls((current) => new Set(current).add(url));
@ -99,19 +103,19 @@ export function AppLogo({
>
{hasDistinctThemeLogos ? (
<>
{lightLogoUrl ? (
{lightLogoUrlForRender ? (
<img
src={lightLogoUrl}
src={lightLogoUrlForRender}
alt=""
width={size}
height={size}
className="h-full w-full object-contain dark:hidden"
onError={() => markLogoFailed(lightLogoUrl)}
className="h-full w-full object-contain p-1.5 dark:hidden"
onError={() => markLogoFailed(lightLogoUrlForRender)}
/>
) : (
<span
className={cn(
"flex h-full w-full items-center justify-center font-bold text-white dark:hidden",
"flex h-full w-full items-center justify-center text-sm font-bold text-white dark:hidden",
colorFor(name),
)}
aria-hidden="true"
@ -119,19 +123,19 @@ export function AppLogo({
{letter}
</span>
)}
{darkLogoUrl ? (
{darkLogoUrlForRender ? (
<img
src={darkLogoUrl}
src={darkLogoUrlForRender}
alt=""
width={size}
height={size}
className="hidden h-full w-full object-contain dark:block"
onError={() => markLogoFailed(darkLogoUrl)}
className="hidden h-full w-full object-contain p-1.5 dark:block"
onError={() => markLogoFailed(darkLogoUrlForRender)}
/>
) : (
<span
className={cn(
"hidden h-full w-full items-center justify-center font-bold text-white dark:flex",
"hidden h-full w-full items-center justify-center text-sm font-bold text-white dark:flex",
colorFor(name),
)}
aria-hidden="true"
@ -146,7 +150,7 @@ export function AppLogo({
alt=""
width={size}
height={size}
className="h-full w-full object-contain"
className="h-full w-full object-contain p-1.5"
onError={() => markLogoFailed(fallbackLogoUrl)}
/>
)}
@ -157,11 +161,11 @@ export function AppLogo({
return (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-lg font-bold text-white",
"inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white",
colorFor(name),
className,
)}
style={{ ...dimension, fontSize: Math.round(size * 0.42) }}
style={dimension}
aria-hidden="true"
>
{letter}

View File

@ -9,6 +9,7 @@ import { AppNotConnected } from "./AppNotConnected";
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listGalleryMock = vi.hoisted(() => vi.fn());
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
const listActionRequestsMock = vi.hoisted(() => vi.fn());
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
@ -23,6 +24,7 @@ vi.mock("@/api/tools", () => ({
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listGallery: (companyId: string) => listGalleryMock(companyId),
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
listConnectionActivity: (connectionId: string, limit: number) =>
listConnectionActivityMock(connectionId, limit),
listActionRequests: (companyId: string, status: string) =>
@ -122,6 +124,8 @@ function connection(overrides: Record<string, unknown> = {}) {
name: "GitHub",
connectionKind: "managed",
transport: "mcp_remote",
authKind: "api_key",
credentialPolicy: "shared",
status: "archived",
transportConfig: { url: "https://github.example/mcp" },
config: { url: "https://github.example/mcp" },
@ -156,6 +160,21 @@ describe("AppNotConnected", () => {
apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }],
});
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-old", uid: "conn-old" },
grants: [],
capabilities: {
canConfigure: true,
canCreateOrganizationGrant: true,
canSetCompanyInstall: true,
canConnectAsCurrentUser: true,
canManageAgentInstalls: true,
canViewOtherPersonalIdentities: false,
editableAgentIds: [],
},
currentUserId: "user-1",
members: [],
});
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
listUserDirectoryMock.mockResolvedValue({ users: [] });
mockAgentsList.mockResolvedValue([]);
@ -341,4 +360,97 @@ describe("AppNotConnected", () => {
expect(container.textContent).toContain("https://github.example/mcp");
expect(container.textContent).toContain("Danger zone");
});
it("carries the retained identity into the reconnect flow", async () => {
listConnectionsMock.mockResolvedValue({
connections: [connection({ credentialPolicy: "per_user", createdByUserId: "user-1" })],
});
await renderPage();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith(
"/apps/connect?applicationId=app-1&name=GitHub&new=1&reconnect=conn-old&identity=user&source=github&link=https%3A%2F%2Fgithub.example%2Fmcp",
);
});
it("keeps a removed Vercel-backed connection in the isolated Vercel setup", async () => {
listConnectionsMock.mockResolvedValue({
connections: [connection({ credentialSource: "vercel_connect" })],
});
await renderPage();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith(
"/apps/vercel-connect?applicationId=app-1&name=GitHub&new=1&reconnect=conn-old&identity=organization&source=github&link=https%3A%2F%2Fgithub.example%2Fmcp",
);
});
it("prefills a custom MCP reconnect from the stored transport URL", async () => {
listApplicationsMock.mockResolvedValue({
applications: [application({ applicationKey: "custom-mcp", name: "Bla", metadata: null })],
});
listConnectionsMock.mockResolvedValue({
connections: [connection({
name: "Bla",
config: { url: "http://127.0.0.1:49287" },
transportConfig: { url: "http://127.0.0.1:49287" },
})],
});
listGalleryMock.mockResolvedValue({ apps: [] });
await renderPage();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith(
"/apps/connect?applicationId=app-1&name=Bla&new=1&reconnect=conn-old&identity=organization&byo=1&link=http%3A%2F%2F127.0.0.1%3A49287",
);
});
it("does not offer a removed personal reconnect to someone other than its fixed owner", async () => {
listConnectionsMock.mockResolvedValue({
connections: [connection({ credentialPolicy: "per_user", createdByUserId: "user-2" })],
});
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-old", uid: "conn-old" },
grants: [{
id: "grant-user-2",
kind: "user",
subjectUserId: "user-2",
status: "revoked",
credentialSecretRefs: [],
}],
capabilities: {
canConfigure: true,
canCreateOrganizationGrant: true,
canSetCompanyInstall: true,
canConnectAsCurrentUser: true,
canManageAgentInstalls: true,
canViewOtherPersonalIdentities: true,
editableAgentIds: [],
},
currentUserId: "user-1",
members: [],
});
await renderPage();
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
expect(Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Reconnect",
)).toBe(false);
});
});

View File

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ToolConnection } from "@paperclipai/shared";
import {
connectionDisplaySecondaryHint,
isConnectableAppSlug,
isToolConnectionAttentionHealth,
} from "@paperclipai/shared";
import { Navigate, useNavigate, useParams } from "@/lib/router";
@ -20,12 +21,12 @@ import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/compa
import { AppLogo } from "./AppLogo";
import {
appApplicationSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { isMcpDirectOAuthConnectSlug } from "./app-connect-policy";
import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail";
import { ActivityPanel } from "./app-detail/ActivityPanel";
import { ReviewPanel } from "./app-detail/ReviewPanel";
@ -99,6 +100,11 @@ export function AppNotConnected() {
queryFn: () => toolsApi.listConnectionActivity(previousConnection!.id, 20),
enabled: !!previousConnection && activeTab === "activity",
});
const grantsQuery = useQuery({
queryKey: queryKeys.tools.connectionGrants(previousConnection?.id ?? "__none__"),
queryFn: () => toolsApi.listConnectionGrants(previousConnection!.id),
enabled: !!previousConnection && activeTab === "setup",
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
queryFn: () => agentsApi.list(selectedCompanyId!),
@ -164,20 +170,48 @@ export function AppNotConnected() {
}
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
const logoUrl =
(appSourceSlug
? appDefinitionLogoUrl(gallery.find((entry) => appDefinitionSlug(entry) === appSourceSlug))
: undefined) ??
appDefinitionLogoUrl(
gallery.find((entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase()),
const logoEntry = (appSourceSlug
? gallery.find((entry) => appDefinitionSlug(entry) === appSourceSlug)
: undefined) ?? gallery.find(
(entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase(),
);
const logoUrl = appDefinitionLogoUrl(logoEntry);
const darkLogoUrl = appDefinitionDarkLogoUrl(logoEntry);
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
const retainedPersonalGrant = previousConnection?.credentialPolicy === "per_user"
? grantsQuery.data?.grants.find((grant) => (
grant.kind === "user" && grant.subjectUserId === previousConnection.createdByUserId
))
?? grantsQuery.data?.grants.find((grant) => grant.kind === "user" && grant.status === "active")
?? grantsQuery.data?.grants.find((grant) => grant.kind === "user")
?? null
: null;
const retainedPersonalUserId = previousConnection?.credentialPolicy === "per_user"
? previousConnection.createdByUserId ?? retainedPersonalGrant?.subjectUserId ?? null
: null;
const canReconnect = !previousConnection
|| (previousConnection.credentialPolicy === "per_user"
? Boolean(
retainedPersonalUserId
&& retainedPersonalUserId === grantsQuery.data?.currentUserId
&& grantsQuery.data?.capabilities.canConnectAsCurrentUser,
)
: grantsQuery.data?.capabilities.canConfigure === true);
const reconnectUnavailableMessage = grantsQuery.isLoading
? "Checking who can reconnect this identity…"
: grantsQuery.isError
? "We couldn't verify who can reconnect this identity. Reload the page to try again."
: previousConnection?.credentialPolicy === "per_user"
&& retainedPersonalUserId !== grantsQuery.data?.currentUserId
? "The person this connection belongs to must reconnect it."
: "You don't have permission to reconnect this identity.";
const connectHref = newConnectionHref({
applicationId,
appName: application.name,
previousAddress,
sourceSlug: appSourceSlug,
previousConnection,
sourceSlug: isConnectableAppSlug(appSourceSlug) ? appSourceSlug : null,
});
return (
@ -186,6 +220,7 @@ export function AppNotConnected() {
applicationName={application.name}
description={application.description}
logoUrl={logoUrl}
darkLogoUrl={darkLogoUrl}
connectedCount={activeConnections.length}
/>
@ -197,6 +232,8 @@ export function AppNotConnected() {
previousConnection={previousConnection}
previousAddress={previousAddress}
userProfileById={userProfileById}
canReconnect={canReconnect}
reconnectUnavailableMessage={reconnectUnavailableMessage}
onConnect={() => navigate(connectHref)}
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
/>
@ -259,16 +296,18 @@ function ApplicationHeader({
applicationName,
description,
logoUrl,
darkLogoUrl,
connectedCount,
}: {
applicationName: string;
description: string | null;
logoUrl: string | undefined;
darkLogoUrl: string | undefined;
connectedCount: number;
}) {
return (
<header className="flex flex-wrap items-center gap-4">
<AppLogo name={applicationName} logoUrl={logoUrl} size={48} />
<AppLogo name={applicationName} logoUrl={logoUrl} darkLogoUrl={darkLogoUrl} 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>
@ -290,6 +329,8 @@ function SetupTab({
previousConnection,
previousAddress,
userProfileById,
canReconnect,
reconnectUnavailableMessage,
onConnect,
onEdit,
}: {
@ -298,6 +339,8 @@ function SetupTab({
previousConnection: ToolConnection | null;
previousAddress: string | null;
userProfileById: ReadonlyMap<string, CompanyUserProfile>;
canReconnect: boolean;
reconnectUnavailableMessage: string;
onConnect: () => void;
onEdit: (connectionId: string) => void;
}) {
@ -368,13 +411,20 @@ function SetupTab({
</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."
? previousConnection.authKind === "oauth"
? "We kept the previous setup. Sign in again to bring it back online."
: "We kept the previous setup. Add a working key to bring it back online."
: "Agents can't use it until it's connected."}
</p>
{previousConnection && !canReconnect ? (
<p className="mt-1 text-sm text-muted-foreground">{reconnectUnavailableMessage}</p>
) : null}
</div>
<Button onClick={onConnect}>
{previousConnection ? "Reconnect" : "Connect"}
</Button>
{!previousConnection || canReconnect ? (
<Button onClick={onConnect}>
{previousConnection ? "Reconnect" : "Connect"}
</Button>
) : null}
</div>
</section>
@ -465,16 +515,34 @@ function newConnectionHref({
applicationId,
appName,
previousAddress,
previousConnection,
sourceSlug,
}: {
applicationId: string;
appName: string;
previousAddress: string | null;
previousConnection: ToolConnection | null;
sourceSlug: string | null;
}): string {
const params = new URLSearchParams({ applicationId, name: appName, new: "1" });
if (previousConnection) {
params.set("reconnect", previousConnection.id);
params.set("identity", previousConnection.credentialPolicy === "per_user" ? "user" : "organization");
}
if (sourceSlug) params.set("source", sourceSlug);
if (!isMcpDirectOAuthConnectSlug(sourceSlug)) params.set("byo", "1");
if (previousAddress && /^https?:\/\//i.test(previousAddress)) params.set("link", previousAddress);
return `/apps/connect?${params.toString()}`;
else params.set("byo", "1");
const storedLink = [
previousConnection?.config?.url,
previousConnection?.config?.endpoint,
previousConnection?.config?.remoteUrl,
previousConnection?.transportConfig.url,
previousConnection?.transportConfig.endpoint,
previousConnection?.transportConfig.remoteUrl,
previousAddress,
].find((value): value is string => typeof value === "string" && /^https?:\/\//i.test(value));
if (storedLink) params.set("link", storedLink);
const path = previousConnection?.credentialSource === "vercel_connect"
? "/apps/vercel-connect"
: "/apps/connect";
return `${path}?${params.toString()}`;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,9 @@ vi.mock("@/api/access", () => ({
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
useNavigate: () => navigateMock,
}));
@ -69,7 +71,10 @@ function galleryEntry(overrides: Record<string, unknown>) {
logoUrl: "https://example.com/github.png",
tagline: "Let agents open PRs and issues.",
authKind: "oauth",
transportTemplate: { transport: "mcp_remote", url: "https://api.github.com/mcp" },
transportTemplate: {
transport: "mcp_remote",
url: "https://api.github.com/mcp",
},
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
@ -84,13 +89,40 @@ describe("Browse store door (PAP-13254 door 1)", () => {
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: "notion", name: "Notion", tagline: "Read and update workspace content." }),
galleryEntry({ key: "composio", name: "Composio", tagline: "Connect hosted toolkits." }),
galleryEntry({ key: "gmail", name: "Gmail", tagline: "Search and draft email." }),
galleryEntry({ key: "acme", name: "Acme CRM", tagline: "Sync deals and contacts." }),
galleryEntry({
key: "zapier",
name: "Zapier",
tagline: "Connect automations.",
}),
galleryEntry({
key: "jira",
name: "Jira",
tagline: "Track projects and issues.",
}),
galleryEntry({
key: "cloudflare",
name: "Cloudflare",
tagline: "Manage Cloudflare resources.",
}),
galleryEntry({
key: "notion",
name: "Notion",
tagline: "Read and update workspace content.",
}),
galleryEntry({
key: "gmail",
name: "Gmail",
tagline: "Search and draft email.",
availability: {
available: false,
reason: "Gmail is not available on this Paperclip instance yet.",
},
}),
galleryEntry({
key: "acme",
name: "Acme CRM",
tagline: "Sync deals and contacts.",
}),
],
});
listApplicationsMock.mockResolvedValue({ applications: [] });
@ -107,7 +139,9 @@ describe("Browse store door (PAP-13254 door 1)", () => {
});
async function renderBrowse() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
root = createRoot(container);
await act(async () => {
root.render(
@ -129,58 +163,97 @@ describe("Browse store door (PAP-13254 door 1)", () => {
expect(text).not.toContain("Other integrations are previews.");
expect(text).toContain("Popular");
expect(text).toContain("All apps");
expect(text).toContain("GitHub");
expect(text).toContain("Slack");
expect(text).toContain("Jira");
expect(text).toContain("Cloudflare");
expect(text).toContain("Acme CRM");
expect(
Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="All apps"] > [data-app-slug]',
),
).map((tile) => tile.dataset.appSlug),
).toEqual(["acme", "cloudflare", "gmail", "jira", "notion", "zapier"]);
// Bring-your-own is a first-class row in the store.
expect(text).toContain("Connect your own tool");
expect(text).toContain("All discovered actions are enabled automatically.");
expect(text).not.toContain("review its actions before enabling it");
expect(text).not.toContain("Vercel Connect");
expect(text).not.toContain("Composio");
});
it("enables Notion, Zapier, Gmail, and custom URLs while fading unfinished integrations", async () => {
it("does not surface Vercel Connect even when the backend capability is enabled", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
galleryEntry({
key: "posthog",
name: "PostHog",
tagline: "Analyze product usage.",
}),
],
credentialSources: {
vercelConnect: {
available: true,
enabled: true,
authentication: "access_token",
manageUrl: "https://vercel.com/connect",
reason: null,
},
},
});
await renderBrowse();
expect(container.textContent).not.toContain("Vercel Connect");
expect(navigateMock).not.toHaveBeenCalledWith("/apps/vercel-connect");
});
it("routes every capability-backed app and explains instance-disabled apps", async () => {
await renderBrowse();
const zapierTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Zapier"]'),
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Connect for Zapier"]',
),
);
const githubTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for GitHub"]'),
const jiraTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Connect for Jira"]',
),
);
const notionTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Notion"]'),
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Connect for Notion"]',
),
);
const composioTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Composio"]'),
);
const gmailTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Gmail"]'),
const gmailTile = container.querySelector<HTMLButtonElement>(
'button[aria-label="Unavailable for Gmail"]',
);
const tile = container.querySelector<HTMLButtonElement>(
'button[aria-label="Coming soon for Acme CRM"]',
'button[aria-label="Unavailable for Acme CRM"]',
);
const byoCard = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Connect your own tool"),
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(notionTiles).toHaveLength(2);
expect(notionTiles.every((button) => !button.disabled)).toBe(true);
expect(composioTiles).toHaveLength(1);
expect(composioTiles[0]?.disabled).toBe(false);
expect(gmailTiles).toHaveLength(1);
expect(gmailTiles[0]?.disabled).toBe(false);
expect(githubTiles.every((button) => button.disabled)).toBe(true);
expect(jiraTiles).toHaveLength(2);
expect(jiraTiles.every((button) => !button.disabled)).toBe(true);
expect(tile?.disabled).toBe(true);
expect(gmailTile?.disabled).toBe(true);
expect(byoCard?.disabled).toBe(false);
expect(tile?.textContent).toContain("Coming soon");
expect(tile?.textContent).toContain("Unavailable");
expect(container.textContent).toContain(
"Gmail is not available on this Paperclip instance yet.",
);
expect(container.textContent).not.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");
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=zapier");
await act(async () => {
notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -188,14 +261,9 @@ describe("Browse store door (PAP-13254 door 1)", () => {
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=notion");
await act(async () => {
composioTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
jiraTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=composio&stage=setup");
await act(async () => {
gmailTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=gmail&stage=access");
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=jira");
await act(async () => {
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -206,21 +274,23 @@ describe("Browse store door (PAP-13254 door 1)", () => {
it("filters the gallery by the search query", async () => {
await renderBrowse();
const input = container.querySelector<HTMLInputElement>('input[type="search"]');
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");
setter?.call(input, "cloudflare");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
const text = container.textContent ?? "";
expect(text).toContain("Results (1)");
expect(text).toContain("Slack");
expect(text).toContain("Cloudflare");
expect(text).not.toContain("Acme CRM");
// Popular grid is hidden while searching.
expect(text).not.toContain("Popular");
@ -229,7 +299,13 @@ describe("Browse store door (PAP-13254 door 1)", () => {
it("shows the connected owner, edits existing connections, and offers a deliberate second account", async () => {
listApplicationsMock.mockResolvedValue({
applications: [
{ id: "app-notion", name: "Legacy integration", status: "active", applicationKey: "legacy:notion", metadata: {} },
{
id: "app-notion",
name: "Legacy integration",
status: "active",
applicationKey: "legacy:notion",
metadata: {},
},
],
});
listConnectionsMock.mockResolvedValue({
@ -248,16 +324,18 @@ describe("Browse store door (PAP-13254 door 1)", () => {
],
});
listUserDirectoryMock.mockResolvedValue({
users: [{
principalId: "user-1",
status: "active",
user: {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: "https://example.com/dotta.png",
users: [
{
principalId: "user-1",
status: "active",
user: {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: "https://example.com/dotta.png",
},
},
}],
],
});
await renderBrowse();
@ -267,12 +345,112 @@ describe("Browse store door (PAP-13254 door 1)", () => {
'button[aria-label="Edit connections for Notion"]',
),
);
expect(editButtons).toHaveLength(2);
expect(editButtons.every((button) => button.textContent?.includes("Edit connections"))).toBe(true);
// draft connections count toward the total (they are real, pending-setup connections)
expect(container.textContent).toContain("3 connected");
expect(editButtons).toHaveLength(3);
expect(
editButtons.every((button) =>
button.textContent?.includes("Edit connections"),
),
).toBe(true);
// Interrupted setup remains resumable but is not represented as a
// successful provider connection.
expect(container.textContent).toContain("2 connected");
expect(container.textContent).toContain("Dottas Notion");
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
expect(
container.querySelector('[title="Dotta"] [data-slot="avatar"]'),
).toBeTruthy();
const connectedAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Connected apps"] > [data-app-slug]',
),
);
expect(connectedAppTiles).toHaveLength(1);
expect(connectedAppTiles[0]?.dataset.appSlug).toBe("notion");
const pageText = container.textContent ?? "";
expect(pageText.indexOf("Popular")).toBeLessThan(
pageText.indexOf("Connected"),
);
expect(pageText.indexOf("Connected")).toBeLessThan(
pageText.indexOf("All apps"),
);
const popularAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Popular apps"] > [data-app-slug]',
),
);
const popularGrid = container.querySelector<HTMLElement>(
'[aria-label="Popular apps"]',
);
const connectedGrid = container.querySelector<HTMLElement>(
'[aria-label="Connected apps"]',
);
for (const grid of [popularGrid, connectedGrid]) {
expect(grid?.className).toContain("lg:grid-cols-4");
expect(grid?.className).toContain("xl:grid-cols-6");
}
for (const tile of popularAppTiles) {
expect(tile.className).toContain("min-w-0");
expect(
tile.querySelector('[data-slot="app-tile-status"]')?.className,
).toContain("min-h-4");
expect(
tile.querySelector('[data-slot="app-tile-primary-action"]')?.className,
).toContain("w-full");
expect(
tile.querySelector('[data-slot="app-tile-primary-action"] button')
?.className,
).toContain("max-w-full");
expect(
tile.querySelector('[data-slot="app-tile-secondary-action"]')
?.className,
).toContain("min-h-5");
}
const allAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="All apps"] > [data-app-slug]',
),
);
const notionAllAppsTile = allAppTiles.find(
(tile) => tile.dataset.appSlug === "notion",
);
expect(notionAllAppsTile?.dataset.connected).toBe("true");
expect(notionAllAppsTile?.textContent).toContain("Dottas Notion");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-title"]')
?.className,
).toContain("break-words");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-title"]')
?.className,
).not.toContain("truncate");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-header-status"]')
?.className,
).toContain("min-h-5");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-connection-name"]')
?.className,
).toContain("break-words");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-connection-name"]')
?.className,
).not.toContain("truncate");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-details"]')
?.className,
).toContain("mt-auto");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-actions"]'),
).toBeTruthy();
expect(
notionAllAppsTile?.querySelector(
'button[aria-label="Add another Notion account"]',
),
).toBeTruthy();
expect(
notionAllAppsTile?.querySelector(
'button[aria-label="Edit connections for Notion"]',
),
).toBeTruthy();
await act(async () => {
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -291,7 +469,55 @@ describe("Browse store door (PAP-13254 door 1)", () => {
);
});
it("treats an existing draft-only Notion connection as editable", async () => {
it("associates a legacy generic Zapier URL connection with the curated Zapier card", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
galleryEntry({
key: "zapier",
name: "Zapier",
tagline: "Connect automations.",
urlPatterns: ["https://mcp.zapier.com/*"],
}),
],
});
listApplicationsMock.mockResolvedValueOnce({
applications: [
{
id: "app-zapier-link",
name: "Zapier for the company",
status: "active",
applicationKey: "app-gallery:link:legacy",
metadata: { source: "link" },
},
],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [
{
id: "conn-zapier",
applicationId: "app-zapier-link",
name: "Zapier for the company",
status: "active",
config: { url: "https://mcp.zapier.com/api/v1/connect" },
transportConfig: { url: "https://mcp.zapier.com/api/v1/connect" },
},
],
});
await renderBrowse();
expect(
container.querySelector('button[aria-label="Connect for Zapier"]'),
).toBeNull();
expect(
container.querySelector(
'button[aria-label="Edit connection for Zapier"]',
),
).toBeTruthy();
expect(container.textContent).toContain("1 connected");
});
it("keeps an existing draft-only Notion connection resumable without calling it connected", async () => {
const applicationId = "057a2df6-175f-4dde-b246-743706444122";
const connectionId = "46dc23c1-ecfa-46f7-8e60-34a7cdbd661e";
listApplicationsMock.mockResolvedValue({
@ -320,28 +546,50 @@ describe("Browse store door (PAP-13254 door 1)", () => {
await renderBrowse();
const editButtons = Array.from(
const finishButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Edit connection for Notion"]',
'button[aria-label="Finish setup for Notion"]',
),
);
expect(editButtons).toHaveLength(2);
expect(container.querySelector('button[aria-label="Connect for Notion"]')).toBeNull();
expect(container.textContent).toContain("1 connected");
expect(finishButtons).toHaveLength(2);
expect(
container.querySelector('button[aria-label="Add another Notion account"]')?.textContent,
).toContain("Add new");
container.querySelector('button[aria-label="Connect for Notion"]'),
).toBeNull();
expect(container.textContent).toContain("Setup incomplete");
expect(container.textContent).not.toContain("1 connected");
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
expect(
container.querySelector(
'button[aria-label="Add another Notion account"]',
),
).toBeNull();
const allAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="All apps"] > [data-app-slug]',
),
);
const notionAllAppsTile = allAppTiles.find(
(tile) => tile.dataset.appSlug === "notion",
);
expect(notionAllAppsTile?.dataset.connected).toBe("false");
expect(notionAllAppsTile?.dataset.setupPending).toBe("true");
await act(async () => {
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
finishButtons[0]?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(navigateMock).toHaveBeenCalledWith(`/apps/${connectionId}/setup`);
expect(navigateMock).toHaveBeenCalledWith(
`/apps/connect?source=notion&resume=${connectionId}`,
);
});
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 input = container.querySelector<HTMLInputElement>(
'input[type="search"]',
);
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",

View File

@ -1,7 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, Link2, Search } from "lucide-react";
import {
appSupportsCatalogSetup,
getAppDefinitionForUrl,
getAppStoreDefinition,
} from "@paperclipai/shared";
import { useNavigate } from "@/lib/router";
import { cn } from "@/lib/utils";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { queryKeys } from "@/lib/queryKeys";
@ -13,6 +19,7 @@ import { buildCompanyUserProfileMap } from "@/lib/company-members";
import { AppLogo } from "./AppLogo";
import {
appApplicationSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionDescription,
appDefinitionLogoUrl,
appDefinitionName,
@ -23,10 +30,12 @@ import {
AdvancedToolsLink,
BYO_CONNECT_HREF,
ByoConnectCard,
NOTION_CONNECT_HREF,
POPULAR_KEYS,
ZAPIER_CONNECT_HREF,
} from "./store-cards";
import {
appSourceConnectHref,
appSourceResumeHref,
} from "./app-connect-policy";
import {
ConnectionOwnerIdentity,
connectionDisplayNameForOwner,
@ -36,12 +45,10 @@ import {
function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
const slug = appDefinitionSlug(entry);
if (slug === "notion") return NOTION_CONNECT_HREF;
if (slug === "zapier") return ZAPIER_CONNECT_HREF;
if (slug === "posthog") return "/apps/connect?byo=1&appKey=posthog&stage=setup";
if (slug === "composio") return "/apps/connect?byo=1&appKey=composio&stage=setup";
if (slug === "gmail") return "/apps/connect?byo=1&appKey=gmail&stage=access";
return null;
const definition = getAppStoreDefinition(slug);
return appSupportsCatalogSetup(definition)
? appSourceConnectHref(slug)
: null;
}
function additionalConnectionHref(
@ -61,10 +68,11 @@ function additionalConnectionHref(
/**
* 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. Notion uses MCP-direct
* OAuth, while Zapier and bring-your-own MCP servers use the URL flow.
* A persistent, browsable storefront: search + Popular and Connected grids +
* the full gallery + a first-class bring-your-own card + a labelled Developer
* link.
* Browse remains the single discoverability surface. Capability-backed apps
* share the curated setup route; Zapier branches to its generated-URL screen.
*/
export function Browse() {
const navigate = useNavigate();
@ -96,7 +104,9 @@ export function Browse() {
enabled: !!selectedCompanyId,
});
const userDirectoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
queryKey: queryKeys.access.companyUserDirectory(
selectedCompanyId ?? "__none__",
),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
@ -104,9 +114,9 @@ export function Browse() {
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
const popular = useMemo(
() =>
POPULAR_KEYS.map((key) => gallery.find((entry) => appDefinitionSlug(entry) === key)).filter(
(entry): entry is AppGalleryDisplayEntry => Boolean(entry),
),
POPULAR_KEYS.map((key) =>
gallery.find((entry) => appDefinitionSlug(entry) === key),
).filter((entry): entry is AppGalleryDisplayEntry => Boolean(entry)),
[gallery],
);
@ -121,42 +131,92 @@ export function Browse() {
}, [gallery, trimmed]);
const connectionSummaryBySlug = useMemo(() => {
const connections = connectionsQuery.data?.connections ?? [];
const gallerySlugs = new Set(gallery.map((entry) => appDefinitionSlug(entry)));
const gallerySlugs = new Set(
gallery.map((entry) => appDefinitionSlug(entry)),
);
const gallerySlugByName = new Map(
gallery.map((entry) => [appDefinitionName(entry).trim().toLowerCase(), appDefinitionSlug(entry)]),
gallery.map((entry) => [
appDefinitionName(entry).trim().toLowerCase(),
appDefinitionSlug(entry),
]),
);
const connectionsByApplicationId = new Map<string, typeof connections>();
for (const connection of connections) {
if (connection.status === "archived") continue;
connectionsByApplicationId.set(
connection.applicationId,
[...(connectionsByApplicationId.get(connection.applicationId) ?? []), connection],
);
connectionsByApplicationId.set(connection.applicationId, [
...(connectionsByApplicationId.get(connection.applicationId) ?? []),
connection,
]);
}
const summaries = new Map<string, {
applicationId: string;
count: number;
primaryConnection: (typeof connections)[number] | null;
}>();
const summaries = new Map<
string,
{
applicationId: string;
connectedCount: number;
draftCount: number;
primaryConnection: (typeof connections)[number] | null;
}
>();
for (const application of applicationsQuery.data?.applications ?? []) {
if (application.status === "archived") continue;
const appConnections = connectionsByApplicationId.get(application.id) ?? [];
const appConnections =
connectionsByApplicationId.get(application.id) ?? [];
const configuredConnectionSlug = appConnections
.map((connection) => connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey)
.find((value): value is string => typeof value === "string" && gallerySlugs.has(value));
.map(
(connection) =>
connection.config?.sourceTemplateKey ??
connection.transportConfig?.sourceTemplateKey,
)
.find(
(value): value is string =>
typeof value === "string" && gallerySlugs.has(value),
);
// Older branded URL flows (notably Zapier) were persisted as generic
// `link` applications even though their public endpoint matched a curated
// provider. Keep those already-working connections attached to the store
// card without rewriting credentials or relying on a display-name guess.
const endpointMatchedSlug = appConnections
.flatMap((connection) => [
connection.config?.url,
connection.transportConfig?.url,
])
.map((value) =>
typeof value === "string"
? appDefinitionSlug(getAppDefinitionForUrl(value, gallery)) || null
: null,
)
.find((value): value is string => Boolean(value));
const applicationSlug = appApplicationSourceSlug(application);
const slug = applicationSlug && gallerySlugs.has(applicationSlug)
? applicationSlug
: configuredConnectionSlug
?? gallerySlugByName.get(application.name.trim().toLowerCase())
?? null;
const slug =
applicationSlug &&
applicationSlug !== "link" &&
gallerySlugs.has(applicationSlug)
? applicationSlug
: (configuredConnectionSlug ??
endpointMatchedSlug ??
gallerySlugByName.get(application.name.trim().toLowerCase()) ??
null);
if (!slug) continue;
const current = summaries.get(slug);
const connectedConnections = appConnections.filter(
(connection) => connection.status !== "draft",
);
const draftConnections = appConnections.filter(
(connection) => connection.status === "draft",
);
summaries.set(slug, {
applicationId: current?.applicationId ?? application.id,
count: (current?.count ?? 0) + appConnections.length,
primaryConnection: current?.primaryConnection ?? appConnections[0] ?? null,
connectedCount:
(current?.connectedCount ?? 0) + connectedConnections.length,
draftCount: (current?.draftCount ?? 0) + draftConnections.length,
// Interrupted OAuth attempts remain resumable, but are not successful
// connections and must not receive the green Connected treatment.
primaryConnection:
current?.primaryConnection ??
connectedConnections[0] ??
draftConnections[0] ??
null,
});
}
return summaries;
@ -165,32 +225,108 @@ export function Browse() {
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
[userDirectoryQuery.data],
);
const sortedPopular = useMemo(
() =>
popular
.map((entry, index) => ({ entry, index }))
.sort((left, right) => {
const leftSummary = connectionSummaryBySlug.get(
appDefinitionSlug(left.entry),
);
const rightSummary = connectionSummaryBySlug.get(
appDefinitionSlug(right.entry),
);
const leftRank =
(leftSummary?.connectedCount ?? 0) > 0
? 2
: (leftSummary?.draftCount ?? 0) > 0
? 1
: 0;
const rightRank =
(rightSummary?.connectedCount ?? 0) > 0
? 2
: (rightSummary?.draftCount ?? 0) > 0
? 1
: 0;
return rightRank - leftRank || left.index - right.index;
})
.map(({ entry }) => entry),
[connectionSummaryBySlug, popular],
);
const connectedApps = useMemo(
() =>
gallery.filter(
(entry) =>
(connectionSummaryBySlug.get(appDefinitionSlug(entry))
?.connectedCount ?? 0) > 0,
),
[connectionSummaryBySlug, gallery],
);
const sortedFiltered = useMemo(
() =>
[...filtered].sort(
(left, right) =>
appDefinitionName(left).localeCompare(
appDefinitionName(right),
undefined,
{
sensitivity: "base",
},
) || appDefinitionSlug(left).localeCompare(appDefinitionSlug(right)),
),
[filtered],
);
if (!selectedCompanyId) {
return <div className="p-6 text-sm text-muted-foreground">Select an organization to browse apps.</div>;
return (
<div className="p-6 text-sm text-muted-foreground">
Select an organization to browse apps.
</div>
);
}
const loading = galleryQuery.isLoading || applicationsQuery.isLoading || connectionsQuery.isLoading;
const loading =
galleryQuery.isLoading ||
applicationsQuery.isLoading ||
connectionsQuery.isLoading;
const tileProps = (entry: AppGalleryDisplayEntry) => {
const summary = connectionSummaryBySlug.get(appDefinitionSlug(entry));
const connectHref = connectHrefFor(entry);
const available = entry.availability?.available !== false;
const primaryConnection = summary?.primaryConnection ?? null;
const owner = primaryConnection ? connectionOwnerProfile(primaryConnection, userProfileById) : null;
const addAnotherHref = summary
? additionalConnectionHref(entry, summary.applicationId)
const owner = primaryConnection
? connectionOwnerProfile(primaryConnection, userProfileById)
: null;
const addAnotherHref =
summary && summary.connectedCount > 0
? additionalConnectionHref(entry, summary.applicationId)
: null;
const setupPending =
(summary?.connectedCount ?? 0) === 0 && (summary?.draftCount ?? 0) > 0;
return {
connectedCount: summary?.count ?? 0,
connectedCount: summary?.connectedCount ?? 0,
setupPending,
connectionName: primaryConnection
? connectionDisplayNameForOwner(primaryConnection, appDefinitionName(entry), owner)
? connectionDisplayNameForOwner(
primaryConnection,
appDefinitionName(entry),
owner,
)
: null,
owner,
onPrimary: primaryConnection
? () => navigate(summary && summary.count > 1
? `/apps/app/${summary.applicationId}/setup`
: `/apps/${primaryConnection.id}/setup`)
? () =>
navigate(
setupPending
? appSourceResumeHref(
appDefinitionSlug(entry),
primaryConnection.id,
)
: summary && summary.connectedCount > 1
? `/apps/app/${summary.applicationId}/setup`
: `/apps/${primaryConnection.id}/setup`,
)
: available && connectHref
? () => navigate(connectHref)
: undefined,
@ -227,13 +363,37 @@ export function Browse() {
</div>
) : (
<>
{!trimmed && popular.length > 0 && (
{!trimmed && sortedPopular.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) => (
<div
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
aria-label="Popular apps"
>
{sortedPopular.map((entry) => (
<AppTile
key={appDefinitionSlug(entry)}
entry={entry}
{...tileProps(entry)}
compact
/>
))}
</div>
</section>
)}
{!trimmed && connectedApps.length > 0 && (
<section className="space-y-3">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Connected
</div>
<div
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
aria-label="Connected apps"
>
{connectedApps.map((entry) => (
<AppTile
key={appDefinitionSlug(entry)}
entry={entry}
@ -247,16 +407,19 @@ export function Browse() {
<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"}
{trimmed ? `Results (${sortedFiltered.length})` : "All apps"}
</div>
{filtered.length === 0 ? (
{sortedFiltered.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) => (
<div
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
aria-label={trimmed ? "App search results" : "All apps"}
>
{sortedFiltered.map((entry) => (
<AppTile
key={appDefinitionSlug(entry)}
entry={entry}
@ -283,6 +446,7 @@ function AppTile({
onPrimary,
onAddAnother,
connectedCount,
setupPending,
connectionName,
owner,
compact = false,
@ -291,99 +455,220 @@ function AppTile({
onPrimary?: () => void;
onAddAnother?: () => void;
connectedCount: number;
setupPending: boolean;
connectionName: string | null;
owner: ConnectionOwnerProfile | null;
compact?: boolean;
}) {
const disabled = !onPrimary;
const unavailableReason =
entry.availability?.available === false
? (entry.availability.reason ?? "This app is disabled on this instance.")
: null;
const connected = connectedCount > 0;
const appName = appDefinitionName(entry);
const actionLabel = connected
? connectedCount > 1 ? "Edit connections" : "Edit connection"
: disabled ? "Coming soon" : "Connect";
? connectedCount > 1
? "Edit connections"
: "Edit connection"
: setupPending
? "Finish setup"
: disabled
? "Unavailable"
: "Connect";
const connectedActionClass = connected
? "border-emerald-500/50 text-emerald-700 hover:bg-emerald-500/10 dark:text-emerald-300"
: undefined;
if (compact) {
return (
<div
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"}
data-app-slug={appDefinitionSlug(entry)}
data-connected={connected ? "true" : "false"}
data-setup-pending={setupPending ? "true" : "false"}
className={
disabled
? "flex h-full min-w-0 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 h-full min-w-0 flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center"
}
>
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<AppLogo
name={appName}
logoUrl={appDefinitionLogoUrl(entry)}
darkLogoUrl={appDefinitionDarkLogoUrl(entry)}
size={36}
/>
<span className="text-xs font-medium text-foreground">{appName}</span>
{connected && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<Check className="h-3 w-3" /> {connectedCount} connected
</span>
)}
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
className={connectedActionClass}
aria-label={`${actionLabel} for ${appName}`}
<div
data-slot="app-tile-status"
className="flex min-h-4 max-w-full items-center justify-center"
>
{actionLabel}
</Button>
{connected && onAddAnother && (
<button
{connected ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<Check className="h-3 w-3" /> {connectedCount} connected
</span>
) : setupPending ? (
<span className="text-xs font-medium text-muted-foreground">
Setup incomplete
</span>
) : unavailableReason ? (
<span
className="truncate text-xs text-muted-foreground"
title={unavailableReason}
>
{unavailableReason}
</span>
) : null}
</div>
<div data-slot="app-tile-primary-action" className="w-full min-w-0">
<Button
type="button"
onClick={onAddAnother}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Add another ${appName} account`}
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
className={cn(
"w-full max-w-full overflow-hidden text-ellipsis",
connectedActionClass,
)}
aria-label={`${actionLabel} for ${appName}`}
>
Add new
</button>
)}
{actionLabel}
</Button>
</div>
<div
data-slot="app-tile-secondary-action"
className="flex min-h-5 items-center justify-center"
>
{connected && onAddAnother ? (
<button
type="button"
onClick={onAddAnother}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Add another ${appName} account`}
>
Add new
</button>
) : null}
</div>
</div>
);
}
return (
<div
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"}
data-app-slug={appDefinitionSlug(entry)}
data-connected={connected ? "true" : "false"}
data-setup-pending={setupPending ? "true" : "false"}
className={
disabled
? "flex h-full cursor-not-allowed flex-col rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
: "flex h-full flex-col rounded-xl border border-border bg-card px-4 py-4 text-left"
}
>
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">{appName}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{appDefinitionDescription(entry)}</div>
{connected && owner && (
<div className="mt-2">
<ConnectionOwnerIdentity owner={owner} />
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
{connected && connectionName && (
<span className="max-w-40 truncate text-xs text-muted-foreground">{connectionName}</span>
)}
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
className={connectedActionClass}
aria-label={`${actionLabel} for ${appName}`}
>
{actionLabel}
</Button>
{connected && onAddAnother && (
<button
type="button"
onClick={onAddAnother}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Add another ${appName} account`}
<div className="flex items-start gap-3">
<AppLogo
name={appName}
logoUrl={appDefinitionLogoUrl(entry)}
darkLogoUrl={appDefinitionDarkLogoUrl(entry)}
size={36}
/>
<div className="min-w-0 flex-1">
<div
data-slot="app-tile-title"
className="break-words text-sm font-semibold leading-tight text-foreground"
>
Add new
</button>
)}
{appName}
</div>
<div
data-slot="app-tile-header-status"
className="mt-1 flex min-h-5 items-center"
>
{connected && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<Check className="h-3 w-3" />
{connectedCount > 1
? `${connectedCount} connected`
: "Connected"}
</span>
)}
{setupPending && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
Setup incomplete
</span>
)}
</div>
</div>
</div>
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{appDefinitionDescription(entry)}
</div>
{unavailableReason ? (
<div className="mt-2 text-xs text-muted-foreground">
{unavailableReason}
</div>
) : null}
{connected || setupPending ? (
<div
data-slot="app-tile-details"
className="mt-auto border-t border-border pt-3"
>
{connectionName && (
<div
data-slot="app-tile-connection-name"
className="break-words text-sm font-medium text-foreground"
>
{connectionName}
</div>
)}
{owner ? (
<div className="mt-2 min-w-0">
<ConnectionOwnerIdentity owner={owner} />
</div>
) : null}
<div
data-slot="app-tile-actions"
className="mt-3 flex items-center justify-end gap-2"
>
{onAddAnother && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onAddAnother}
aria-label={`Add another ${appName} account`}
>
Add new
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
className={connectedActionClass}
aria-label={`${actionLabel} for ${appName}`}
>
{actionLabel}
</Button>
</div>
</div>
) : (
<div
data-slot="app-tile-actions"
className="mt-auto flex justify-end pt-3"
>
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
aria-label={`${actionLabel} for ${appName}`}
>
{actionLabel}
</Button>
</div>
)}
</div>
);
}

View File

@ -13,21 +13,37 @@ import { composioChildParentConnectionId, composioChildToolkitSlug } from "./com
* relationship wherever the child appears, and links to the broker's Services tab
* so the parent is one click away.
*/
export function ComposioProvenanceChip({
export function ConnectionProvenanceChip({
connection,
className,
}: {
connection: { config?: Record<string, unknown> | null } | null | undefined;
connection: {
config?: Record<string, unknown> | null;
credentialSource?: string;
externalCredential?: { connectorUid?: string } | null;
} | null | undefined;
className?: string;
}) {
const toolkitSlug = composioChildToolkitSlug(connection);
if (!toolkitSlug) return null;
const parentConnectionId = composioChildParentConnectionId(connection);
const chipClass = cn(
"inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground",
className,
);
if (connection?.credentialSource === "vercel_connect") {
const connectorUid = connection.externalCredential?.connectorUid;
return (
<span
className={chipClass}
title={connectorUid ? `Credentials managed by Vercel Connect (${connectorUid})` : "Credentials managed by Vercel Connect"}
>
<Blocks className="h-3 w-3" />
via Vercel Connect
</span>
);
}
const toolkitSlug = composioChildToolkitSlug(connection);
if (!toolkitSlug) return null;
const parentConnectionId = composioChildParentConnectionId(connection);
const label = (
<>
<Blocks className="h-3 w-3" />
@ -52,3 +68,6 @@ export function ComposioProvenanceChip({
</Link>
);
}
/** Backward-compatible name for callers outside the Apps v2 surfaces. */
export const ComposioProvenanceChip = ConnectionProvenanceChip;

View File

@ -111,6 +111,7 @@ function connection(overrides: Record<string, unknown>) {
healthStatus: "healthy",
healthCheckedAt: null,
lastError: null,
credentialPolicy: "shared",
enabled: true,
lastUsedAt: null,
createdByAgentId: null,
@ -231,6 +232,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
name: "GitHub",
healthStatus: "healthy",
createdByUserId: "user-1",
credentialPolicy: "per_user",
}),
connection({
id: "c-attention",
@ -295,7 +297,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
expect(text).toContain("1 connection needs attention");
// 3. New header columns are present.
const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent?.trim());
expect(headers).toEqual(["Connection", "Connected by", "Status", "Actions", "Last used", ""]);
expect(headers).toEqual(["Connection", "Type", "Connected by", "Status", "Actions", "Last used", ""]);
expect(text).toContain("Personal");
expect(text).toContain("Company");
// 4. Actions column reflects enabled catalog entries per account; missing profile => 0 on.
expect(text).toContain("3 on");
expect(text).toContain("0 on");
@ -318,6 +322,8 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
expect(rowButtonLabel("Notion")).toBe("Edit");
// 8. Generic connection names inherit the originating user's first name.
expect(text).toContain("Dottas GitHub");
expect(text).toContain("Slack for the company");
expect(text).toContain("Slack Team for the company");
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
// Custom account labels remain untouched.
expect(text).toContain("Slack Team");
@ -374,7 +380,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
await renderApps();
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete GitHub connection"]',
'button[aria-label="Delete GitHub for the company connection"]',
);
expect(deleteButton).toBeTruthy();
@ -431,7 +437,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
await renderApps();
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Composio connection"]',
'button[aria-label="Delete Composio for the company connection"]',
);
await act(async () => {
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -465,7 +471,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
await renderApps();
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete GitHub connection"]',
'button[aria-label="Delete GitHub for the company connection"]',
);
await act(async () => {
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -515,7 +521,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
await renderApps();
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete GitHub connection"]',
'button[aria-label="Delete GitHub for the company connection"]',
);
await act(async () => {
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));

View File

@ -33,10 +33,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { AppLogo } from "./AppLogo";
import { ComposioProvenanceChip } from "./ComposioProvenanceChip";
import { ConnectionProvenanceChip } from "./ComposioProvenanceChip";
import { composioChildParentConnectionId } from "./composio-services";
import {
appApplicationSourceSlug,
appDefinitionDarkLogoUrl,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
@ -44,6 +45,7 @@ import {
} from "./app-definition-display";
import { useReviewCount } from "./useReviewCount";
import { AdvancedToolsLink } from "./store-cards";
import { connectionNameForCredentialPolicy, connectionTypeLabel } from "./connection-identity";
import {
ConnectionOwnerIdentity,
connectionDisplayNameForOwner,
@ -71,6 +73,7 @@ type AppRow = {
actionCount: number;
lastUsedAt: Date | string | null;
logoUrl?: string | null;
darkLogoUrl?: string | null;
};
/**
@ -232,10 +235,11 @@ export function Connections() {
return applications.flatMap((application): AppRow[] => {
const appConnections = connectionsByApplication.get(application.id) ?? [];
const appSourceSlug = appApplicationSourceSlug(application);
const galleryEntry = logoByKey.get(appSourceSlug ?? "") ??
const resolvedGalleryEntry = logoByKey.get(appSourceSlug ?? "") ??
logoByName.get(application.name.toLowerCase());
const logoUrl = appDefinitionLogoUrl(galleryEntry);
const logoUrl = appDefinitionLogoUrl(resolvedGalleryEntry);
const brandKey = appSourceSlug ?? application.name;
const darkLogoUrl = appDefinitionDarkLogoUrl(resolvedGalleryEntry);
const agentAvailableConnectionCount = appConnections.filter(
(connection) => connection.status === "active" && connection.enabled,
).length;
@ -251,14 +255,22 @@ export function Connections() {
actionCount: 0,
lastUsedAt: null,
logoUrl,
darkLogoUrl,
}];
}
return appConnections.map((connection) => {
const owner = connectionOwnerProfile(connection, userProfileById);
const type = connectionTypeLabel(connection.credentialPolicy);
const displayName = type === "Company"
? connectionNameForCredentialPolicy(
humanizeConnectionDisplayName(connection),
connection.credentialPolicy,
)
: connectionDisplayNameForOwner(connection, application.name, owner);
return {
application,
connection,
displayName: connectionDisplayNameForOwner(connection, application.name, owner),
displayName,
brandKey,
owner,
remainingAgentAvailableConnectionCount: Math.max(
@ -270,6 +282,7 @@ export function Connections() {
actionCount: actionCountByConnection.get(`app:${connection.id}`) ?? 0,
lastUsedAt: connection.lastUsedAt ?? null,
logoUrl,
darkLogoUrl,
};
});
});
@ -362,6 +375,7 @@ export function Connections() {
<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">Connection</th>
<th className="px-4 py-2.5">Type</th>
<th className="px-4 py-2.5">Connected by</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Actions</th>
@ -408,12 +422,13 @@ export function Connections() {
name={row.displayName}
brandKey={row.brandKey}
logoUrl={row.logoUrl}
darkLogoUrl={row.darkLogoUrl}
size={32}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{row.displayName}</span>
<ComposioProvenanceChip connection={row.connection} />
<ConnectionProvenanceChip connection={row.connection} />
</div>
{hint && (
<div className="truncate text-xs text-muted-foreground">{hint}</div>
@ -421,6 +436,11 @@ export function Connections() {
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="text-xs font-medium text-foreground">
{connection ? connectionTypeLabel(connection.credentialPolicy) : "—"}
</span>
</td>
<td className="px-4 py-3">
<ConnectionOwnerIdentity owner={row.owner} />
</td>

View File

@ -8,12 +8,14 @@ function source(relativePath: string) {
describe("Apps agent selector contract", () => {
it("keeps every agent chooser under /apps searchable", () => {
const appConnect = source("./AppsConnect.tsx");
const connectionSetupFlow = source("../../features/connections/ConnectionSetupFlow.tsx");
const permissions = source("./app-detail/PermissionsPanel.tsx");
const tester = source("./app-detail/TestPanel.tsx");
const profiles = source("../tools/ProfilesTab.tsx");
const audit = source("../tools/AuditTab.tsx");
expect(appConnect).toContain("<AgentMultiSelect");
expect(appConnect).toContain("<ConnectionSetupFlow");
expect(connectionSetupFlow).toContain("<AgentMultiSelect");
expect(permissions).toContain("<AgentMultiSelect");
expect(tester).toContain('placeholder="Search agents…"');

View File

@ -1,30 +1,73 @@
import { APP_STORE_DEFINITIONS, appSupportsCatalogSetup } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import {
MCP_DIRECT_OAUTH_CONNECT_SLUGS,
appSourceConnectHref,
appSourceResumeHref,
canEnterAppsConnect,
isMcpDirectOAuthConnectSlug,
resolveAppsConnectRouteKey,
vercelConnectSourceHref,
} from "./app-connect-policy";
describe("app connect policy", () => {
it("allowlists exactly Notion for MCP-direct OAuth", () => {
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(["notion"]);
it("derives automatic OAuth entry points from app capabilities", () => {
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(expect.arrayContaining(["jira", "notion", "sentry"]));
expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true);
expect(isMcpDirectOAuthConnectSlug("jira")).toBe(true);
expect(isMcpDirectOAuthConnectSlug("asana")).toBe(false);
expect(isMcpDirectOAuthConnectSlug("github")).toBe(false);
expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false);
expect(isMcpDirectOAuthConnectSlug(null)).toBe(false);
});
it("admits the Notion deep link without opening other source slugs", () => {
it("admits every capability-backed catalog deep link", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=notion"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=jira"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=asana"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(true);
});
it("admits retained hidden-provider reconnects without opening fresh setup", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=github&reconnect=connection-1"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown&reconnect=connection-1"))).toBe(false);
});
it("builds a generic source deep link", () => {
expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion");
expect(appSourceConnectHref("notion", "intent-1"))
.toBe("/apps/connect?source=notion&intent=intent-1");
expect(appSourceResumeHref("notion", "11111111-1111-4111-8111-111111111111")).toBe(
"/apps/connect?source=notion&resume=11111111-1111-4111-8111-111111111111",
);
expect(vercelConnectSourceHref()).toBe("/apps/vercel-connect");
expect(vercelConnectSourceHref("notion")).toBe("/apps/vercel-connect?source=notion");
});
it("routes every capability-backed catalog definition through its source deep link", () => {
const connectableApps = APP_STORE_DEFINITIONS.filter(appSupportsCatalogSetup);
expect(connectableApps.length).toBeGreaterThan(0);
for (const app of connectableApps) {
const href = appSourceConnectHref(app.slug);
const searchParams = new URL(href, "http://paperclip.test").searchParams;
expect(canEnterAppsConnect(searchParams), app.slug).toBe(true);
expect(resolveAppsConnectRouteKey({ sourceSlug: searchParams.get("source") }), app.slug).toBe(app.slug);
}
});
it("preserves explicit route precedence while accepting every authentication mode", () => {
expect(resolveAppsConnectRouteKey({ serviceSlug: "jira", appKey: "asana", sourceSlug: "mem0" })).toBe("jira");
expect(resolveAppsConnectRouteKey({ appKey: "asana", sourceSlug: "mem0" })).toBe("asana");
expect(resolveAppsConnectRouteKey({ sourceSlug: "mem0" })).toBe("mem0");
expect(resolveAppsConnectRouteKey({ sourceSlug: "context7" })).toBe("context7");
expect(resolveAppsConnectRouteKey({ sourceSlug: "supabase" })).toBe("supabase");
expect(resolveAppsConnectRouteKey({})).toBeUndefined();
});
});

View File

@ -1,5 +1,17 @@
/** OAuth apps that are safe to connect directly through the MCP OAuth broker. */
export const MCP_DIRECT_OAUTH_CONNECT_SLUGS = ["notion"] as const;
import {
APP_STORE_DEFINITIONS,
appSupportsCatalogSetup,
connectionMethodSupportsAutomaticOAuth,
getAvailableConnectionMethods,
getAppStoreDefinition,
getConnectableAppDefinition,
} from "@paperclipai/shared";
export const MCP_DIRECT_OAUTH_CONNECT_SLUGS = APP_STORE_DEFINITIONS
.filter((app) => getAvailableConnectionMethods(app).some((method) =>
connectionMethodSupportsAutomaticOAuth(method)
))
.map((app) => app.slug);
export function isMcpDirectOAuthConnectSlug(slug: string | null | undefined): boolean {
return MCP_DIRECT_OAUTH_CONNECT_SLUGS.some((allowedSlug) => allowedSlug === slug);
@ -11,6 +23,31 @@ export function appSourceConnectHref(slug: string, interactionId?: string | null
return `/apps/connect?${params.toString()}`;
}
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
return searchParams.get("byo") === "1" || isMcpDirectOAuthConnectSlug(searchParams.get("source"));
/** Resume one exact draft through the same setup wizard used for a new app. */
export function appSourceResumeHref(slug: string, connectionId: string): string {
return `/apps/connect?${new URLSearchParams({ source: slug, resume: connectionId }).toString()}`;
}
export function vercelConnectSourceHref(slug?: string): string {
if (!slug) return "/apps/vercel-connect";
return `/apps/vercel-connect?${new URLSearchParams({ source: slug }).toString()}`;
}
export function resolveAppsConnectRouteKey(input: {
serviceSlug?: string | null;
appKey?: string | null;
sourceSlug?: string | null;
}): string | undefined {
return input.serviceSlug ?? input.appKey ?? input.sourceSlug ?? undefined;
}
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
if (searchParams.get("byo") === "1") return true;
const source = searchParams.get("source") ?? "";
const entry = getAppStoreDefinition(source);
// A retained connection may belong to a provider hidden from fresh catalog
// setup. Admit only known providers here; the setup flow then proves the
// exact reconnect target is visible to the selected company before rendering.
if (getConnectableAppDefinition(source) && searchParams.get("reconnect")?.trim()) return true;
return appSupportsCatalogSetup(entry);
}

View File

@ -23,6 +23,10 @@ export function appDefinitionLogoUrl(entry: AppGalleryDisplayEntry | null | unde
return entry?.branding?.logoUrl ?? entry?.logoUrl;
}
export function appDefinitionDarkLogoUrl(entry: AppGalleryDisplayEntry | null | undefined): string | undefined {
return entry?.branding?.darkLogoUrl;
}
export function appApplicationSourceSlug(application: ToolApplication | null | undefined): string | null {
if (!application) return null;
const metadata = application.metadata;

View File

@ -74,9 +74,9 @@ function RecentActivity({
}, [events, lifecycleEvents, issues, actionRequests, nameById, connectionId, appName, userLabelById]);
return (
<section className="space-y-2">
<section className="space-y-5">
<div>
<h2 className="text-sm font-bold text-foreground">Recent activity</h2>
<h2 className="text-lg font-semibold text-foreground">Recent activity</h2>
</div>
{loading ? (
<div className="space-y-2 py-4">

View File

@ -37,24 +37,40 @@ function renderComposioDangerZone() {
return container;
}
function expandDangerZone(node: HTMLDivElement) {
const trigger = Array.from(node.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Danger zone"));
expect(trigger).toBeTruthy();
act(() => trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
/**
* Remove app deletes the operator's credentials and revokes agent access
* (PAP-17119). The confirmation has to say so before the operator commits a
* "you can connect it again later" reassurance implies the pasted key survives,
* so these assertions exist to stop that copy coming back.
* (PAP-17119). The compact confirmation still names both effects before the
* operator commits.
*/
describe("DangerZone", () => {
it("promises credential deletion and re-authentication before the operator confirms", () => {
const text = renderDangerZone().textContent ?? "";
it("keeps dangerous actions folded by default", () => {
const node = renderDangerZone();
expect(text).toContain("Deletes the saved credentials for PostHog");
expect(text).toContain("takes agent access away right away");
expect(text).toContain("needs a new sign-in or key");
expect(text).not.toContain("You can connect it again later");
expect(node.textContent).toContain("Danger zone");
expect(node.textContent).not.toContain("Remove app");
expect(node.textContent).not.toContain("Deletes credentials");
});
it("promises credential deletion and re-authentication before the operator confirms", () => {
const node = renderDangerZone();
expandDangerZone(node);
const text = node.textContent ?? "";
expect(text).toContain("Deletes credentials for PostHog");
expect(text).toContain("removes agent access");
expect(text).toContain("requires a new sign-in or key");
});
it("keeps the warning visible in the confirming state", () => {
const node = renderDangerZone();
expandDangerZone(node);
const trigger = Array.from(node.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Remove app");
expect(trigger).toBeTruthy();
@ -63,13 +79,13 @@ describe("DangerZone", () => {
const text = node.textContent ?? "";
expect(text).toContain("Yes, remove it");
expect(text).toContain("Deletes the saved credentials for PostHog");
expect(text).toContain("needs a new sign-in or key");
expect(text).toContain("Deletes credentials for PostHog");
expect(text).toContain("requires a new sign-in or key");
});
it("names every child service that parent removal will take down", () => {
const node = renderComposioDangerZone();
expect(node.textContent).toContain("removes 2 connected services");
expect(node.textContent).toContain("Agents lose access to all of them right away");
expandDangerZone(node);
expect(node.textContent).toContain("2 connected services");
});
});

View File

@ -1,16 +1,29 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ArrowUpRight, Loader2, Lock } from "lucide-react";
import type { AppDefinition, ToolConnection } from "@paperclipai/shared";
import { ArrowUpRight, ChevronRight, Loader2, Lock } from "lucide-react";
import type {
AppDefinition,
ConnectionGrant,
ToolConnection,
ToolConnectionCredentialPolicy,
} from "@paperclipai/shared";
import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useToast } from "@/context/ToastContext";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { cn } from "@/lib/utils";
import type { AppDetailSectionProps } from "./types";
import { RevokeGrantDialog } from "./IdentitiesSection";
export function AdvancedPanel({
connection,
@ -20,21 +33,56 @@ export function AdvancedPanel({
removing,
onRemove,
onReplaced,
canReplaceCredential = true,
credentialUnavailableMessage = "You don't have permission to replace this identity's credential.",
appToggleDisabled,
onToggleApp,
identityGrant = null,
identityCurrentUserId = null,
identityProviderName,
credentialPolicy,
identityActionPending = false,
onReconnectIdentity,
onRevokeIdentity,
}: Pick<AppDetailSectionProps, "connection" | "appName" | "galleryEntry"> & {
removing: boolean;
childConnectionCount?: number;
onRemove: () => void;
onReplaced: () => void;
canReplaceCredential?: boolean;
credentialUnavailableMessage?: string;
appToggleDisabled: boolean;
onToggleApp: () => void;
identityGrant?: ConnectionGrant | null;
identityCurrentUserId?: string | null;
identityProviderName?: string;
credentialPolicy?: ToolConnectionCredentialPolicy;
identityActionPending?: boolean;
onReconnectIdentity?: () => void;
onRevokeIdentity?: (grant: ConnectionGrant) => void;
}) {
return (
<div className="space-y-6">
<KeySection connection={connection} galleryEntry={galleryEntry} onReplaced={onReplaced} />
<div className="space-y-4 border-t border-border pt-8">
<TechnicalDetails connection={connection} />
<DangerZone
appName={appName}
connection={connection}
galleryEntry={galleryEntry}
childConnectionCount={childConnectionCount}
removing={removing}
onRemove={onRemove}
onReplaced={onReplaced}
canReplaceCredential={canReplaceCredential}
credentialUnavailableMessage={credentialUnavailableMessage}
toggleDisabled={appToggleDisabled}
onToggleConnection={onToggleApp}
identityGrant={identityGrant}
identityCurrentUserId={identityCurrentUserId}
identityProviderName={identityProviderName ?? appName}
credentialPolicy={credentialPolicy}
identityActionPending={identityActionPending}
onReconnectIdentity={onReconnectIdentity}
onRevokeIdentity={onRevokeIdentity}
/>
</div>
);
@ -44,10 +92,14 @@ function KeySection({
connection,
galleryEntry,
onReplaced,
canReplace,
unavailableMessage,
}: {
connection: ToolConnection;
galleryEntry: AppDefinition | null;
onReplaced: () => void;
canReplace: boolean;
unavailableMessage: string;
}) {
const [open, setOpen] = useState(false);
return (
@ -56,15 +108,15 @@ function KeySection({
<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.
<h2 className="text-sm font-medium text-foreground">Reconnect</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{canReplace ? "Replace the stored credential." : unavailableMessage}
</p>
</div>
</div>
{!open && (
{canReplace && !open && (
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
Replace key
Reconnect
</Button>
)}
</div>
@ -89,14 +141,23 @@ export function ReconnectCard({
connection,
galleryEntry,
onReconnected,
canReconnect = true,
reconnectUnavailableMessage,
}: {
connection: ToolConnection;
galleryEntry: AppDefinition | null;
onReconnected: () => void;
canReconnect?: boolean;
reconnectUnavailableMessage?: string;
}) {
const { pushToast } = useToast();
const reconnectOAuth = useMutation({
mutationFn: () => toolsApi.startOAuth(connection.id),
// Reconnect is not a new identity choice. Personal-only connections must
// put the replacement token back on the signed-in user's existing grant;
// shared and legacy fallback connections keep using the organization slot.
mutationFn: () => connection.credentialPolicy === "per_user"
? toolsApi.startOAuth(connection.id, { asCurrentUser: true })
: toolsApi.startOAuth(connection.id),
onSuccess: ({ authorizationUrl }) => {
// Reconnect navigates to the same discovered address a fresh connect does,
// so it goes through the same gate (PAP-17099).
@ -114,20 +175,60 @@ export function ReconnectCard({
tone: "error",
}),
});
const verifyVercel = useMutation({
mutationFn: () => toolsApi.checkConnectionHealth(connection.id),
onSuccess: () => {
pushToast({
title: "Vercel credential verified",
body: `${humanizeConnectionDisplayName(connection)} is back online.`,
tone: "success",
});
onReconnected();
},
onError: (error) => pushToast({
title: "Credential still needs attention",
body: error instanceof Error ? error.message : "Review the connector in Vercel Connect and try again.",
tone: "error",
}),
});
const oauth = connection.authKind === "oauth";
const managedByVercel = connection.credentialSource === "vercel_connect";
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">
{oauth ? "Reconnect required" : "This app needs reconnecting"}
</h2>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
{connection.healthMessage?.trim() || (oauth
? "Authorization expired or was revoked. Sign in again to restore access."
: "The key stopped working. Paste a new one to get it back online.")}
</p>
<div className="mt-3">
{oauth ? (
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{oauth ? "Reconnect required" : "This app needs reconnecting"}
</h2>
<p className="mt-0.5 text-sm text-amber-800 dark:text-amber-200">
{connection.healthMessage?.trim() || (oauth
? "Authorization expired or was revoked. Sign in again to restore access."
: "The key stopped working. Paste a new one to get it back online.")}
</p>
</div>
<div className="shrink-0">
{!canReconnect ? (
<p className="text-sm text-amber-800 dark:text-amber-200">
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
</p>
) : managedByVercel && !oauth ? (
<div className="flex items-center gap-2">
<Button type="button" size="sm" variant="outline" asChild>
<a href="https://vercel.com/connect" target="_blank" rel="noreferrer">
Manage in Vercel <ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</a>
</Button>
<Button
type="button"
size="sm"
disabled={verifyVercel.isPending}
onClick={() => verifyVercel.mutate()}
>
{verifyVercel.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Check again
</Button>
</div>
) : oauth ? (
<Button
type="button"
size="sm"
@ -209,6 +310,14 @@ function ReconnectForm({
? fields.every((f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0)
: single.trim().length > 0;
if (connection.credentialSource === "vercel_connect") {
return (
<p className="text-sm text-muted-foreground">
Credentials for this connection are managed in Vercel Connect.
</p>
);
}
return (
<div className="space-y-3">
{usesGallery ? (
@ -261,62 +370,214 @@ function ReconnectForm({
}
function TechnicalDetails({ connection }: { connection: ToolConnection }) {
const [open, setOpen] = useState(false);
return (
<section>
<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>
<Collapsible open={open} onOpenChange={setOpen} asChild>
<section>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center gap-3 py-1 text-left">
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">Connection details</span>
<ChevronRight
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<dl className="mt-4 grid gap-2 pb-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">Type</dt>
<dd className="text-foreground">{connectionTransportLabel(connection.transport)}</dd>
</dl>
</CollapsibleContent>
</section>
</Collapsible>
);
}
export function DangerZone({
appName,
connection,
galleryEntry = null,
childConnectionCount = 0,
removing,
onRemove,
onReplaced,
canReplaceCredential = true,
credentialUnavailableMessage = "You don't have permission to replace this identity's credential.",
toggleDisabled = false,
onToggleConnection,
identityGrant = null,
identityCurrentUserId = null,
identityProviderName = appName,
credentialPolicy,
identityActionPending = false,
onReconnectIdentity,
onRevokeIdentity,
}: {
appName: string;
connection?: ToolConnection;
galleryEntry?: AppDefinition | null;
childConnectionCount?: number;
removing: boolean;
onRemove: () => void;
onReplaced?: () => void;
canReplaceCredential?: boolean;
credentialUnavailableMessage?: string;
toggleDisabled?: boolean;
onToggleConnection?: () => void;
identityGrant?: ConnectionGrant | null;
identityCurrentUserId?: string | null;
identityProviderName?: string;
credentialPolicy?: ToolConnectionCredentialPolicy;
identityActionPending?: boolean;
onReconnectIdentity?: () => void;
onRevokeIdentity?: (grant: ConnectionGrant) => void;
}) {
const [open, setOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<ConnectionGrant | null>(null);
const paused = connection
? connection.enabled === false || connection.status === "disabled"
: false;
return (
<section className="space-y-3">
<div className="text-sm font-bold text-destructive">
Danger zone
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">Remove this app</p>
<p className="text-xs text-muted-foreground">
{childConnectionCount > 0
? `Deletes the saved credentials for ${appName} and removes ${childConnectionCount} connected ${childConnectionCount === 1 ? "service" : "services"}. Agents lose access to all of them right away.`
: `Deletes the saved credentials for ${appName} and takes agent access away right away. Connecting it again later needs a new sign-in or key.`}
</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>
<Collapsible
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setConfirming(false);
}}
asChild
>
<section>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 py-1 text-left"
>
<span className="min-w-0 flex-1 text-sm font-medium text-destructive">Danger zone</span>
<ChevronRight
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-3 divide-y divide-border border-t border-border">
{connection && onToggleConnection ? (
<div className="flex items-center justify-between gap-4 py-4">
<h2 className="text-sm font-medium text-foreground">Pause connection</h2>
<ToggleSwitch
aria-label="Pause connection"
checked={paused}
disabled={toggleDisabled}
onCheckedChange={onToggleConnection}
size="lg"
/>
</div>
) : null}
{connection && connection.authKind !== "oauth" ? (
<div className="py-4">
<KeySection
connection={connection}
galleryEntry={galleryEntry}
onReplaced={onReplaced ?? (() => undefined)}
canReplace={canReplaceCredential}
unavailableMessage={credentialUnavailableMessage}
/>
</div>
) : null}
{connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? (
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="text-sm font-medium text-foreground">Reconnect</p>
<p className="text-xs text-muted-foreground">
{canReplaceCredential
? `Sign in to ${identityProviderName} again.`
: credentialUnavailableMessage}
</p>
</div>
{canReplaceCredential && onReconnectIdentity ? (
<Button
size="sm"
variant="outline"
disabled={identityActionPending}
onClick={onReconnectIdentity}
>
{identityActionPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Reconnect
</Button>
) : null}
</div>
) : null}
{identityGrant?.capabilities?.canRevoke
&& identityGrant.status !== "revoked"
&& onRevokeIdentity ? (
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="text-sm font-medium text-foreground">Revoke identity</p>
<p className="text-xs text-muted-foreground">
Disconnect the identity currently used by this app.
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setRevokeTarget(identityGrant)}
>
Revoke
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="text-sm font-medium text-foreground">Remove this app</p>
<p className="text-xs text-muted-foreground">
{childConnectionCount > 0
? `Deletes credentials for ${appName} and ${childConnectionCount} connected ${childConnectionCount === 1 ? "service" : "services"}.`
: `Deletes credentials for ${appName} and removes agent access. Reconnecting requires a new sign-in or key.`}
</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>
</div>
) : (
<Button variant="destructive" size="sm" onClick={() => setConfirming(true)}>
Remove app
</Button>
)}
</div>
</section>
</CollapsibleContent>
{revokeTarget && credentialPolicy ? (
<RevokeGrantDialog
grant={revokeTarget}
providerName={identityProviderName}
pending={identityActionPending}
credentialPolicy={credentialPolicy}
isOwnIdentity={revokeTarget.kind === "user" && revokeTarget.subjectUserId === identityCurrentUserId}
onCancel={() => setRevokeTarget(null)}
onConfirm={() => {
onRevokeIdentity?.(revokeTarget);
setRevokeTarget(null);
}}
/>
) : null}
</section>
</Collapsible>
);
}

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ChevronRight, Loader2 } from "lucide-react";
import { Building2, Loader2, UserRound } from "lucide-react";
import type {
ConnectionAudienceMember,
ConnectionGrant,
@ -7,10 +7,10 @@ import type {
ToolConnectionCredentialPolicy,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Identity } from "@/components/Identity";
import { Skeleton } from "@/components/ui/skeleton";
import { InlineBanner } from "@/components/InlineBanner";
import { MemberMultiSelect } from "@/components/MemberMultiSelect";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { RadioCardGroup } from "@/components/ui/radio-card";
import {
Dialog,
@ -33,14 +33,12 @@ import {
import { cn } from "@/lib/utils";
import { brandChipBadge } from "@/lib/status-colors";
import {
audienceSummary,
audienceUserIds,
grantAccountLabel,
grantStatusLabel,
grantStatusTone,
memberLabel,
organizationGrant,
otherPersonalGrants,
personalGrantFor,
type GrantStatusTone,
} from "../connection-identity";
@ -66,69 +64,43 @@ function StatusText({ status }: { status: ConnectionGrant["status"] | null }) {
);
}
function formatLastUsed(value: ConnectionGrant["lastUsedAt"]): string | null {
if (!value) return null;
const parsed = value instanceof Date ? value : new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
return `Last used ${parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
}
/**
* Identity rows for the connection Setup tab (PAP-17835 Surface B).
* Fixed identity for the connection Setup tab.
*
* Rows are separated by space and a rule, not wrapped in one card each: "space
* separates; lines contain". Every action here is rendered from a server
* capability a policy-forbidden action is absent rather than disabled, so a
* viewer sees the same legible state with no controls at all.
* The chosen personal/organization type comes from the connection policy and
* the alternative is not rendered after setup. Every action is rendered from
* a server capability a policy-forbidden action is absent rather than
* disabled, so a viewer sees the same legible state with no controls at all.
*/
export function IdentitiesSection({
appName,
providerName,
credentialPolicy,
ownerUserId,
connectedUser,
grantsQuery,
agents,
agentsLoading,
agentsError,
loading,
error,
onConnectAsMe,
onConnectOrganization,
onReconnectOrganization,
onRevokeGrant,
onReplaceAudience,
connectPending,
revokePending,
delegationPending,
audiencePending,
audienceError,
audienceGrantId,
onOpenAudience,
onCloseAudience,
onReplaceDelegations,
}: {
appName: string;
providerName: string;
credentialPolicy: ToolConnectionCredentialPolicy;
ownerUserId: string | null;
connectedUser: { label: string; image: string | null } | null;
grantsQuery: ConnectionGrantsResponse | undefined;
agents: Array<{
id: string;
name: string;
title?: string | null;
icon?: string | null;
status: string;
}>;
agentsLoading: boolean;
agentsError: boolean;
loading: boolean;
error: boolean;
onConnectAsMe: () => void;
onConnectOrganization: () => void;
onReconnectOrganization: () => void;
onRevokeGrant: (grant: ConnectionGrant) => void;
onReplaceAudience: (grant: ConnectionGrant, memberUserIds: string[]) => void;
connectPending: boolean;
revokePending: boolean;
delegationPending: boolean;
audiencePending: boolean;
audienceError: string | null;
/**
@ -139,36 +111,43 @@ export function IdentitiesSection({
audienceGrantId: string | null;
onOpenAudience: (grantId: string) => void;
onCloseAudience: () => void;
onReplaceDelegations: (grant: ConnectionGrant, agentIds: string[]) => void;
}) {
const [revokeTarget, setRevokeTarget] = useState<ConnectionGrant | null>(null);
const [othersExpanded, setOthersExpanded] = useState(false);
const grants = grantsQuery?.grants ?? [];
const capabilities = grantsQuery?.capabilities;
const currentUserId = grantsQuery?.currentUserId ?? null;
const members = grantsQuery?.members ?? [];
const orgGrant = useMemo(() => organizationGrant(grants), [grants]);
const myGrant = useMemo(() => personalGrantFor(grants, currentUserId), [grants, currentUserId]);
const others = useMemo(() => otherPersonalGrants(grants, currentUserId), [grants, currentUserId]);
const myLabel = memberLabel(members, currentUserId);
const personalGrant = useMemo(() => {
const personalGrants = grants.filter((grant) => grant.kind === "user");
return personalGrants.find((grant) => grant.subjectUserId === ownerUserId)
?? myGrant
?? personalGrants.find((grant) => grant.status === "active")
?? personalGrants[0]
?? null;
}, [grants, myGrant, ownerUserId]);
const personalSubjectLabel = memberLabel(
members,
personalGrant?.subjectUserId ?? ownerUserId ?? currentUserId,
);
const usesPersonalIdentity = credentialPolicy === "per_user"
|| (credentialPolicy === "per_user_with_fallback" && Boolean(myGrant));
const audienceGrant = audienceGrantId
? grants.find((grant) => grant.id === audienceGrantId) ?? null
: null;
if (loading) {
return (
<section className="space-y-3" aria-busy="true">
<section className="space-y-5" aria-busy="true">
<IdentitiesHeading />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</section>
);
}
if (error) {
return (
<section className="space-y-3">
<section className="space-y-5">
<IdentitiesHeading />
<InlineBanner tone="warning" compact>
We couldn't load who this connection acts as. Reload the page to try again.
@ -178,157 +157,55 @@ export function IdentitiesSection({
}
return (
<section>
<section className="space-y-5">
<IdentitiesHeading />
{agentsError ? (
<div className="mt-3">
<InlineBanner tone="warning" compact>
We couldn't load agents for autonomous access. Reload the page to try again.
</InlineBanner>
</div>
) : null}
<ConnectionAudienceCallout
personal={usesPersonalIdentity}
connectedName={usesPersonalIdentity ? personalSubjectLabel ?? connectedUser?.label ?? null : null}
connectedImage={usesPersonalIdentity ? connectedUser?.image ?? null : null}
status={(usesPersonalIdentity ? personalGrant : orgGrant)?.status ?? null}
/>
<div className="mt-4 divide-y divide-border">
{/* Organization identity always visible, including when missing, so the
shared-vs-personal distinction never has to be inferred from absence. */}
<IdentityRow
title="Organization identity"
secondary={grantAccountLabel(orgGrant)}
status={orgGrant?.status ?? null}
detail={orgGrant
? audienceSummary(orgGrant)
: "Used when the connection policy allows a shared identity."}
actions={
<>
{orgGrant?.capabilities?.canEditAudience ? (
<Button size="sm" variant="outline" onClick={() => onOpenAudience(orgGrant.id)}>
Manage audience
</Button>
) : null}
{orgGrant && capabilities?.canConfigure ? (
<Button
size="sm"
variant="outline"
disabled={connectPending}
onClick={onReconnectOrganization}
>
Reconnect
</Button>
) : null}
{orgGrant?.capabilities?.canRevoke && orgGrant.status !== "revoked" ? (
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(orgGrant)}>
Revoke
</Button>
) : null}
{!orgGrant && capabilities?.canCreateOrganizationGrant ? (
<Button size="sm" disabled={connectPending} onClick={onConnectOrganization}>
Connect organization identity
</Button>
) : null}
</>
}
/>
{/* Your identity the signed-in user only. Audience is never shown here:
a personal identity is consent-bound to the person who granted it. */}
{capabilities?.canConnectAsCurrentUser || myGrant ? (
<IdentityRow
id="personal-identity"
title="Your identity"
secondary={myGrant ? grantAccountLabel(myGrant, { subjectLabel: myLabel }) : null}
status={myGrant?.status ?? null}
detail={myGrant
? formatLastUsed(myGrant.lastUsedAt) ?? "Only work running for you can use this identity."
: "You have not connected your account."}
actions={
<>
{myGrant && myGrant.status !== "revoked" ? (
<>
<Button size="sm" variant="outline" disabled={connectPending} onClick={onConnectAsMe}>
Reconnect
</Button>
{myGrant.capabilities?.canRevoke ? (
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(myGrant)}>
Revoke
</Button>
) : null}
{myGrant.status === "active" ? (
<AgentMultiSelect
agents={agents.filter((agent) => agent.status !== "terminated")}
loading={agentsLoading}
selectedAgentIds={new Set(
(myGrant.delegations ?? []).map((delegation) => delegation.agentId),
)}
pending={delegationPending}
triggerLabel={(myGrant.delegations?.length ?? 0) === 0
? "Allow autonomous access"
: `${myGrant.delegations?.length ?? 0} ${myGrant.delegations?.length === 1 ? "agent" : "agents"} allowed for autonomous runs`}
triggerSize="sm"
triggerFullWidth={false}
showSelectionPreview={false}
headerContent={(
<p className="mt-2 text-xs text-muted-foreground">
Select the named agents that may use your identity in autonomous runs.
</p>
)}
onSave={(agentIds) => onReplaceDelegations(myGrant, [...agentIds])}
/>
) : null}
</>
) : capabilities?.canConnectAsCurrentUser ? (
<div>
{usesPersonalIdentity ? (
personalGrant ? null : (
<IdentityRow
id="personal-identity"
title="Personal account"
status={null}
detail="Personal identity"
actions={capabilities?.canConnectAsCurrentUser ? (
<Button size="sm" disabled={connectPending} onClick={onConnectAsMe}>
{connectPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Connect as me
</Button>
) : null}
</>
}
/>
) : null}
</div>
{/* Manager oversight. A regular member never sees this list the server
omits the grants entirely, so there is nothing to hide client-side. */}
{capabilities?.canViewOtherPersonalIdentities && others.length > 0 ? (
<div className="mt-4 border-t border-border pt-3">
<button
type="button"
className="flex w-full items-center gap-1.5 text-left text-sm text-muted-foreground hover:text-foreground"
aria-expanded={othersExpanded}
onClick={() => setOthersExpanded((open) => !open)}
>
<span className="flex-1">Other personal identities · {others.length}</span>
<ChevronRight
className={cn("h-4 w-4 transition-transform", othersExpanded && "rotate-90")}
/>
</button>
{othersExpanded ? (
<div className="mt-2 divide-y divide-border">
{others.map((grant) => (
<div key={grant.id} className="flex flex-wrap items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-foreground">
{grantAccountLabel(grant, {
subjectLabel: memberLabel(members, grant.subjectUserId),
})}
</div>
{formatLastUsed(grant.lastUsedAt) ? (
<div className="text-xs text-muted-foreground">{formatLastUsed(grant.lastUsedAt)}</div>
) : null}
</div>
<StatusText status={grant.status} />
{grant.capabilities?.canRevoke && grant.status !== "revoked" ? (
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(grant)}>
Revoke
</Button>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
)
) : (
orgGrant ? (
orgGrant.capabilities?.canEditAudience ? (
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onOpenAudience(orgGrant.id)}>
Manage access
</Button>
</div>
) : null
) : (
<IdentityRow
title="Organization account"
status={null}
detail="Organization identity"
actions={capabilities?.canCreateOrganizationGrant ? (
<Button size="sm" disabled={connectPending} onClick={onConnectOrganization}>
Connect organization identity
</Button>
) : null}
/>
)
)}
</div>
{audienceGrant ? (
<AudienceDialog
@ -342,31 +219,44 @@ export function IdentitiesSection({
/>
) : null}
{revokeTarget ? (
<RevokeGrantDialog
grant={revokeTarget}
providerName={providerName}
pending={revokePending}
credentialPolicy={credentialPolicy}
isOwnIdentity={revokeTarget.kind === "user" && revokeTarget.subjectUserId === currentUserId}
onCancel={() => setRevokeTarget(null)}
onConfirm={() => {
onRevokeGrant(revokeTarget);
setRevokeTarget(null);
}}
/>
) : null}
</section>
);
}
function IdentitiesHeading() {
return <h2 className="text-sm font-semibold text-foreground">Account</h2>;
}
function ConnectionAudienceCallout({
personal,
connectedName,
connectedImage,
status,
}: {
personal: boolean;
connectedName: string | null;
connectedImage: string | null;
status: ConnectionGrant["status"] | null;
}) {
const Icon = personal ? UserRound : Building2;
return (
<div>
<h2 className="text-sm font-bold text-foreground">Identities</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Who agents act as when they use this connection.
</p>
<div className="flex items-start gap-4 rounded-lg border border-border bg-card p-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-foreground">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-3">
<p className="text-lg font-semibold text-foreground">
{personal
? "Only you can use this connection"
: "Anyone in your company can use this connection"}
</p>
{connectedName && status !== null ? (
<div className="flex flex-wrap items-center gap-2">
<Identity name={connectedName} avatarUrl={connectedImage} />
{status === "active" ? null : <StatusText status={status} />}
</div>
) : null}
</div>
</div>
);
}
@ -374,26 +264,23 @@ function IdentitiesHeading() {
function IdentityRow({
id,
title,
secondary,
status,
detail,
actions,
}: {
id?: string;
title: string;
secondary: string | null;
status: ConnectionGrant["status"] | null;
detail: string | null;
actions: ReactNode;
}) {
return (
<div id={id} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div id={id} className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{title}</span>
<StatusText status={status} />
{status === "active" ? null : <StatusText status={status} />}
</div>
{secondary ? <div className="mt-0.5 truncate text-sm text-foreground">{secondary}</div> : null}
{detail ? <div className="mt-0.5 text-xs text-muted-foreground">{detail}</div> : null}
</div>
<div className="flex flex-wrap items-center gap-2">{actions}</div>

View File

@ -9,27 +9,26 @@ import { RadioCardGroup } from "@/components/ui/radio-card";
import { cn } from "@/lib/utils";
import { type InstallState } from "@/lib/tool-installs";
import { QuarantinedActionsReview } from "./SetupPanel";
import type { AppDetailSectionProps } from "./types";
import {
formatActionPermissionSummary,
summarizeActionPermissions,
} from "./action-permission-summary";
import type { AccessDraft, AppDetailSectionProps } from "./types";
type ActionPermission = "off" | "allowed" | "ask";
/**
* Permissions tab.
*
* Agent availability is expressed **once** (PAP-17859). This panel used to
* stack a legacy "Who can use it" editor on top of "Available to agents",
* asking the reader to hold two overlapping models of the same fact and to
* guess which one wins. The install model *Agents I pick / Any agent* is
* now the single visible source of truth.
*
* The runtime distinction survives untouched: an install still authorizes its
* target server-side (`putConnectionInstalls` extends the app profile's
* bindings), so "installed ⊆ permitted" holds without a second editor. What is
* gone is the *user-facing* duplicate, and with it the hidden path that could
* widen access from a control the reader could not see.
* Agent access and installs answer two different questions. Access decides who
* may use the app when work needs it. Installs decide which agents load the app
* on every run. Keeping the sections adjacent makes that distinction explicit
* while preserving the server invariant that installed agents are permitted.
*/
export function PermissionsPanel({
appName,
agents,
access,
install,
readOnly,
canChange,
@ -38,6 +37,7 @@ export function PermissionsPanel({
askFirstIds,
pending,
installPending,
onSaveAccess,
onSaveInstall,
onSetActionPermission,
onReviewQuarantined,
@ -46,10 +46,19 @@ export function PermissionsPanel({
capabilities,
}: Pick<
AppDetailSectionProps,
"agents" | "readOnly" | "canChange" | "quarantined" | "enabledIds" | "askFirstIds" | "pending"
| "appName"
| "agents"
| "access"
| "readOnly"
| "canChange"
| "quarantined"
| "enabledIds"
| "askFirstIds"
| "pending"
> & {
install: InstallState;
installPending: boolean;
onSaveAccess: (next: AccessDraft) => void;
onSaveInstall: (next: InstallState) => void;
onSetActionPermission: (id: string, next: ActionPermission) => void;
onReviewQuarantined: (enabledIds: string[]) => void;
@ -63,14 +72,24 @@ export function PermissionsPanel({
const [searchParams] = useSearchParams();
const focusId = searchParams.get("focus");
return (
<div className="space-y-6">
<AvailableToAgentsSection
<div className="space-y-10">
<AlwaysInstalledSection
appName={appName}
agents={agents}
install={install}
capabilities={capabilities}
disabled={installPending}
onSave={onSaveInstall}
/>
<AgentAccessSection
appName={appName}
agents={agents}
access={access}
install={install}
capabilities={capabilities}
disabled={pending}
onSave={onSaveAccess}
/>
<ActionsSection
readOnly={readOnly}
canChange={canChange}
@ -89,55 +108,46 @@ export function PermissionsPanel({
);
}
/**
* "Available to agents" (PAP-17835 Surface E).
*
* The old section exposed the runtime's own vocabulary "permitted only",
* "installed", an auto-extend warning which asked the reader to hold two
* overlapping concepts to answer one question. It is now the same two-choice
* model the create flow uses: pick agents, or any agent. The runtime
* distinction still exists in code; it just stopped being the user's problem.
*
* Every control is gated on a server capability. A viewer, or a member who may
* not configure this connection, sees the summary and the agent list with no
* controls at all rather than disabled ones.
*/
function AvailableToAgentsSection({
function AgentAccessSection({
appName,
agents,
access,
install,
capabilities,
disabled,
onSave,
}: {
appName: string;
agents: Agent[];
access: AccessDraft;
install: InstallState;
capabilities: ToolConnectionCapabilities | undefined;
disabled: boolean;
onSave: (next: InstallState) => void;
onSave: (next: AccessDraft) => void;
}) {
const liveAgents = agents.filter((a) => a.status !== "terminated");
const canManage = capabilities?.canManageAgentInstalls ?? false;
const canSetCompanyWide = capabilities?.canSetCompanyInstall ?? false;
// "Agents I pick" is scoped to the agents this person may actually edit. The
// server decides that set; the client never infers it from a role string.
const canManage = capabilities?.canConfigure ?? false;
const editableAgentIds = capabilities?.editableAgentIds;
const selectableAgents = editableAgentIds
? liveAgents.filter((agent) => editableAgentIds.includes(agent.id))
: liveAgents;
const mode: "all" | "specific" = install.onAll ? "all" : "specific";
const selectedAgents = liveAgents.filter((agent) => install.agentIds.has(agent.id));
const summary = install.onAll
const selectedAgents = liveAgents.filter((agent) => access.agentIds.has(agent.id));
const requiredAgentIds = install.agentIds;
const summary = access.mode === "all"
? "Any agent"
: install.agentIds.size === 0
? "No agents yet"
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"}`;
: access.agentIds.size === 0
? "No agents"
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"}`;
return (
<section>
<section className="border-t border-border pt-8">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-foreground">Available to agents</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
<h2 className="text-lg font-semibold text-foreground">Agent access</h2>
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
<p className="mt-2 text-sm text-muted-foreground">
Agents that may use {appName} when work needs it.
</p>
</div>
{disabled && <span className="text-xs text-muted-foreground">Saving</span>}
</div>
@ -146,6 +156,128 @@ function AvailableToAgentsSection({
<div className="space-y-3 pt-4">
<RadioCardGroup
ariaLabel="Which agents can use this connection"
value={access.mode}
disabled={disabled}
className="sm:grid-cols-2"
onValueChange={(next) => {
if (next === "all") onSave({ mode: "all", agentIds: new Set() });
else onSave({
mode: "specific",
agentIds: new Set([...access.agentIds, ...requiredAgentIds]),
});
}}
options={[
{
value: "specific",
title: "Agents I pick",
description: install.onAll
? "Unavailable while installed for every agent."
: "Only selected agents.",
disabled: install.onAll,
},
{
value: "all",
title: "Any agent",
description: "Available across your company.",
},
]}
/>
{access.mode === "specific" ? (
<AgentMultiSelect
agents={selectableAgents}
selectedAgentIds={access.agentIds}
disabled={disabled}
triggerLabel={
access.agentIds.size === 0
? "Choose agents"
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} selected`
}
emptyMessage="You cannot edit any agents yet."
isAgentDisabled={(agent) => requiredAgentIds.has(agent.id)}
getDescription={(agent) => requiredAgentIds.has(agent.id) ? "Always installed" : agent.title}
headerContent={requiredAgentIds.size > 0 ? (
<p className="mt-2 text-xs text-muted-foreground">
Always-installed agents keep access.
</p>
) : null}
onChange={(agentIds) => onSave({
mode: "specific",
agentIds: new Set([...agentIds, ...requiredAgentIds]),
})}
/>
) : null}
</div>
) : (
// Read-only: the state is still fully legible, just not editable.
<div className="pt-3">
{access.mode === "all" ? (
<p className="text-sm text-muted-foreground">Every agent can use this connection.</p>
) : selectedAgents.length === 0 ? (
<p className="text-sm text-muted-foreground">No agents can use this connection.</p>
) : (
<div className="space-y-0.5">
{selectedAgents.map((agent) => (
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
</div>
))}
</div>
)}
</div>
)}
</section>
);
}
function AlwaysInstalledSection({
appName,
agents,
install,
capabilities,
disabled,
onSave,
}: {
appName: string;
agents: Agent[];
install: InstallState;
capabilities: ToolConnectionCapabilities | undefined;
disabled: boolean;
onSave: (next: InstallState) => void;
}) {
const liveAgents = agents.filter((agent) => agent.status !== "terminated");
const canManage = capabilities?.canManageAgentInstalls ?? false;
const canSetCompanyWide = capabilities?.canSetCompanyInstall ?? false;
const editableAgentIds = capabilities?.editableAgentIds;
const selectableAgents = editableAgentIds
? liveAgents.filter((agent) => editableAgentIds.includes(agent.id))
: liveAgents;
const selectedAgents = liveAgents.filter((agent) => install.agentIds.has(agent.id));
const mode: "all" | "specific" = install.onAll ? "all" : "specific";
const summary = install.onAll
? "Every agent"
: install.agentIds.size === 0
? "No agents"
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"}`;
return (
<section>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Always installed</h2>
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
<p className="mt-2 text-sm text-muted-foreground">
Loads {appName} on every run. Agent access only makes it available when needed.
</p>
</div>
{disabled && <span className="text-xs text-muted-foreground">Saving</span>}
</div>
{canManage ? (
<div className="space-y-3 pt-4">
<RadioCardGroup
ariaLabel="Which agents always load this connection"
value={mode}
disabled={disabled}
className="sm:grid-cols-2"
@ -157,14 +289,14 @@ function AvailableToAgentsSection({
{
value: "specific",
title: "Agents I pick",
description: "Choose one or more agents you can edit.",
description: "Always loaded for selected agents.",
},
{
value: "all",
title: "Any agent",
title: "Every agent",
description: canSetCompanyWide
? "Make this connection available to every agent."
: "Only someone who can configure this connection can choose this.",
? "Always loaded for current and future agents."
: "Only a connection manager can choose this.",
},
].filter((option) => option.value !== "all" || canSetCompanyWide || install.onAll)}
/>
@ -174,23 +306,20 @@ function AvailableToAgentsSection({
agents={selectableAgents}
selectedAgentIds={install.agentIds}
disabled={disabled}
triggerLabel={
install.agentIds.size === 0
? "Choose agents"
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} selected`
}
triggerLabel={install.agentIds.size === 0
? "Choose agents"
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} selected`}
emptyMessage="You cannot edit any agents yet."
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
/>
) : null}
</div>
) : (
// Read-only: the state is still fully legible, just not editable.
<div className="pt-3">
{install.onAll ? (
<p className="text-sm text-muted-foreground">Every agent can use this connection.</p>
<p className="text-sm text-muted-foreground">This connection is always loaded for every agent.</p>
) : selectedAgents.length === 0 ? (
<p className="text-sm text-muted-foreground">No agents have this connection yet.</p>
<p className="text-sm text-muted-foreground">This connection is not always loaded for any agent.</p>
) : (
<div className="space-y-0.5">
{selectedAgents.map((agent) => (
@ -236,14 +365,16 @@ function ActionsSection({
onRefreshActions: () => void;
}) {
return (
<section className="space-y-3">
<section className="space-y-10 border-t border-border pt-8">
<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">
{canConfigure
? "Choose what agents can do and what needs a human first."
: "What agents can do, and what needs a human first."}
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
<p className="mt-1 text-sm text-muted-foreground">
{formatActionPermissionSummary(summarizeActionPermissions(
[...readOnly, ...canChange],
enabledIds,
askFirstIds,
))}
</p>
</div>
{/* Viewer rule D4: a forbidden action is omitted, not rendered disabled.
@ -278,8 +409,8 @@ function ActionsSection({
)}
<ActionGroup
title="Read only"
hint="Can look up context without changing anything."
title={`Read (${readOnly.length})`}
hint="Views data without changing it."
actions={readOnly}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
@ -289,8 +420,8 @@ function ActionsSection({
onSetPermission={onSetPermission}
/>
<ActionGroup
title="Can make changes"
hint="Can change something in another app."
title={`Write (${canChange.length})`}
hint="Creates or changes data."
actions={canChange}
enabledIds={enabledIds}
askFirstIds={askFirstIds}
@ -339,9 +470,9 @@ function ActionGroup({
if (actions.length === 0) return null;
return (
<div>
<div className="pb-2 text-sm">
<span className="font-bold text-foreground">{title}</span>
<span className="ml-2 text-muted-foreground">- {hint}</span>
<div className="pb-4">
<div className="text-lg font-semibold text-foreground">{title}</div>
<div className="mt-1 text-sm text-muted-foreground">{hint}</div>
</div>
<div className="divide-y divide-border">
{actions.map((action) => {

View File

@ -14,7 +14,8 @@ export function ReviewPanel({
const showsQuarantinedActions = quarantined.length > 0 && !!onReviewQuarantined;
return (
<div className="space-y-4">
<div className="space-y-6">
<h2 className="text-lg font-semibold text-foreground">Review</h2>
{showsQuarantinedActions ? (
<QuarantinedActionsReview
entries={quarantined}

View File

@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -10,33 +11,38 @@ import { googleSheetsConfigWithAllowlist, parseGoogleSheetIds } from "../google-
export function SetupPanel({
connection,
galleryEntry,
onToggleApp,
appToggleDisabled,
onUpdateConfig,
configUpdateDisabled,
identities,
agentsSummary,
permissionsSummary,
permissionsLoading,
onOpenPermissions,
}: Pick<
AppDetailSectionProps,
"connection" | "galleryEntry"
> & {
onToggleApp: () => void;
appToggleDisabled: boolean;
onUpdateConfig: (config: Record<string, unknown>) => void;
configUpdateDisabled: boolean;
/**
* The Identities section (PAP-17835). It replaces the old generic OAuth
* "workspace authorization" block, because that block could only ever describe
* one shared identity and this connection may act as each person instead.
* The fixed Identity section. It replaces the old generic OAuth "workspace
* authorization" block and shows the identity type chosen during setup.
*/
identities?: ReactNode;
agentsSummary: string;
permissionsSummary: string | null;
permissionsLoading: boolean;
onOpenPermissions: () => void;
}) {
const description = galleryEntry?.description ?? null;
return (
<div className="space-y-6">
{description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
)}
<div className="space-y-10">
{identities}
<SetupLinkSection title="Agents" summary={agentsSummary} onClick={onOpenPermissions} />
<SetupLinkSection
title="Actions"
summary={permissionsLoading ? "Loading permissions…" : permissionsSummary ?? "Manage permissions"}
onClick={onOpenPermissions}
/>
{appDefinitionSlug(galleryEntry) === "google-sheets" && (
<GoogleSheetsAllowlistSection
connection={connection}
@ -47,11 +53,34 @@ export function SetupPanel({
{appDefinitionSlug(galleryEntry) === "posthog" && (
<PostHogConfigurationSection connection={connection} />
)}
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
</div>
);
}
function SetupLinkSection({
title,
summary,
onClick,
}: {
title: string;
summary: string;
onClick: () => void;
}) {
return (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<button
type="button"
onClick={onClick}
className="flex w-full items-center justify-between gap-4 rounded-lg border border-border px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="min-w-0 text-sm text-muted-foreground">{summary}</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</section>
);
}
/**
* Provider label used in identity and revoke copy. Falls back to the app's own
* display name so a pasted server never reads as a generic "OAuth".
@ -84,7 +113,7 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
const tools = typeof config.tools === "string" && config.tools ? config.tools : "None";
const rows = [
["Connection method", method],
["Project ID", typeof config.projectId === "string" ? config.projectId : "Not set"],
["Project pin", typeof config.projectId === "string" ? config.projectId : "Use active project"],
["Read-only mode", config.readOnly === true ? "On" : "Off"],
["Feature groups", features],
["Individual tools", tools],
@ -94,7 +123,7 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
<section>
<h2 className="text-sm font-bold text-foreground">PostHog access scope</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
This connection is pinned to the project and analytics surface below.
PostHog uses its normal account defaults unless you narrow the optional controls below.
</p>
<dl className="mt-4 divide-y divide-border">
{rows.map(([label, value]) => (
@ -215,41 +244,6 @@ function GoogleSheetsAllowlistSection({
);
}
export function AppLifecycleSection({
connection,
disabled,
onToggle,
}: {
connection: ToolConnection;
disabled: boolean;
onToggle: () => void;
}) {
const enabled = connection.enabled !== false && connection.status !== "disabled";
return (
<section>
<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 QuarantinedActionsReview({
entries,
disabled,

View File

@ -229,11 +229,12 @@ describe("TestPanel", () => {
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
});
it("renders the Test-as header and grouped actions with access badges", async () => {
it("renders the test hierarchy and grouped actions with access badges", async () => {
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Test as");
expect(container.textContent).toContain("Test an action");
expect(container.textContent).toContain("Agent");
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)");

View File

@ -14,7 +14,6 @@ import {
} from "lucide-react";
import type {
ToolCatalogEntry,
ToolConnectionAccessSummary,
ToolConnectionTestAgent,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,
@ -41,6 +40,7 @@ import {
} from "@/components/JsonSchemaForm";
import { cn, relativeTime } from "@/lib/utils";
import { appTabHref } from "../app-tabs";
import { formatActionPermissionSummary } from "./action-permission-summary";
// ---------------------------------------------------------------------------
// Small format helpers
@ -99,19 +99,6 @@ function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) {
);
}
/** "Allowed for 1 action · Ask first for 2 · Off for 1" — singular gets " action". */
function summaryCount(label: string, n: number): string {
return `${label} ${n}${n === 1 ? " action" : ""}`;
}
function accessSummaryLine(summary: ToolConnectionAccessSummary): string {
return [
summaryCount("Allowed for", summary.allowedCount),
summaryCount("Ask first for", summary.askFirstCount),
summaryCount("Off for", summary.offCount),
].join(" · ");
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
@ -152,9 +139,6 @@ export function TestPanel({
setAgentId(agents[0].id);
}, [agents, agentId]);
// Switches the header from "TEST AS" card to the compact "Testing as …" line.
const [hasInteracted, setHasInteracted] = useState(false);
const selectedAgent = agents.find((a) => a.id === agentId) ?? null;
// Per-action decision for the selected agent, keyed by both the upstream and
@ -236,11 +220,10 @@ export function TestPanel({
appName,
allAgents: agents,
onSelectAgent: setAgentId,
onInteract: () => setHasInteracted(true),
};
return (
<div className="space-y-5">
<div className="space-y-8">
{selectedAgent && (
<TestAsHeader
appName={appName}
@ -248,11 +231,11 @@ export function TestPanel({
selectedAgent={selectedAgent}
onSelect={setAgentId}
connectionId={connectionId}
compact={hasInteracted}
/>
)}
<div className="space-y-3">
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-(--sz-12rem) flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@ -269,7 +252,7 @@ export function TestPanel({
<FilterChip label={`Write ${writeActions.length}`} active={kindFilter === "write"} onClick={() => setKindFilter("write")} />
</div>
<p className="text-xs text-muted-foreground">{visibleCount} matches · sorted AZ</p>
</div>
</section>
{visibleCount === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
@ -339,38 +322,24 @@ function TestAsHeader({
selectedAgent,
onSelect,
connectionId,
compact,
}: {
appName: string;
agents: ToolConnectionTestAgent[];
selectedAgent: ToolConnectionTestAgent;
onSelect: (agentId: string) => void;
connectionId: string;
compact: boolean;
}) {
if (compact) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 pb-3">
<p className="text-sm text-muted-foreground">
Testing as{" "}
<AgentPicker
agents={agents}
selectedAgent={selectedAgent}
onSelect={onSelect}
connectionId={connectionId}
appName={appName}
inline
/>
</p>
<p className="text-xs text-muted-foreground">{accessSummaryLine(selectedAgent.effectiveAccess)}</p>
</div>
);
}
return (
<div>
<section className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Test an action</h2>
<p className="mt-1 text-sm text-muted-foreground">
Run a real action as an agent.
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Test as</p>
<p className="text-xs font-medium text-muted-foreground">Agent</p>
<AgentPicker
agents={agents}
selectedAgent={selectedAgent}
@ -379,12 +348,14 @@ function TestAsHeader({
appName={appName}
/>
</div>
<p className="text-sm text-muted-foreground">{accessSummaryLine(selectedAgent.effectiveAccess)}</p>
<Link
className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
to={appTabHref(connectionId, "permissions")}
>
{formatActionPermissionSummary(selectedAgent.effectiveAccess)}
</Link>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Runs real actions in {appName}, exactly as this agent would.
</p>
</div>
</section>
);
}
@ -531,7 +502,6 @@ type RowSharedProps = {
appName: string;
allAgents: ToolConnectionTestAgent[];
onSelectAgent: (agentId: string) => void;
onInteract: () => void;
};
function ActionGroup({
@ -693,7 +663,6 @@ function ActionTester({
agent,
allAgents,
onSelectAgent,
onInteract,
}: {
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
@ -761,7 +730,6 @@ function ActionTester({
const validationErrors = validateJsonSchemaForm(rawSchema, values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) return;
onInteract();
cancelledRef.current = false;
startedAtRef.current = Date.now();
setElapsedMs(0);

View File

@ -0,0 +1,41 @@
import type { ToolCatalogEntry } from "@paperclipai/shared";
export type ActionPermissionSummary = {
allowedCount: number;
askFirstCount: number;
offCount: number;
};
export function summarizeActionPermissions(
entries: ToolCatalogEntry[],
enabledIds: Set<string>,
askFirstIds: Set<string>,
): ActionPermissionSummary {
let allowedCount = 0;
let askFirstCount = 0;
let offCount = 0;
for (const entry of entries) {
if (!enabledIds.has(entry.id)) {
offCount += 1;
} else if (askFirstIds.has(entry.id)) {
askFirstCount += 1;
} else {
allowedCount += 1;
}
}
return { allowedCount, askFirstCount, offCount };
}
function summaryCount(label: string, count: number): string {
return `${label} ${count}${count === 1 ? " action" : ""}`;
}
export function formatActionPermissionSummary(summary: ActionPermissionSummary): string {
return [
summaryCount("Allowed for", summary.allowedCount),
summaryCount("Ask first for", summary.askFirstCount),
summaryCount("Off for", summary.offCount),
].join(" · ");
}

View File

@ -4,8 +4,8 @@ export const APP_TABS = [
{ key: "setup", label: "Setup", icon: Settings2 },
{ key: "test", label: "Test", icon: Beaker },
{ key: "services", label: "Services", icon: Blocks },
{ key: "review", label: "Review", icon: Inbox },
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
{ key: "review", label: "Review", icon: Inbox },
{ key: "activity", label: "Activity", icon: Activity },
] as const;

View File

@ -1,4 +1,5 @@
import type {
ConnectionGrantKind,
ConnectionAudienceMember,
ConnectionGrant,
ConnectionGrantStatus,
@ -14,24 +15,34 @@ import type {
* card cannot drift into three different names for the same thing.
*/
export interface ActsAsSummary {
title: string;
detail: string;
export type ConnectionTypeLabel = "Personal" | "Company";
/** The two connection types shown throughout the product. */
export function connectionTypeLabel(
credentialPolicy: ToolConnectionCredentialPolicy,
): ConnectionTypeLabel {
return credentialPolicy === "per_user" ? "Personal" : "Company";
}
export function actsAsSummary(credentialPolicy: ToolConnectionCredentialPolicy): ActsAsSummary {
switch (credentialPolicy) {
case "per_user":
return { title: "Acts as each person", detail: "Each person connects their own account." };
case "per_user_with_fallback":
return {
title: "Uses a personal identity with organization fallback",
detail: "Agents use your account when you have one, and the organization account otherwise.",
};
case "shared":
default:
return { title: "Acts as the organization", detail: "Agents share the organization identity." };
const COMPANY_NAME_SUFFIX = " for the company";
/** Keep company-owned connections unmistakable anywhere their name appears. */
export function connectionNameForGrantKind(name: string, grantKind: ConnectionGrantKind): string {
const trimmed = name.trim();
if (grantKind !== "organization" || trimmed.toLocaleLowerCase().endsWith(COMPANY_NAME_SUFFIX)) {
return trimmed;
}
return `${trimmed}${COMPANY_NAME_SUFFIX}`;
}
export function connectionNameForCredentialPolicy(
name: string,
credentialPolicy: ToolConnectionCredentialPolicy,
): string {
return connectionNameForGrantKind(
name,
connectionTypeLabel(credentialPolicy) === "Company" ? "organization" : "user",
);
}
/**

View File

@ -7,6 +7,7 @@ import {
genericConnectGuidance,
genericConnectPayload,
newCustomHeaderRow,
oauthCallbackUrlForBrowser,
type GenericConnectDraft,
} from "./generic-mcp-connect";
@ -36,6 +37,23 @@ describe("endpointHost", () => {
});
});
describe("oauthCallbackUrlForBrowser", () => {
it("uses localhost for local HTTP callbacks to match the API authorization request", () => {
expect(oauthCallbackUrlForBrowser("http://127.0.0.1:3200")).toBe(
"http://localhost:3200/api/tools/oauth/callback",
);
expect(oauthCallbackUrlForBrowser("http://[::1]:3200")).toBe(
"http://localhost:3200/api/tools/oauth/callback",
);
});
it("preserves public HTTPS origins", () => {
expect(oauthCallbackUrlForBrowser("https://paperclip.example.test")).toBe(
"https://paperclip.example.test/api/tools/oauth/callback",
);
});
});
describe("defaultGenericMcpName", () => {
it("keeps the port and path so endpoints on one host get distinct names", () => {
expect(defaultGenericMcpName("http://127.0.0.1:47399/mcp"))

View File

@ -35,6 +35,25 @@ export function endpointHost(url: string): string | null {
}
}
/**
* The callback URI shown to operators must exactly match the URI the API sends.
*
* Xero and a few other OAuth providers reject numeric loopback hosts even though
* they point at the same machine. The API therefore uses `localhost` for local
* HTTP OAuth, so the setup form must advertise that same canonical spelling.
*/
export function oauthCallbackUrlForBrowser(origin: string = window.location.origin): string {
const callbackUrl = new URL("/api/tools/oauth/callback", origin);
const hostname = callbackUrl.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (
callbackUrl.protocol === "http:"
&& (hostname === "127.0.0.1" || hostname === "::1")
) {
callbackUrl.hostname = "localhost";
}
return callbackUrl.toString();
}
/**
* A useful, non-secret default label for an arbitrary endpoint.
*

View File

@ -4,7 +4,7 @@ import { advancedTabHref } from "@/pages/tools/tool-tabs";
import { appSourceConnectHref } from "./app-connect-policy";
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "posthog", "linear"];
export const POPULAR_KEYS = ["zapier", "notion", "posthog", "linear", "jira", "cloudflare"];
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";

View File

@ -90,8 +90,8 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
ownership: "customer",
transport: "mcp_remote",
authKind: "none",
credentialPolicy: "shared",
credentialSource: "paperclip_vault",
credentialPolicy: "shared",
status: "draft",
enabled: false,
config: { url: "http://127.0.0.1:8848/mcp" },
@ -124,7 +124,7 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
}],
canMakeChanges: [],
},
suggestedDefaults: { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] },
suggestedDefaults: { access: "all_agents", askFirstRiskLevels: [] },
...overrides,
};
}

View File

@ -19,7 +19,7 @@ import { useNavigate } from "@/lib/router";
import {
OAuthConnectStateScreen,
type OAuthConnectPhase,
} from "@/pages/apps/AppsConnect";
} from "@/features/connections/ConnectionSetupFlow";
import { endpointHost } from "@/pages/apps/generic-mcp-connect";
import { McpConfigHelpDialog } from "./McpConfigHelpDialog";
import { ErrorState } from "./shared";

View File

@ -1,7 +1,10 @@
import { useEffect, useState, type ReactNode } from "react";
import type { Preview } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkTimelineResult } from "@paperclipai/shared";
import {
CONNECTABLE_APP_DEFINITIONS,
type WorkTimelineResult,
} from "@paperclipai/shared";
import { MemoryRouter } from "@/lib/router";
import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard";
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
@ -39,12 +42,16 @@ import "./styles.css";
const STORYBOOK_USER_AVATAR =
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=96&q=80";
function withStorybookTimelineDetails(data: WorkTimelineResult): WorkTimelineResult {
function withStorybookTimelineDetails(
data: WorkTimelineResult,
): WorkTimelineResult {
return {
...data,
actors: data.actors.map((actor) => (
actor.type === "user" ? { ...actor, avatar: STORYBOOK_USER_AVATAR } : actor
)),
actors: data.actors.map((actor) =>
actor.type === "user"
? { ...actor, avatar: STORYBOOK_USER_AVATAR }
: actor,
),
spans: data.spans.map((span, index) => {
const inputTokens = 42_000 + index * 137;
const cachedInputTokens = index % 3 === 0 ? 8_000 : 0;
@ -62,7 +69,9 @@ function withStorybookTimelineDetails(data: WorkTimelineResult): WorkTimelineRes
};
}
const storybookTimelineSample = withStorybookTimelineDetails(timelineSample as WorkTimelineResult);
const storybookTimelineSample = withStorybookTimelineDetails(
timelineSample as WorkTimelineResult,
);
// Install fetch monkeypatch eagerly so any module-load-time fetches (e.g. schema
// caches in adapter config renderers) hit our fixtures before they reach the
@ -148,6 +157,66 @@ function installStorybookApiFixtures() {
return Response.json([]);
}
if (
url.pathname ===
"/api/connection-intents/interaction-connection-intent-default/setup-options"
) {
return Response.json({
version: 1,
interaction: null,
service: {
service: "notion",
name: "Notion",
description: "Search and update a Notion workspace.",
logoUrl: null,
methods: [
{ key: "mcp-oauth", label: "Sign in with Notion", auth: "oauth" },
],
state: "needs_user_action",
connectionId: null,
},
requestedAgentId: "11111111-1111-4111-8111-111111111111",
existingConnections: [
{
id: "connection-storybook-notion",
companyId: "company-storybook",
applicationId: "application-storybook-notion",
name: "Board Operators Notion",
uid: "notion/storybook",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
enabled: true,
config: { sourceTemplateKey: "notion" },
transportConfig: { sourceTemplateKey: "notion" },
credentialRefs: [],
credentialSecretRefs: [],
},
],
});
}
if (url.pathname === "/api/companies/company-storybook/tools/gallery") {
return Response.json({
apps: CONNECTABLE_APP_DEFINITIONS.filter(
(app) => app.slug === "notion",
),
capabilities: {
canSetCompanyInstall: true,
companyInstallReason: null,
},
});
}
if (
url.pathname === "/api/companies/company-storybook/tools/applications"
) {
return Response.json({ applications: [] });
}
if (url.pathname === "/api/companies/company-storybook/tools/connections") {
return Response.json({ connections: [] });
}
if (url.pathname === "/api/adapters") {
return Response.json([
{
@ -209,23 +278,33 @@ function installStorybookApiFixtures() {
return Response.json([]);
}
const adapterSchemaMatch = url.pathname.match(/^\/api\/adapters\/([^/]+)\/config-schema$/);
const adapterSchemaMatch = url.pathname.match(
/^\/api\/adapters\/([^/]+)\/config-schema$/,
);
if (adapterSchemaMatch) {
const [, adapterType] = adapterSchemaMatch;
const schemas = (window as typeof window & {
__paperclipStorybookAdapterSchemas?: Record<string, unknown>;
}).__paperclipStorybookAdapterSchemas;
const schemas = (
window as typeof window & {
__paperclipStorybookAdapterSchemas?: Record<string, unknown>;
}
).__paperclipStorybookAdapterSchemas;
const schema = schemas?.[adapterType];
if (schema) return Response.json(schema);
}
const secretsListMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secrets$/);
const secretsListMatch = url.pathname.match(
/^\/api\/companies\/([^/]+)\/secrets$/,
);
if (secretsListMatch) {
const [, companyId] = secretsListMatch;
return Response.json(companyId === "company-storybook" ? storybookSecrets : []);
return Response.json(
companyId === "company-storybook" ? storybookSecrets : [],
);
}
const secretProvidersMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secret-providers$/);
const secretProvidersMatch = url.pathname.match(
/^\/api\/companies\/([^/]+)\/secret-providers$/,
);
if (secretProvidersMatch) {
return Response.json(storybookSecretProviders);
}
@ -247,36 +326,57 @@ function installStorybookApiFixtures() {
const secretProviderConfigDiscoveryPreviewMatch = url.pathname.match(
/^\/api\/companies\/([^/]+)\/secret-provider-configs\/discovery\/preview$/,
);
if (secretProviderConfigDiscoveryPreviewMatch && init?.method?.toUpperCase() === "POST") {
if (
secretProviderConfigDiscoveryPreviewMatch &&
init?.method?.toUpperCase() === "POST"
) {
return Response.json(storybookSecretProviderDiscoveryPreview);
}
const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
const secretUsageMatch = url.pathname.match(
/^\/api\/secrets\/([^/]+)\/usage$/,
);
if (secretUsageMatch) {
const [, secretId] = secretUsageMatch;
return Response.json({
secretId,
bindings: storybookSecretBindings.filter((binding) => binding.secretId === secretId),
bindings: storybookSecretBindings.filter(
(binding) => binding.secretId === secretId,
),
});
}
const secretEventsMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/access-events$/);
const secretEventsMatch = url.pathname.match(
/^\/api\/secrets\/([^/]+)\/access-events$/,
);
if (secretEventsMatch) {
const [, secretId] = secretEventsMatch;
return Response.json(storybookSecretAccessEvents.filter((event) => event.secretId === secretId));
return Response.json(
storybookSecretAccessEvents.filter(
(event) => event.secretId === secretId,
),
);
}
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
const companyResourceMatch = url.pathname.match(
/^\/api\/companies\/([^/]+)\/([^/]+)$/,
);
if (companyResourceMatch) {
const [, companyId, resource] = companyResourceMatch;
if (resource === "agents") {
return Response.json(companyId === "company-storybook" ? storybookAgents : []);
return Response.json(
companyId === "company-storybook" ? storybookAgents : [],
);
}
if (resource === "projects") {
return Response.json(companyId === "company-storybook" ? storybookProjects : []);
return Response.json(
companyId === "company-storybook" ? storybookProjects : [],
);
}
if (resource === "approvals") {
return Response.json(companyId === "company-storybook" ? storybookApprovals : []);
return Response.json(
companyId === "company-storybook" ? storybookApprovals : [],
);
}
if (resource === "dashboard") {
return Response.json({
@ -293,9 +393,15 @@ function installStorybookApiFixtures() {
spans: [],
events: [],
edges: [],
pagination: { limit: 100, offset: 0, totalIssues: 0, hasMore: false },
pagination: {
limit: 100,
offset: 0,
totalIssues: 0,
hasMore: false,
},
window: {
from: url.searchParams.get("from") ?? new Date(0).toISOString(),
from:
url.searchParams.get("from") ?? new Date(0).toISOString(),
to: url.searchParams.get("to") ?? new Date(0).toISOString(),
capped: false,
},
@ -306,7 +412,9 @@ function installStorybookApiFixtures() {
return Response.json([]);
}
if (resource === "live-runs") {
return Response.json(companyId === "company-storybook" ? storybookLiveRuns : []);
return Response.json(
companyId === "company-storybook" ? storybookLiveRuns : [],
);
}
if (resource === "inbox-dismissals") {
return Response.json([]);
@ -327,14 +435,19 @@ function installStorybookApiFixtures() {
return Response.json(
query
? issues.filter((issue) =>
`${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`.toLowerCase().includes(query),
`${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`
.toLowerCase()
.includes(query),
)
: issues,
);
}
}
if (url.pathname.startsWith("/api/invites/") && url.pathname.endsWith("/logo")) {
if (
url.pathname.startsWith("/api/invites/") &&
url.pathname.endsWith("/logo")
) {
return new Response(null, { status: 204 });
}

View File

@ -275,8 +275,8 @@ const CONNECTIONS: ToolConnection[] = [
ownership: "customer",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
credentialSource: "paperclip_vault",
credentialPolicy: "per_user",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
@ -311,8 +311,8 @@ const CONNECTIONS: ToolConnection[] = [
ownership: "customer",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
credentialSource: "paperclip_vault",
credentialPolicy: "per_user",
status: "active",
transportConfig: {},
credentialSecretRefs: [],

View File

@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
acceptedManyRequestCheckboxConfirmationInteraction,
acceptedRequestCheckboxConfirmationInteraction,
@ -55,6 +61,13 @@ import {
rejectedSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction,
pendingConnectionIntentInteraction,
authorizingConnectionIntentInteraction,
retryConnectionIntentInteraction,
connectedConnectionIntentInteraction,
declinedConnectionIntentInteraction,
supersededConnectionIntentInteraction,
expiredConnectionIntentInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type {
AskUserQuestionsAnswer,
@ -123,7 +136,37 @@ function ScenarioCard({
);
}
function AudienceCard({ interaction }: { interaction: RequestConfirmationInteraction }) {
function OpenConnectionIntentDialogStory() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
const trigger = Array.from(
hostRef.current?.querySelectorAll("button") ?? [],
).find((candidate) =>
candidate.textContent?.includes("Connect / Use existing"),
);
trigger?.click();
});
return () => window.cancelAnimationFrame(frame);
}, []);
return (
<div ref={hostRef}>
<IssueThreadInteractionCard
interaction={pendingConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</div>
);
}
function AudienceCard({
interaction,
}: {
interaction: RequestConfirmationInteraction;
}) {
return (
<IssueThreadInteractionCard
interaction={interaction}
@ -152,13 +195,20 @@ function InteractiveSuggestedTasksCard() {
...acceptedSuggestedTasksInteraction,
result: {
version: 1,
createdTasks: (acceptedSuggestedTasksInteraction.result?.createdTasks ?? []).filter((task) =>
selectedClientKeys?.includes(task.clientKey) ?? true),
createdTasks: (
acceptedSuggestedTasksInteraction.result?.createdTasks ?? []
).filter(
(task) => selectedClientKeys?.includes(task.clientKey) ?? true,
),
skippedClientKeys: pendingSuggestedTasksInteraction.payload.tasks
.map((task) => task.clientKey)
.filter((clientKey) => !(selectedClientKeys?.includes(clientKey) ?? true)),
.filter(
(clientKey) =>
!(selectedClientKeys?.includes(clientKey) ?? true),
),
},
})}
})
}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejectedSuggestedTasksInteraction,
@ -166,11 +216,12 @@ function InteractiveSuggestedTasksCard() {
version: 1,
...(rejectedSuggestedTasksInteraction.result ?? {}),
rejectionReason:
reason
|| rejectedSuggestedTasksInteraction.result?.rejectionReason
|| null,
reason ||
rejectedSuggestedTasksInteraction.result?.rejectionReason ||
null,
},
})}
})
}
/>
);
}
@ -178,13 +229,15 @@ function InteractiveSuggestedTasksCard() {
function buildAnsweredInteraction(
answers: AskUserQuestionsAnswer[],
): AskUserQuestionsInteraction {
const labels = pendingAskUserQuestionsInteraction.payload.questions.flatMap((question) => {
const answer = answers.find((entry) => entry.questionId === question.id);
if (!answer) return [];
return question.options
.filter((option) => answer.optionIds.includes(option.id))
.map((option) => option.label);
});
const labels = pendingAskUserQuestionsInteraction.payload.questions.flatMap(
(question) => {
const answer = answers.find((entry) => entry.questionId === question.id);
if (!answer) return [];
return question.options
.filter((option) => answer.optionIds.includes(option.id))
.map((option) => option.label);
},
);
return {
...answeredAskUserQuestionsInteraction,
@ -208,15 +261,17 @@ function InteractiveAskUserQuestionsCard() {
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onSubmitInteractionAnswers={(_interaction, answers) =>
setInteraction(buildAnsweredInteraction(answers))}
setInteraction(buildAnsweredInteraction(answers))
}
/>
);
}
function InteractiveRequestConfirmationCard() {
const [interaction, setInteraction] = useState<RequestConfirmationInteraction>(
pendingRequestConfirmationInteraction,
);
const [interaction, setInteraction] =
useState<RequestConfirmationInteraction>(
pendingRequestConfirmationInteraction,
);
return (
<IssueThreadInteractionCard
@ -224,16 +279,22 @@ function InteractiveRequestConfirmationCard() {
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={() => setInteraction(acceptedRequestConfirmationInteraction)}
onAcceptInteraction={() =>
setInteraction(acceptedRequestConfirmationInteraction)
}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejectedRequestConfirmationInteraction,
result: {
version: 1,
outcome: "rejected",
reason: reason || rejectedRequestConfirmationInteraction.result?.reason || null,
reason:
reason ||
rejectedRequestConfirmationInteraction.result?.reason ||
null,
},
})}
})
}
/>
);
}
@ -247,7 +308,8 @@ function InteractiveRequestCheckboxConfirmationCard({
accepted: RequestCheckboxConfirmationInteraction;
rejected: RequestCheckboxConfirmationInteraction;
}) {
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
const [interaction, setInteraction] =
useState<RequestCheckboxConfirmationInteraction>(pending);
return (
<IssueThreadInteractionCard
@ -255,7 +317,11 @@ function InteractiveRequestCheckboxConfirmationCard({
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
onAcceptInteraction={(
_interaction,
_selectedClientKeys,
selectedOptionIds,
) =>
setInteraction({
...accepted,
payload: pending.payload,
@ -264,7 +330,8 @@ function InteractiveRequestCheckboxConfirmationCard({
outcome: "accepted",
selectedOptionIds: selectedOptionIds ?? [],
},
})}
})
}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejected,
@ -274,7 +341,8 @@ function InteractiveRequestCheckboxConfirmationCard({
outcome: "rejected",
reason: reason || rejected.result?.reason || null,
},
})}
})
}
/>
);
}
@ -284,7 +352,8 @@ function InteractiveRequestItemVerdictsCard({
}: {
initial?: RequestItemVerdictsInteraction;
}) {
const [interaction, setInteraction] = useState<RequestItemVerdictsInteraction>(initial);
const [interaction, setInteraction] =
useState<RequestItemVerdictsInteraction>(initial);
return (
<IssueThreadInteractionCard
@ -304,7 +373,8 @@ function InteractiveRequestItemVerdictsCard({
id: verdict.id,
verdict: verdict.verdict as RequestItemVerdictValue,
reason: verdict.reason ?? null,
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedByUserId:
issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T15:20:00.000Z"),
})),
];
@ -313,7 +383,9 @@ function InteractiveRequestItemVerdictsCard({
...current,
status: complete ? "answered" : "pending",
resolvedAt: complete ? new Date("2026-04-20T15:20:00.000Z") : null,
resolvedByUserId: complete ? issueThreadInteractionFixtureMeta.currentUserId : null,
resolvedByUserId: complete
? issueThreadInteractionFixtureMeta.currentUserId
: null,
result: {
version: 1,
outcome: "resolved",
@ -321,7 +393,8 @@ function InteractiveRequestItemVerdictsCard({
items: merged,
},
};
})}
})
}
/>
);
}
@ -334,8 +407,13 @@ function AutoOpenDeclineRequestConfirmationCard({
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const declineButton = Array.from(ref.current?.querySelectorAll("button") ?? [])
.find((button) => button.textContent?.includes(interaction.payload.rejectLabel ?? "Decline"));
const declineButton = Array.from(
ref.current?.querySelectorAll("button") ?? [],
).find((button) =>
button.textContent?.includes(
interaction.payload.rejectLabel ?? "Decline",
),
);
declineButton?.click();
}, [interaction]);
@ -675,7 +753,128 @@ export const RequestConfirmationFailed: Story = {
};
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
export const RequestConfirmationRejected =
RequestConfirmationDeclinedWithReason;
export const ConnectionIntentStates: Story = {
render: () => (
<StoryFrame>
<Section eyebrow="Connection intent" title="Inline setup request states">
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard
title="Addressed user"
description="The responsible user can launch the shared connection setup flow or decline in place."
>
<IssueThreadInteractionCard
interaction={pendingConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Other viewer"
description="Other viewers see who Paperclip is waiting for and receive no connection controls."
>
<IssueThreadInteractionCard
interaction={pendingConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId="user-product"
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Authorizing"
description="The in-flight state prevents duplicate authorization or decline actions."
>
<IssueThreadInteractionCard
interaction={authorizingConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Retry needed"
description="A failed popup or provider callback keeps the intent pending with a safe retry path."
>
<IssueThreadInteractionCard
interaction={retryConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Connected"
description="The terminal card records that the requesting agent receives the connection on continuation."
>
<IssueThreadInteractionCard
interaction={connectedConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Declined"
description="Decline is terminal and wakes the requesting agent without exposing setup controls."
>
<IssueThreadInteractionCard
interaction={declinedConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Superseded"
description="A newer run owns the active request, so the stale card points at the latest one."
>
<IssueThreadInteractionCard
interaction={supersededConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
<ScenarioCard
title="Expired"
description="Closed tasks and expired requests have a quiet terminal state with no authorization controls."
>
<IssueThreadInteractionCard
interaction={expiredConnectionIntentInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</div>
</Section>
</StoryFrame>
),
};
export const ConnectionIntentSetupDialog: Story = {
render: () => (
<StoryFrame>
<Section eyebrow="Connection intent" title="Shared setup dialog">
<OpenConnectionIntentDialogStory />
</Section>
</StoryFrame>
),
};
export const ConnectionIntentSetupDialogMobile: Story = {
render: () => (
<StoryFrame>
<OpenConnectionIntentDialogStory />
</StoryFrame>
),
parameters: {
viewport: { defaultViewport: "mobile" },
},
};
// ---------------------------------------------------------------------------
// MCP tool-approval card (PAP-13745). A `request_confirmation` carrying a
@ -710,7 +909,10 @@ export const ToolActionPendingWrite: Story = {
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 />
<ToolActionCard
interaction={pendingToolActionWriteInteraction}
interactive
/>
</ScenarioCard>
</StoryFrame>
),
@ -723,7 +925,10 @@ export const ToolActionPendingDestructive: Story = {
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 />
<ToolActionCard
interaction={pendingToolActionDestructiveInteraction}
interactive
/>
</ScenarioCard>
</StoryFrame>
),
@ -801,7 +1006,10 @@ export const ToolActionLegacyGeneric: Story = {
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 />
<ToolActionCard
interaction={genericPendingRequestConfirmationInteraction}
interactive
/>
</ScenarioCard>
</StoryFrame>
),
@ -833,7 +1041,10 @@ export const SecretProposalPending: Story = {
title="Pending secret binding"
description="A human reviews safe binding metadata, the agent-authored reason, and expiry before approving the real write."
>
<SecretProposalCard interaction={pendingSecretProposalInteraction} interactive />
<SecretProposalCard
interaction={pendingSecretProposalInteraction}
interactive
/>
</ScenarioCard>
</StoryFrame>
),
@ -896,20 +1107,44 @@ export const SecretProposalAllStates: Story = {
<StoryFrame>
<Section eyebrow="Secret binding proposal" title="All lifecycle states">
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard title="1 · Pending" description="Safe metadata and approval actions.">
<SecretProposalCard interaction={pendingSecretProposalInteraction} interactive />
<ScenarioCard
title="1 · Pending"
description="Safe metadata and approval actions."
>
<SecretProposalCard
interaction={pendingSecretProposalInteraction}
interactive
/>
</ScenarioCard>
<ScenarioCard title="2 · Executed" description="The binding was created.">
<SecretProposalCard interaction={executedSecretProposalInteraction} />
<ScenarioCard
title="2 · Executed"
description="The binding was created."
>
<SecretProposalCard
interaction={executedSecretProposalInteraction}
/>
</ScenarioCard>
<ScenarioCard title="3 · FAILED" description="Accepted, then failed closed.">
<ScenarioCard
title="3 · FAILED"
description="Accepted, then failed closed."
>
<SecretProposalCard interaction={failedSecretProposalInteraction} />
</ScenarioCard>
<ScenarioCard title="4 · Rejected" description="The binding was not created.">
<SecretProposalCard interaction={rejectedSecretProposalInteraction} />
<ScenarioCard
title="4 · Rejected"
description="The binding was not created."
>
<SecretProposalCard
interaction={rejectedSecretProposalInteraction}
/>
</ScenarioCard>
<ScenarioCard title="5 · Expired" description="A fresh proposal is required.">
<SecretProposalCard interaction={expiredSecretProposalInteraction} />
<ScenarioCard
title="5 · Expired"
description="A fresh proposal is required."
>
<SecretProposalCard
interaction={expiredSecretProposalInteraction}
/>
</ScenarioCard>
</div>
</Section>
@ -937,31 +1172,41 @@ export const ResolverAudienceStates: Story = {
title="Anyone except creator"
description="Requested on purpose when the answer has to be independent of the agent that asked."
>
<AudienceCard interaction={notCreatorRequestConfirmationInteraction} />
<AudienceCard
interaction={notCreatorRequestConfirmationInteraction}
/>
</ScenarioCard>
<ScenarioCard
title="Human only"
description="Reserved for a person: agents are turned away by the server, and the copy says so."
>
<AudienceCard interaction={humanOnlyRequestConfirmationInteraction} />
<AudienceCard
interaction={humanOnlyRequestConfirmationInteraction}
/>
</ScenarioCard>
<ScenarioCard
title="Named addressee"
description="One agent owns the response; the card stays out of the open attention feed."
>
<AudienceCard interaction={agentAddressedRequestConfirmationInteraction} />
<AudienceCard
interaction={agentAddressedRequestConfirmationInteraction}
/>
</ScenarioCard>
<ScenarioCard
title="Narrowed by a company cap"
description="The request asked for Anyone; company interaction governance capped the kind, and the card explains the narrowing."
>
<AudienceCard interaction={companyCappedRequestConfirmationInteraction} />
<AudienceCard
interaction={companyCappedRequestConfirmationInteraction}
/>
</ScenarioCard>
<ScenarioCard
title="Legacy restricted card"
description="Created before Anyone became the default. Migration keeps it restricted fail-closed and the card says a new card would be open."
>
<AudienceCard interaction={legacyRestrictedRequestConfirmationInteraction} />
<AudienceCard
interaction={legacyRestrictedRequestConfirmationInteraction}
/>
</ScenarioCard>
</div>
</Section>
@ -972,31 +1217,67 @@ export const ResolverAudienceStates: Story = {
export const ToolActionAllStates: Story = {
render: () => (
<StoryFrame>
<Section eyebrow="MCP Tool Approval" title="All six lifecycle states (PAP-13745)">
<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
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
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.">
<ScenarioCard
title="2 · Approved — running…"
description="Transient, self-resolving spinner."
>
<ToolActionCard interaction={runningToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="3 · Executed" description="Green, with a result summary.">
<ScenarioCard
title="3 · Executed"
description="Green, with a result summary."
>
<ToolActionCard interaction={executedToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="4 · Failed" description="Ran, but the connector errored.">
<ScenarioCard
title="4 · Failed"
description="Ran, but the connector errored."
>
<ToolActionCard interaction={failedToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="5 · Declined" description="Rejected — nothing ran.">
<ScenarioCard
title="5 · Declined"
description="Rejected — nothing ran."
>
<ToolActionCard interaction={declinedToolActionInteraction} />
</ScenarioCard>
<ScenarioCard title="6 · Expired" description="No response in 60 min.">
<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
title="Legacy · no toolAction"
description="Unchanged generic rendering."
>
<ToolActionCard
interaction={genericPendingRequestConfirmationInteraction}
interactive
/>
</ScenarioCard>
</div>
</Section>
@ -1012,7 +1293,10 @@ export const ToolActionMobile: Story = {
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 />
<ToolActionCard
interaction={pendingToolActionWriteInteraction}
interactive
/>
</div>
</ScenarioCard>
</StoryFrame>
@ -1165,7 +1449,9 @@ export const ItemVerdictsPartial: Story = {
title="S3 / S4 — partial progress"
description="Two items already applied (one approved, one rejected with its reason echoed); three remain actionable. The card stays alive and shows 2 of 5 decided."
>
<InteractiveRequestItemVerdictsCard initial={partialRequestItemVerdictsInteraction} />
<InteractiveRequestItemVerdictsCard
initial={partialRequestItemVerdictsInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
@ -1214,7 +1500,9 @@ export const ItemVerdictsManyItems: Story = {
title="S7 — long list"
description="24 items decided in passes; the expanded list scrolls in a bounded region and reuses the 200-item cap."
>
<InteractiveRequestItemVerdictsCard initial={manyItemsRequestItemVerdictsInteraction} />
<InteractiveRequestItemVerdictsCard
initial={manyItemsRequestItemVerdictsInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
@ -1226,14 +1514,17 @@ export const ReviewSurface: Story = {
<section className="paperclip-story__frame p-6">
<div className="paperclip-story__label">Thread interactions</div>
<div className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
This review surface pressure-tests the thread interaction kinds directly inside the issue
chat surface. The card language leans closer to
annotated review sheets than generic admin widgets so the objects feel like first-class work
artifacts in the thread.
This review surface pressure-tests the thread interaction kinds
directly inside the issue chat surface. The card language leans closer
to annotated review sheets than generic admin widgets so the objects
feel like first-class work artifacts in the thread.
</div>
</section>
<Section eyebrow="Suggested Tasks" title="Pending, accepted, and rejected task-tree cards">
<Section
eyebrow="Suggested Tasks"
title="Pending, accepted, and rejected task-tree cards"
>
<div className="grid gap-6 xl:grid-cols-3">
<ScenarioCard
title="Pending"
@ -1266,7 +1557,10 @@ export const ReviewSurface: Story = {
</div>
</Section>
<Section eyebrow="Ask User Questions" title="Pending multi-question form and answered summary">
<Section
eyebrow="Ask User Questions"
title="Pending multi-question form and answered summary"
>
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard
title="Pending"
@ -1288,7 +1582,10 @@ export const ReviewSurface: Story = {
</div>
</Section>
<Section eyebrow="Request Confirmation" title="Plan approval and compact resolution states">
<Section
eyebrow="Request Confirmation"
title="Plan approval and compact resolution states"
>
<div className="grid gap-6 xl:grid-cols-2">
<ScenarioCard
title="Plan approval"
@ -1340,7 +1637,10 @@ export const ReviewSurface: Story = {
</div>
</Section>
<Section eyebrow="Mixed Feed" title="Interaction cards in the real issue thread">
<Section
eyebrow="Mixed Feed"
title="Interaction cards in the real issue thread"
>
<ScenarioCard
title="IssueChatThread composition"
description="Comments, timeline events, accepted task suggestions, a pending confirmation, a pending question form, and an active run share the same feed."

View File

@ -4,6 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
CONNECTABLE_APP_DEFINITIONS,
type AppDefinition,
type ConnectionGrant,
type ConnectionGrantsResponse,
type ToolConnection,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
@ -14,20 +16,75 @@ import {
type OAuthConnectPhase,
} from "@/pages/apps/AppsConnect";
import { SetupPanel } from "@/pages/apps/app-detail/SetupPanel";
import { ReconnectCard } from "@/pages/apps/app-detail/AdvancedPanel";
import {
AdvancedPanel,
ReconnectCard,
} from "@/pages/apps/app-detail/AdvancedPanel";
import { IdentitiesSection } from "@/pages/apps/app-detail/IdentitiesSection";
const COMPANY_ID = "company-storybook";
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion") as AppDefinition;
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier") as AppDefinition;
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github") as AppDefinition;
const NOTION = CONNECTABLE_APP_DEFINITIONS.find(
(app) => app.slug === "notion",
) as AppDefinition;
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find(
(app) => app.slug === "zapier",
) as AppDefinition;
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find(
(app) => app.slug === "github",
) as AppDefinition;
function seededClient() {
const client = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
queries: {
staleTime: Infinity,
gcTime: Infinity,
retry: false,
refetchOnMount: false,
},
},
});
client.setQueryData(queryKeys.apps.gallery(COMPANY_ID), { apps: [NOTION, ZAPIER, GITHUB] });
client.setQueryData(queryKeys.apps.gallery(COMPANY_ID), {
apps: [NOTION, ZAPIER, GITHUB],
credentialSources: {
vercelConnect: {
available: true,
enabled: true,
authentication: "access_token",
manageUrl: "https://vercel.com/connect",
reason: null,
},
},
});
client.setQueryData(queryKeys.tools.applications(COMPANY_ID), {
applications: [
{
id: "application-notion",
companyId: COMPANY_ID,
name: "Notion",
applicationKey: "notion",
status: "active",
metadata: {},
},
],
});
client.setQueryData(queryKeys.tools.connections(COMPANY_ID), {
connections: [notionConnection()],
});
client.setQueryData(queryKeys.access.companyUserDirectory(COMPANY_ID), {
users: [
{
principalId: "board-user",
status: "active",
user: {
id: "board-user",
name: "Dotta",
email: "dotta@example.com",
image: null,
},
},
],
});
return client;
}
@ -42,7 +99,13 @@ function BrowseHost() {
);
}
function OAuthStateHost({ phase, error }: { phase: OAuthConnectPhase; error?: string }) {
function OAuthStateHost({
phase,
error,
}: {
phase: OAuthConnectPhase;
error?: string;
}) {
return (
<div className="mx-auto max-w-5xl p-6">
<OAuthConnectStateScreen
@ -56,7 +119,9 @@ function OAuthStateHost({ phase, error }: { phase: OAuthConnectPhase; error?: st
);
}
function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnection {
function notionConnection(
overrides: Partial<ToolConnection> = {},
): ToolConnection {
return {
id: "connection-notion",
companyId: COMPANY_ID,
@ -67,8 +132,8 @@ function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnecti
ownership: "dcr",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
credentialSource: "paperclip_vault",
credentialPolicy: "per_user",
status: "active",
transportConfig: { url: "https://mcp.notion.com/mcp" },
config: {
@ -91,25 +156,131 @@ function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnecti
};
}
function personalGrant(): ConnectionGrant {
return {
id: "grant-notion-personal",
companyId: COMPANY_ID,
connectionId: "connection-notion",
kind: "user",
subjectUserId: "board-user",
providerTenant: { name: "Dotta" },
credentialSecretRefs: [],
status: "active",
isDefault: true,
createdByAgentId: null,
createdByUserId: "board-user",
revokedAt: null,
revokedByAgentId: null,
revokedByUserId: null,
lastUsedAt: new Date("2026-08-06T19:00:00.000Z"),
createdAt: new Date("2026-08-06T18:55:00.000Z"),
updatedAt: new Date("2026-08-06T19:00:00.000Z"),
delegations: [
{
id: "delegation-1",
companyId: COMPANY_ID,
grantId: "grant-notion-personal",
agentId: "agent-1",
createdByUserId: "board-user",
createdAt: new Date("2026-08-06T19:00:00.000Z"),
},
],
capabilities: { canRevoke: true, canEditAudience: false },
};
}
function personalGrantsResponse(
grant: ConnectionGrant,
): ConnectionGrantsResponse {
return {
connection: { id: "connection-notion", uid: "notion-storybook" },
grants: [grant],
capabilities: {
canConfigure: true,
canCreateOrganizationGrant: false,
canSetCompanyInstall: true,
canConnectAsCurrentUser: true,
canManageAgentInstalls: true,
canViewOtherPersonalIdentities: false,
editableAgentIds: ["agent-1"],
},
currentUserId: "board-user",
members: [
{ userId: "board-user", name: "Dotta", email: "dotta@example.com" },
],
};
}
function ConnectedHost() {
const client = useMemo(() => seededClient(), []);
const connection = notionConnection();
const grant = personalGrant();
return (
<div className="mx-auto max-w-3xl p-6">
<header className="mb-6 flex items-center gap-3">
<AppLogo name={NOTION.name} logoUrl={NOTION.branding.logoUrl} size={44} />
<div>
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
<p className="mt-1 text-sm text-muted-foreground">Connected app setup</p>
<QueryClientProvider client={client}>
<div className="mx-auto w-screen max-w-3xl p-6">
<header className="mb-6 flex items-center gap-3">
<AppLogo
name={NOTION.name}
logoUrl={NOTION.branding.logoUrl}
size={44}
/>
<div>
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
<p className="mt-1 text-sm text-muted-foreground">
Connected app setup
</p>
</div>
</header>
<div className="space-y-8">
<SetupPanel
connection={connection}
galleryEntry={NOTION}
onUpdateConfig={() => undefined}
configUpdateDisabled={false}
agentsSummary="1 agent"
permissionsSummary="Allowed for 28 · Ask first for 0 · Off for 0"
permissionsLoading={false}
onOpenPermissions={() => undefined}
identities={
<IdentitiesSection
appName="Notion"
credentialPolicy="per_user"
ownerUserId="board-user"
connectedUser={{ label: "Dotta", image: null }}
grantsQuery={personalGrantsResponse(grant)}
loading={false}
error={false}
onConnectAsMe={() => undefined}
onConnectOrganization={() => undefined}
onReplaceAudience={() => undefined}
connectPending={false}
audiencePending={false}
audienceError={null}
audienceGrantId={null}
onOpenAudience={() => undefined}
onCloseAudience={() => undefined}
/>
}
/>
<AdvancedPanel
connection={connection}
appName="Notion"
galleryEntry={NOTION}
removing={false}
onRemove={() => undefined}
onReplaced={() => undefined}
appToggleDisabled={false}
onToggleApp={() => undefined}
identityGrant={grant}
identityCurrentUserId="board-user"
identityProviderName="Notion"
credentialPolicy="per_user"
onReconnectIdentity={() => undefined}
onRevokeIdentity={() => undefined}
/>
</div>
</header>
<SetupPanel
connection={notionConnection()}
galleryEntry={NOTION}
onToggleApp={() => undefined}
appToggleDisabled={false}
onUpdateConfig={() => undefined}
configUpdateDisabled={false}
/>
</div>
</div>
</QueryClientProvider>
);
}
@ -118,12 +289,15 @@ function ReconnectRequiredHost() {
<div className="mx-auto max-w-3xl p-6">
<header className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
<p className="mt-1 text-sm text-muted-foreground">Connection needs attention</p>
<p className="mt-1 text-sm text-muted-foreground">
Connection needs attention
</p>
</header>
<ReconnectCard
connection={notionConnection({
healthStatus: "failed",
healthMessage: "Notion authorization expired or was revoked (invalid_grant).",
healthMessage:
"Notion authorization expired or was revoked (invalid_grant).",
lastError: "invalid_grant",
})}
galleryEntry={NOTION}
@ -133,6 +307,33 @@ function ReconnectRequiredHost() {
);
}
function VercelConnectProvenanceHost() {
return (
<div className="mx-auto max-w-3xl p-6">
<ReconnectCard
connection={notionConnection({
credentialSource: "vercel_connect",
externalCredential: {
provider: "vercel_connect",
connectorId: "scl_storybook",
connectorUid: "notion-paperclip",
service: "notion",
connectorType: "oauth",
principalMode: "user",
headerName: "Authorization",
headerPrefix: "Bearer ",
scopes: ["*"],
},
healthStatus: "failed",
healthMessage: "This Vercel Connect identity needs authorization.",
})}
galleryEntry={NOTION}
onReconnected={() => undefined}
/>
</div>
);
}
const meta: Meta = {
title: "Apps/Notion MCP connect flow (PAP-16650)",
parameters: { layout: "fullscreen" },
@ -175,3 +376,8 @@ export const ReconnectRequired: Story = {
name: "6 — Reconnect required",
render: () => <ReconnectRequiredHost />,
};
export const VercelConnectReconnect: Story = {
name: "7 — Vercel Connect reconnect",
render: () => <VercelConnectProvenanceHost />,
};

View File

@ -17,6 +17,7 @@ import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interact
import { queryKeys } from "@/lib/queryKeys";
import { AgentToolsTab } from "@/pages/AgentToolsTab";
import { PermissionsPanel } from "@/pages/apps/app-detail/PermissionsPanel";
import type { AccessDraft } from "@/pages/apps/app-detail/types";
import { AccessStep } from "@/pages/apps/AppsConnect";
import type { InstallState } from "@/lib/tool-installs";
@ -137,11 +138,14 @@ function PanelHarness({
capabilities?: ToolConnectionCapabilities;
}) {
const [state, setState] = useState(install);
const [access, setAccess] = useState<AccessDraft>({ mode: "all", agentIds: new Set() });
return (
<div className="mx-auto max-w-3xl bg-background p-6">
<PermissionsPanel
capabilities={capabilities}
appName="Gmail"
agents={AGENTS}
access={access}
install={state}
readOnly={GMAIL_TOOLS.filter((t) => t.isReadOnly)}
canChange={GMAIL_TOOLS.filter((t) => !t.isReadOnly)}
@ -151,6 +155,7 @@ function PanelHarness({
pending={false}
installPending={false}
refreshPending={false}
onSaveAccess={setAccess}
onSaveInstall={setState}
onSetActionPermission={() => {}}
onReviewQuarantined={() => {}}

View File

@ -7,7 +7,6 @@ import type {
ToolConnectionCredentialPolicy,
} from "@paperclipai/shared";
import { IdentitiesSection } from "@/pages/apps/app-detail/IdentitiesSection";
import { actsAsSummary } from "@/pages/apps/connection-identity";
// ---------------------------------------------------------------------------
// PAP-17835 — personal connection identity UX review harness.
@ -100,11 +99,7 @@ function personalGrant(overrides: Partial<ConnectionGrant> = {}): ConnectionGran
});
}
/**
* Renders the identity surface the way the Setup tab does, including the header
* "acts as" sentence that sentence is the at-a-glance answer the design
* requires, so a screenshot of the rows alone would not show the whole state.
*/
/** Renders the fixed account surface used on the Setup tab. */
function IdentitiesHarness({
credentialPolicy = "per_user",
grants,
@ -130,30 +125,17 @@ function IdentitiesHarness({
currentUserId: CURRENT_USER,
members: MEMBERS,
};
const actsAs = actsAsSummary(credentialPolicy);
return (
<div className="mx-auto max-w-3xl bg-background p-6">
<header className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">Gmail</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{actsAs.title}</span>
{" · "}
{actsAs.detail}
</p>
</header>
<div className="mx-auto max-w-4xl bg-background p-8">
<IdentitiesSection
appName="Gmail"
providerName="Gmail"
credentialPolicy={credentialPolicy}
ownerUserId={CURRENT_USER}
connectedUser={{ label: "Carol", image: null }}
grantsQuery={loading || error ? undefined : response}
agents={[{ id: "agent-1", name: "Outreach agent", title: "Growth", status: "active" }]}
agentsLoading={false}
agentsError={false}
loading={loading}
error={error}
connectPending={false}
revokePending={false}
delegationPending={false}
audiencePending={false}
audienceError={audienceError}
audienceGrantId={openAudience}
@ -161,9 +143,6 @@ function IdentitiesHarness({
onCloseAudience={() => setOpenAudience(null)}
onConnectAsMe={() => {}}
onConnectOrganization={() => {}}
onReconnectOrganization={() => {}}
onRevokeGrant={() => {}}
onReplaceDelegations={() => {}}
onReplaceAudience={() => {}}
/>
</div>
@ -177,10 +156,10 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
// --- Gate 3: personal connected, organization missing ----------------------
// --- Gate 3: fixed personal identity ---------------------------------------
export const SetupPersonalConnectedOrganizationMissing: Story = {
name: "3 · Setup — your identity connected, organization missing",
export const SetupPersonalConnectedOrganizationHidden: Story = {
name: "3 · Setup — your identity connected, organization hidden",
render: () => (
<IdentitiesHarness
grants={[personalGrant({ providerTenant: { name: "carol@example.com" } })]}
@ -193,10 +172,10 @@ export const SetupPersonalNotConnected: Story = {
render: () => <IdentitiesHarness grants={[]} />,
};
// --- Gate 4: organization identity with a selected audience, manager view ---
// --- Gate 4: legacy alternates stay hidden after setup ---------------------
export const SetupManagerOversight: Story = {
name: "4 · Setup — selected audience + manager oversight",
export const SetupFixedPersonalIdentity: Story = {
name: "4 · Setup — fixed personal identity",
render: () => (
<IdentitiesHarness
credentialPolicy="per_user_with_fallback"
@ -273,6 +252,7 @@ export const SetupViewerReadOnly: Story = {
name: "7 · Setup — viewer read-only",
render: () => (
<IdentitiesHarness
credentialPolicy="shared"
capabilities={VIEWER_CAPABILITIES}
grants={[
grant({