From c90d90477902435e19249d02abc53da6a2f09ac0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:08:35 -0500 Subject: [PATCH] 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 --- tests/e2e/app-not-connected.spec.ts | 5 +- .../e2e/application-delete-screenshot.spec.ts | 3 +- tests/e2e/applications-crud.spec.ts | 21 +- tests/e2e/apps-dark-mode-shots.spec.ts | 7 +- tests/e2e/apps-prosumer-mcp-flow.spec.ts | 6 +- tests/e2e/connection-intents.spec.ts | 373 ++ ui/src/App.test.tsx | 3 + ui/src/App.tsx | 15 +- ui/src/api/connection-intents.ts | 8 + ui/src/api/tools.ts | 39 +- ui/src/components/AppConnectionSidebar.tsx | 2 + ui/src/components/AppsSidebar.test.tsx | 7 +- ui/src/components/AppsSidebar.tsx | 11 +- .../ConnectionIntentInteractionBody.tsx | 178 - .../IssueThreadInteractionCard.test.tsx | 88 +- .../components/IssueThreadInteractionCard.tsx | 18 +- ui/src/components/Layout.test.tsx | 2 +- ui/src/components/Layout.tsx | 1 + .../ConnectionIntentInteractionBody.test.tsx | 347 ++ .../ConnectionIntentInteractionBody.tsx | 334 ++ .../ConnectionSetupFlow.architecture.test.ts | 26 + .../ConnectionSetupFlow.oauth.test.ts | 226 + .../connections/ConnectionSetupFlow.tsx | 3811 +++++++++++++++++ .../issueThreadInteractionFixtures.ts | 140 +- ui/src/lib/app-gallery-copy.ts | 32 +- ui/src/lib/issue-chat-messages.test.ts | 533 ++- ui/src/lib/issue-chat-messages.ts | 30 +- ui/src/lib/issue-thread-interactions.ts | 20 +- ui/src/lib/react-root.test.ts | 20 + ui/src/lib/react-root.ts | 24 + ui/src/main.tsx | 7 +- ui/src/pages/DesignGuide.tsx | 45 + ui/src/pages/apps/AppDetail.test.tsx | 399 +- ui/src/pages/apps/AppDetail.tsx | 334 +- ui/src/pages/apps/AppLogo.test.tsx | 71 + ui/src/pages/apps/AppLogo.tsx | 48 +- ui/src/pages/apps/AppNotConnected.test.tsx | 112 + ui/src/pages/apps/AppNotConnected.tsx | 100 +- ui/src/pages/apps/AppsConnect.test.tsx | 1038 ++++- ui/src/pages/apps/AppsConnect.tsx | 2474 +---------- ui/src/pages/apps/Browse.test.tsx | 384 +- ui/src/pages/apps/Browse.tsx | 525 ++- ui/src/pages/apps/ComposioProvenanceChip.tsx | 31 +- ui/src/pages/apps/Connections.test.tsx | 16 +- ui/src/pages/apps/Connections.tsx | 30 +- .../apps/agent-selector-contract.test.ts | 4 +- ui/src/pages/apps/app-connect-policy.test.ts | 51 +- ui/src/pages/apps/app-connect-policy.ts | 45 +- ui/src/pages/apps/app-definition-display.ts | 4 + .../pages/apps/app-detail/ActivityPanel.tsx | 4 +- .../app-detail/AdvancedPanel.render.test.tsx | 42 +- .../pages/apps/app-detail/AdvancedPanel.tsx | 379 +- .../apps/app-detail/IdentitiesSection.tsx | 315 +- .../apps/app-detail/PermissionsPanel.tsx | 267 +- ui/src/pages/apps/app-detail/ReviewPanel.tsx | 3 +- ui/src/pages/apps/app-detail/SetupPanel.tsx | 94 +- .../pages/apps/app-detail/TestPanel.test.tsx | 5 +- ui/src/pages/apps/app-detail/TestPanel.tsx | 72 +- .../app-detail/action-permission-summary.ts | 41 + ui/src/pages/apps/app-tabs.ts | 2 +- ui/src/pages/apps/connection-identity.ts | 41 +- ui/src/pages/apps/generic-mcp-connect.test.ts | 18 + ui/src/pages/apps/generic-mcp-connect.ts | 19 + ui/src/pages/apps/store-cards.tsx | 2 +- ui/src/pages/tools/PasteConfigTab.test.tsx | 4 +- ui/src/pages/tools/PasteConfigTab.tsx | 2 +- ui/storybook/.storybook/preview.tsx | 167 +- .../stories/access-profiles.stories.tsx | 4 +- .../issue-thread-interactions.stories.tsx | 452 +- .../stories/notion-connect-flow.stories.tsx | 260 +- .../permitted-vs-installed.stories.tsx | 5 + .../personal-connection-identity.stories.tsx | 42 +- 72 files changed, 10106 insertions(+), 4182 deletions(-) create mode 100644 tests/e2e/connection-intents.spec.ts delete mode 100644 ui/src/components/ConnectionIntentInteractionBody.tsx create mode 100644 ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx create mode 100644 ui/src/features/connections/ConnectionIntentInteractionBody.tsx create mode 100644 ui/src/features/connections/ConnectionSetupFlow.architecture.test.ts create mode 100644 ui/src/features/connections/ConnectionSetupFlow.oauth.test.ts create mode 100644 ui/src/features/connections/ConnectionSetupFlow.tsx create mode 100644 ui/src/lib/react-root.test.ts create mode 100644 ui/src/lib/react-root.ts create mode 100644 ui/src/pages/apps/AppLogo.test.tsx create mode 100644 ui/src/pages/apps/app-detail/action-permission-summary.ts diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts index 9eaa9be013..60328294c5 100644 --- a/tests/e2e/app-not-connected.spec.ts +++ b/tests/e2e/app-not-connected.spec.ts @@ -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(); diff --git a/tests/e2e/application-delete-screenshot.spec.ts b/tests/e2e/application-delete-screenshot.spec.ts index 1bdb468362..cfb15c7acb 100644 --- a/tests/e2e/application-delete-screenshot.spec.ts +++ b/tests/e2e/application-delete-screenshot.spec.ts @@ -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 }); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index db89eb6239..75a729a757 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -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(); diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index c8eb1199ef..c7afab7f1f 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -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(); diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts index ea663c476b..ea8335da80 100644 --- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts +++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts @@ -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 }); diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts new file mode 100644 index 0000000000..65d12deec0 --- /dev/null +++ b/tests/e2e/connection-intents.spec.ts @@ -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; +type Seed = { companyId: string; prefix: string }; +type Agent = { id: string; name: string }; + +async function json( + response: Awaited>, +): Promise { + 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 { + 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 { + return await json( + 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 ?? ""), + 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((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>( + 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>( + 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>( + 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(); + } +}); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 56bf0c032a..73b591f416 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -250,6 +250,9 @@ describe("Apps routes", () => { expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); + expect(appSource).toContain('path="apps/vercel-connect"'); + expect(appSource).toContain(''); + expect(appSource).toContain(''); expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e1184a6452..bc88dd5211 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } + /> } /> } /> } /> @@ -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) ? : ; + return canEnterAppsConnect(searchParams) + ? + : ; } function InboxRootRedirect() { diff --git a/ui/src/api/connection-intents.ts b/ui/src/api/connection-intents.ts index 7407dd64fb..19605178ce 100644 --- a/ui/src/api/connection-intents.ts +++ b/ui/src/api/connection-intents.ts @@ -9,6 +9,14 @@ export const connectionIntentsApi = { api.get( `/connection-intents/${interactionId}/setup-options`, ), + setPhase: ( + interactionId: string, + phase: ConnectionIntentInteraction["payload"]["phase"], + ) => + api.post( + `/connection-intents/${interactionId}/phase`, + { phase }, + ), complete: (interactionId: string, connectionId: string) => api.post( `/connection-intents/${interactionId}/complete`, diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index fa2ac051c5..bd7ec6f241 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -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 & { @@ -268,12 +279,30 @@ export const toolsApi = { // --- Applications --- listGallery: (companyId: string) => api.get(`/companies/${companyId}/tools/gallery`), + preflightAppMetadata: (companyId: string, galleryKey: string, methodKey?: string | null) => { + const query = methodKey ? `?methodKey=${encodeURIComponent(methodKey)}` : ""; + return api.get( + `/companies/${companyId}/tools/apps/${encodeURIComponent(galleryKey)}/preflight${query}`, + ); + }, connectApp: (companyId: string, input: ConnectToolApp) => api.post(`/companies/${companyId}/tools/apps/connect`, input), - startOAuth: (connectionId: string, interactionId?: string) => - api.post( - `/tools/oauth/${connectionId}/start`, - interactionId ? { interactionId } : {}, + startOAuth: ( + connectionId: string, + input: { + asCurrentUser?: boolean; + interactionId?: string; + } = {}, + ) => + api.post(`/tools/oauth/${connectionId}/start`, input), + finalizeOAuthAccess: ( + companyId: string, + connectionId: string, + input: { grantKind: ConnectionGrantKind }, + ) => + api.post( + `/companies/${companyId}/tools/apps/${connectionId}/finalize-oauth-access`, + input, ), finishApp: (companyId: string, connectionId: string, input: { enabledCatalogEntryIds: string[]; diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx index 5189f7a2dd..762d532c5e 100644 --- a/ui/src/components/AppConnectionSidebar.tsx +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -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} /> {appName} diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx index 45e3c6621a..9a6fc0f61e 100644 --- a/ui/src/components/AppsSidebar.test.tsx +++ b/ui/src/components/AppsSidebar.test.tsx @@ -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" })); diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index bca01e789a..729e64de93 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -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 (