diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 494599f7a30..3a1b30589aa 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -474,6 +474,7 @@ describe("Connectors landing page", () => { await renderBrowse(false); expect(container.querySelectorAll('[data-app-slug="notion"]')).toHaveLength(1); const row = container.querySelector('[data-app-slug="notion"]')!; + await clickButton("5 connections", row); expect(row.querySelectorAll('button[aria-label="Manage Work connection"]')).toHaveLength(4); expect(row.textContent).toContain("Native"); for (const provider of ["Composio", "Arcade", "Executor"]) expect(row.textContent).toContain(`Managed by ${provider}`); @@ -602,6 +603,7 @@ describe("Connectors landing page", () => { listComposioAppsMock.mockImplementation((id: string) => Promise.resolve({ apps: [], sync: { status: id === "conn-work" ? "syncing" : "ready" } })); await renderBrowse(); expect(container.textContent).not.toContain("Refresh Composio"); + await clickButton("2 connections", container.querySelector('[data-app-slug="composio"]')!); let menu = await openComposioAccountMenu("Work Composio"); expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")?.getAttribute("aria-disabled")).toBe("true"); await act(() => { menu.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); @@ -1120,6 +1122,93 @@ describe("Connectors landing page", () => { expect(navigateMock).toHaveBeenCalledWith("/apps/advanced/paste-config"); }); + it("folds healthy multi-account lists behind a connection-count toggle", async () => { + listApplicationsMock.mockResolvedValue({ applications: [application()] }); + listConnectionsMock.mockResolvedValue({ + connections: [ + connection(), + connection({ id: "conn-ops", name: "ops@example.com" }), + connection({ id: "conn-eng", name: "eng@example.com" }), + ], + }); + await renderBrowse(); + + const notion = container.querySelector('[data-app-slug="notion"]')!; + const toggle = Array.from(notion.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "3 connections")!; + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(notion.textContent).not.toContain("ops@example.com"); + + await clickButton("3 connections", notion); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + for (const name of ["devinfoley@gmail.com", "ops@example.com", "eng@example.com"]) expect(notion.textContent).toContain(name); + + await clickButton("3 connections", notion); + expect(notion.textContent).not.toContain("ops@example.com"); + + await search("ops@example"); + expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("ops@example.com"); + }); + + it("reopens connection lists whenever the search query changes", async () => { + listApplicationsMock.mockResolvedValue({ applications: [application()] }); + listConnectionsMock.mockResolvedValue({ + connections: [connection(), connection({ id: "conn-ops", name: "ops@example.com" }), + connection({ id: "conn-eng", name: "eng@example.com" })], + }); + await renderBrowse(); + await search("ops@example"); + let notion = container.querySelector('[data-app-slug="notion"]')!; + expect(notion.textContent).toContain("ops@example.com"); + await clickButton("3 connections", notion); + expect(notion.textContent).not.toContain("ops@example.com"); + + await search("eng@example"); + notion = container.querySelector('[data-app-slug="notion"]')!; + expect(notion.textContent).toContain("eng@example.com"); + await search("ops@example"); + expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("ops@example.com"); + }); + + it("keeps a single account inline and opens lists that need attention", async () => { + listApplicationsMock.mockResolvedValue({ applications: [application()] }); + listConnectionsMock.mockResolvedValue({ connections: [connection()] }); + await renderBrowse(); + let notion = container.querySelector('[data-app-slug="notion"]')!; + expect(notion.textContent).toContain("devinfoley@gmail.com"); + expect(notion.querySelector('[data-slot="collapsible-trigger"]')).toBeNull(); + + act(() => root.unmount()); + listConnectionsMock.mockResolvedValue({ + connections: [connection(), connection({ id: "conn-draft", name: "draft@example.com", status: "draft" })], + }); + await renderBrowse(); + notion = container.querySelector('[data-app-slug="notion"]')!; + expect(notion.textContent).toContain("2 connections· 1 needs attention"); + expect(notion.textContent).toContain("draft@example.com"); + }); + + it.each(["provider_setup", "identity_link", undefined, "complete"])( + "keeps unfinished verifying chat setup visible for step %s", async (step) => { + chatListMock.mockResolvedValue([ + { id: "chat-active", provider: "slack", status: "active", assignedAgentName: "Active agent" }, + { id: "chat-verifying", provider: "slack", status: "verifying", assignedAgentName: "Setup agent", + setup: step === undefined ? undefined : { step } }, + ]); + await renderBrowse(); + const slack = container.querySelector('[data-app-slug="slack"]')!; + const trigger = slack.querySelector('[data-slot="collapsible-trigger"]')!; + const unfinished = step !== "complete"; + expect(trigger.getAttribute("aria-expanded")).toBe(String(unfinished)); + expect(slack.textContent?.includes("1 needs attention")).toBe(unfinished); + expect(slack.textContent?.includes("Finish setup")).toBe(unfinished); + if (unfinished) { + await clickButton("Finish setup", slack); + expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=slack&purpose=chat&resume=chat-verifying"); + } + }, + ); + it("sorts connected providers first and shows account, owner, status actions, and edit menus inline", async () => { listApplicationsMock.mockResolvedValue({ applications: [application()] }); listConnectionsMock.mockResolvedValue({ diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 4577cee140b..37385598800 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -71,6 +71,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, @@ -234,6 +235,24 @@ function connectionState(connection: ToolConnection): ConnectionState { return { kind: "connected", label: "Connected", message: null }; } +type UpstreamAppSnapshot = NonNullable[number]; + +function chatEndpointNeedsSetup(endpoint: ChatEndpoint): boolean { + return endpoint.status === "draft" + || (endpoint.status === "verifying" && endpoint.setup?.step !== "complete"); +} + +function upstreamAccountState(connection: ToolConnection, snapshot: UpstreamAppSnapshot, account: UpstreamAppSnapshot["accounts"][number]): ConnectionState { + const gatewayState = connectionState(connection); + return gatewayState.kind !== "connected" ? gatewayState + : snapshot.freshness === "stale" || snapshot.errorAt || Date.now() - new Date(snapshot.checkedAt).getTime() > 5 * 60_000 + ? { kind: "attention", label: "Not verified", message: "Last known account · Refresh to verify" } + : account.status === "UNVERIFIED" ? { kind: "attention", label: "Not verified", message: "Account found upstream · Authorization not verified" } + : account.status === "ACTIVE" ? { kind: "connected", label: "Connected", message: null } + : account.status === "INITIATED" ? { kind: "draft", label: "Waiting for sign-in", message: "Finish connecting this account." } + : { kind: "attention", label: "Needs sign-in", message: "Sign in again to restore access." }; +} + function connectionRank(connection: ToolConnection): number { return connection.status === "draft" ? 0 : 1; } @@ -961,6 +980,8 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne discoveryByConnection={discoveryByConnection} onConfigureArcade={connection => setArcadeToConfigure(connection)} onConfigureExecutor={connection => setExecutorToConfigure(connection)} + defaultExpanded={Boolean(trimmed)} + searchQuery={trimmed} /> ))} {showCustomConnector ? ( @@ -1061,6 +1082,8 @@ export function ConnectorCard({ discoveryByConnection, onConfigureArcade, onConfigureExecutor, + defaultExpanded = false, + searchQuery = "", }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode; row: ConnectorRowModel; @@ -1081,7 +1104,15 @@ export function ConnectorCard({ discoveryByConnection?: ReadonlyMap; onConfigureArcade?: (connection: ToolConnection) => void; onConfigureExecutor?: (connection: ToolConnection) => void; + /** Start with the connection list open, e.g. while a search is narrowing the catalog. */ + defaultExpanded?: boolean; + searchQuery?: string; }) { + // The user's toggle only applies while the default it overrode still holds, so a new search reopens the list. + const [expandedOverride, setExpandedOverride] = useState<{ open: boolean; defaultExpanded: boolean } | null>(null); + useEffect(() => { + setExpandedOverride(null); + }, [defaultExpanded, searchQuery]); const action = connectorAction( row, chatProviderVisible(row.slug, chatConnectorsEnabled, githubReviewBotsEnabled), @@ -1092,6 +1123,16 @@ export function ConnectorCard({ return connection ? snapshot.accounts.map((account, index) => ({ connection, account, snapshot, name: account.alias || (snapshot.accounts.length > 1 ? `${row.name} account ${index + 1}` : `${row.name} account`) })) : []; }); + const listedUpstreamAccounts = row.upstreamCatalogApp ? upstreamAccounts : []; + const connectionCount = row.connections.length + listedUpstreamAccounts.length + row.chatEndpoints.length; + const attentionCount = row.connections.filter(connection => ["attention", "draft"].includes(connectionState(connection).kind)).length + + listedUpstreamAccounts.filter(({ connection, snapshot, account }) => ["attention", "draft"].includes(upstreamAccountState(connection, snapshot, account).kind)).length + + row.chatEndpoints.filter(endpoint => ["attention", "revoked"].includes(endpoint.status) || chatEndpointNeedsSetup(endpoint)).length; + // A single connection stays inline; longer lists fold behind a "N connections" toggle, + // but start open when an account needs action so setup and reconnect stay visible. + const collapsible = connectionCount > 1; + const expanded = !collapsible || (expandedOverride?.defaultExpanded === defaultExpanded + ? expandedOverride.open : defaultExpanded || attentionCount > 0); return (
- {row.connections.length > 0 ? ( -
- {row.connections.map((connection) => ( - (onRefreshAggregator ?? onRefreshComposio)?.(connection.id) : undefined} - refreshingComposio={(aggregatorRefreshState ?? composioRefreshState)?.get(connection.id)} - discovery={discoveryByConnection?.get(connection.id)} - onConfigureArcade={onConfigureArcade ? () => onConfigureArcade(connection) : undefined} - onConfigureExecutor={onConfigureExecutor ? () => onConfigureExecutor(connection) : undefined} - onRemove={() => { - const accountName = connectionDisplayNameForOwner( - connection, - row.name, - connectionOwnerProfile(connection, userProfileById), - ); - onRequestRemove({ - id: connection.id, - pool: Boolean(aiConnectionRouterPluginKey(connection)), - accountName, - providerName: row.name, - remainingConnectionCount: row.connections.filter( - (candidate) => - candidate.id !== connection.id && - candidate.status === "active" && - candidate.enabled, - ).length, - }); - }} - /> - ))} -
- ) : null} - {row.upstreamCatalogApp && upstreamAccounts.length > 0 ?
- {upstreamAccounts.map(({ connection, account, name, snapshot }) => { - const gatewayState = connectionState(connection); - const state: ConnectionState = gatewayState.kind !== "connected" ? gatewayState - : snapshot.freshness === "stale" || snapshot.errorAt || Date.now() - new Date(snapshot.checkedAt).getTime() > 5 * 60_000 - ? { kind: "attention", label: "Not verified", message: "Last known account · Refresh to verify" } - : account.status === "UNVERIFIED" ? { kind: "attention", label: "Not verified", message: "Account found upstream · Authorization not verified" } - : account.status === "ACTIVE" ? { kind: "connected", label: "Connected", message: null } - : account.status === "INITIATED" ? { kind: "draft", label: "Waiting for sign-in", message: "Finish connecting this account." } - : { kind: "attention", label: "Needs sign-in", message: "Sign in again to restore access." }; - const provider = snapshot.provider ?? (isAppAggregator(connection.config?.sourceTemplateKey) ? connection.config?.sourceTemplateKey : "composio"); - const providerName = AGGREGATOR_NAMES[provider]; - const managementUrl = aggregatorManagementUrl(provider, account.managementUrl ?? (typeof connection.config?.managementUrl === "string" ? connection.config?.managementUrl : null)); - const manage = () => onManageAggregator?.(row.upstreamCatalogApp!, connection.id); - return Managed by {providerName} ·} - onOpen={manage} openLabel={`Manage ${name}`}> - - - {managementUrl ? Open in {providerName} - : Open in {providerName}} - - - ; - })} -
: null} - {row.chatEndpoints.length > 0 ? ( -
- {row.chatEndpoints.map((endpoint) => ( -
-
- -
- -

- {endpoint.providerAccountLabel ?? - endpoint.botLabel ?? - "Provider identity"} -

-
-
-
- - {["paused", "attention", "revoked"].includes(endpoint.status) && - {endpoint.status === "attention" ? "Needs attention" : endpoint.status === "revoked" ? "Access revoked" : "Paused"} - } - {endpoint.status === "draft" ? ( - - ) : null} - - - - + setExpandedOverride({ open, defaultExpanded })}> + {collapsible ? ( + + + ) : null} + + {row.connections.length > 0 ? ( +
+ {row.connections.map((connection) => ( + (onRefreshAggregator ?? onRefreshComposio)?.(connection.id) : undefined} + refreshingComposio={(aggregatorRefreshState ?? composioRefreshState)?.get(connection.id)} + discovery={discoveryByConnection?.get(connection.id)} + onConfigureArcade={onConfigureArcade ? () => onConfigureArcade(connection) : undefined} + onConfigureExecutor={onConfigureExecutor ? () => onConfigureExecutor(connection) : undefined} + onRemove={() => { + const accountName = connectionDisplayNameForOwner( + connection, + row.name, + connectionOwnerProfile(connection, userProfileById), + ); + onRequestRemove({ + id: connection.id, + pool: Boolean(aiConnectionRouterPluginKey(connection)), + accountName, + providerName: row.name, + remainingConnectionCount: row.connections.filter( + (candidate) => + candidate.id !== connection.id && + candidate.status === "active" && + candidate.enabled, + ).length, + }); + }} + /> + ))} +
+ ) : null} + {listedUpstreamAccounts.length > 0 ?
+ {listedUpstreamAccounts.map(({ connection, account, name, snapshot }) => { + const state = upstreamAccountState(connection, snapshot, account); + const provider = snapshot.provider ?? (isAppAggregator(connection.config?.sourceTemplateKey) ? connection.config?.sourceTemplateKey : "composio"); + const providerName = AGGREGATOR_NAMES[provider]; + const managementUrl = aggregatorManagementUrl(provider, account.managementUrl ?? (typeof connection.config?.managementUrl === "string" ? connection.config?.managementUrl : null)); + const manage = () => onManageAggregator?.(row.upstreamCatalogApp!, connection.id); + return Managed by {providerName} ·} + onOpen={manage} openLabel={`Manage ${name}`}> + - onNavigate(`/apps/chat/${endpoint.id}/settings`)}> - Manage - - - onRequestRemove({ - kind: "chat", - provider: endpoint.provider, - id: endpoint.id, - accountName: `${endpoint.assignedAgentName} · ${row.name}`, - providerName: row.name, - remainingConnectionCount: 0, - })} - > - - Remove connection - + {managementUrl ? Open in {providerName} + : Open in {providerName}} -
+ ; + })} +
: null} + {row.chatEndpoints.length > 0 ? ( +
+ {row.chatEndpoints.map((endpoint) => ( +
+
+ +
+ +

+ {endpoint.providerAccountLabel ?? + endpoint.botLabel ?? + "Provider identity"} +

+
+
+
+ + {["paused", "attention", "revoked"].includes(endpoint.status) && + {endpoint.status === "attention" ? "Needs attention" : endpoint.status === "revoked" ? "Access revoked" : "Paused"} + } + {chatEndpointNeedsSetup(endpoint) ? ( + + ) : null} + + + + + + onNavigate(`/apps/chat/${endpoint.id}/settings`)}> + Manage + + + onRequestRemove({ + kind: "chat", + provider: endpoint.provider, + id: endpoint.id, + accountName: `${endpoint.assignedAgentName} · ${row.name}`, + providerName: row.name, + remainingConnectionCount: 0, + })} + > + + Remove connection + + + +
+
+ ))}
- ))} -
- ) : null} + ) : null} + +
); } diff --git a/ui/storybook/stories/connection-setup/apps-connection-count-toggle.stories.tsx b/ui/storybook/stories/connection-setup/apps-connection-count-toggle.stories.tsx new file mode 100644 index 00000000000..0c38ddde864 --- /dev/null +++ b/ui/storybook/stories/connection-setup/apps-connection-count-toggle.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { getAppStoreDefinition, type ToolApplication, type ToolConnection } from "@paperclipai/shared"; +import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails"; +import { ConnectorCard } from "@/pages/apps/Browse"; +import type { ComponentProps } from "react"; + +type Row = ComponentProps["row"]; + +const PROFILES = new Map([ + ["user-dotta", { label: "Dotta B", image: null }], + ["user-nicky", { label: "Nicky Paperclip Admin", image: null }], + ["user-devin", { label: "Devin Foley", image: null }], + ["user-scott", { label: "Scott", image: null }], +]); + +function aiConnection(id: string, name: string, owner: string, provider: "openai" | "anthropic", method: "api_key" | "subscription", + shared: boolean, overrides: Partial = {}): ToolConnection { + return { + id, companyId: "company-storybook", applicationId: `app-${provider}`, uid: id, name, + connectionKind: "managed", connectionPurpose: "ai", ownership: "customer", transport: "http", authKind: method === "api_key" ? "api_key" : "oauth", + credentialSource: "paperclip_vault", credentialPolicy: shared ? "shared" : "per_user", status: "active", enabled: true, + config: { sourceTemplateKey: provider, aiIsolatedSubscription: true, ai: { provider, method } }, transportConfig: {}, credentialSecretRefs: [], + healthStatus: "ok", healthCheckedAt: new Date(), lastError: null, createdByAgentId: null, createdByUserId: owner, + createdAt: new Date(), updatedAt: new Date(), ...overrides, + } as ToolConnection; +} + +const OPENAI_ACCOUNTS = [ + aiConnection("dotta-key", "Dotta's OpenAI API Key", "user-dotta", "openai", "api_key", false), + aiConnection("nicky-sub", "Nicky’s OpenAI", "user-nicky", "openai", "subscription", true), + aiConnection("devin-sub", "Devin’s OpenAI", "user-devin", "openai", "subscription", true), + aiConnection("dotta-openai", "Dotta’s OpenAI", "user-dotta", "openai", "api_key", false), + aiConnection("scott-sub", "Scott's ChatGPT subscription account", "user-scott", "openai", "subscription", false), +]; + +function providerRow(slug: string, connections: ToolConnection[]): Row { + const entry = getAppStoreDefinition(slug)!; + const application: ToolApplication = { + id: `app-${slug}`, companyId: "company-storybook", name: entry.name, description: entry.description, type: "mcp_http", + status: "active", pluginId: null, ownerAgentId: null, ownerUserId: null, metadata: { sourceTemplateKey: slug }, + archivedAt: null, createdAt: new Date(), updatedAt: new Date(), + }; + return { + key: `native:${slug}`, slug, name: entry.name, description: entry.description, brandKey: slug, entry, + connections, applications: [application], chatEndpoints: [], logoUrl: entry.branding?.logoUrl, darkLogoUrl: entry.branding?.darkLogoUrl, + }; +} + +/** Design review: the shipped Apps page card with local example accounts. */ +function ConnectionCountToggleReview({ rows, defaultExpanded = false }: { rows: Row[]; defaultExpanded?: boolean }) { + return
+ {rows.map(row => {}} onRequestRemove={() => {}} + renderAccountDetails={connection => } />)} +
; +} + +const meta = { + id: "apps-connection-count-toggle", + title: "Apps, channels, and extensions/Connection setup/Connection count toggle", + component: ConnectionCountToggleReview, + parameters: { + layout: "fullscreen", + paperclip: { + capabilities: ["connection-setup/catalog", "app-permissions/account-maintenance"], + status: "current", + implementation: "production", + boundary: "fixtures", + notes: "Uses ConnectorCard with local account fixtures. Covers collapsed, expanded, needs-attention, unfinished chat setup, search, single-account and overview states. Navigation and account mutation callbacks are simulated; provider access and persistence are not exercised.", + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Collapsed: Story = { args: { rows: [providerRow("openai", OPENAI_ACCOUNTS)] } }; + +export const Expanded: Story = { + args: Collapsed.args, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: /5 connections/ })); + await expect(within(canvasElement).getByText("Scott's ChatGPT subscription account")).toBeVisible(); + }, +}; + +export const NeedsAttention: Story = { + args: { rows: [providerRow("openai", OPENAI_ACCOUNTS.map(connection => connection.id === "devin-sub" + ? { ...connection, healthStatus: "error", healthMessage: "Sign in again to restore access." } as ToolConnection : connection))] }, +}; + +export const SearchMatch: Story = { args: { rows: Collapsed.args.rows, defaultExpanded: true } }; + +export const UnfinishedChatSetup: Story = { + args: { rows: [{ + ...providerRow("slack", []), + chatEndpoints: [ + { id: "slack-active", companyId: "company-storybook", provider: "slack", status: "active", + assignedAgentId: "active-agent", assignedAgentName: "Active agent", allowUnlinkedPeople: false }, + { id: "slack-verifying", companyId: "company-storybook", provider: "slack", status: "verifying", + assignedAgentId: "setup-agent", assignedAgentName: "Setup agent", allowUnlinkedPeople: false, + setup: { step: "provider_setup" } }, + ], + }] }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByRole("button", { name: /2 connections/ })).toHaveAttribute("aria-expanded", "true"); + await expect(within(canvasElement).getByRole("button", { name: "Finish setup" })).toBeVisible(); + }, +}; + +export const SingleConnection: Story = { + args: { rows: [providerRow("anthropic", [aiConnection("dotta-claude", "Dotta’s Claude", "user-dotta", "anthropic", "subscription", false)])] }, +}; + +export const PageOverview: Story = { + args: { rows: [ + providerRow("anthropic", [aiConnection("dotta-claude", "Dotta’s Claude", "user-dotta", "anthropic", "subscription", false)]), + providerRow("openai", OPENAI_ACCOUNTS), + providerRow("openrouter", [ + aiConnection("router-team", "Team OpenRouter", "user-nicky", "openai", "api_key", true, { config: { sourceTemplateKey: "openrouter", ai: { provider: "openai", method: "api_key" } } }), + aiConnection("router-devin", "Devin’s OpenRouter", "user-devin", "openai", "api_key", false, { config: { sourceTemplateKey: "openrouter", ai: { provider: "openai", method: "api_key" } } }), + ]), + ] }, +};