From e0f30a4c7186498b71b2637f253b3399997dac7d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 13 Oct 2026 14:57:48 -0700 Subject: [PATCH] feat(apps): collapse provider connections behind an "N connections" toggle (#16097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps page shows one card for each provider. Each card lists the connections for that provider. > - Some providers have many connections. For example, a team can have five OpenAI accounts. > - Each connection uses a full row. Thus, the page becomes long and cluttered. > - This pull request puts the connection list behind a "N connections" row with a chevron. The user clicks the row to show or hide the list. > - The benefit is a shorter Apps page. Connections that need action stay visible. ## Linked Issues or Issue Description **Subsystem affected** UI: the Apps page (`ui/src/pages/apps/Browse.tsx`), the `ConnectorCard` component. **Problem or motivation** A provider card shows every connection as a full row. When a provider has many accounts, the card is very tall. The page is hard to scan. **Proposed solution** When a card has two or more connections, show a "N connections" row with a chevron below the card header. A click on the row opens or closes the list. The list is closed when all connections are healthy. The list is open when a connection needs attention or setup. The row also shows the count, for example "· 1 needs attention". The list is open while a search is active. A card with one connection does not show the row. **Alternatives considered** - Always show the list. This is the current behavior, and it causes the clutter. - Show the toggle for one connection too. This adds a click and gives no benefit. - Always close the list. This can hide an account that needs reconnection, so we did not use it. ## What Changed - `ConnectorCard` counts native connections, gateway-managed accounts (Composio, Arcade, Executor), and chat or bot endpoints. - When the count is two or more, the card wraps the list in the existing `Collapsible` component. The trigger row shows the count and a chevron that turns when the list opens. - The list opens by default when one or more connections need attention or setup. The trigger row shows how many need attention. - The `Browse` page sends `defaultExpanded` and the current trimmed search query while a search is active. A new search opens the list again, even after the user closed it. - The upstream account state logic moved into the `upstreamAccountState` helper. The list render and the attention count use the same helper. The behavior did not change. - The attention count and Finish setup action share one predicate. A verifying endpoint stays visible until setup is complete. Four regression cases cover provider setup, identity linking, missing setup metadata, and completed setup. - New tests in `Browse.test.tsx` cover: open and close with the toggle, search opens the list, one connection stays inline, and the list opens when an account needs attention. - New review story: `ui/storybook/stories/connection-setup/apps-connection-count-toggle.stories.tsx` ("Apps, channels, and extensions / Connection setup / Connection count toggle"). It includes production component fixtures for collapsed, expanded, needs-attention, search-match, single-connection, page-overview, and unfinished-chat-setup states. The original six story IDs are retained; the unfinished chat setup fixture adds one ID. ## Verification - `pnpm exec vitest run ui/src/pages/apps/Browse.test.tsx`: all 84 tests pass. The new test closes an active search result, changes the query, then returns to the first query. It fails before the search-reset fix and passes after it. - `node scripts/storybook-catalog.mjs --check`: passes (265 story files, 2,520 stories). Story annotations describe the production components, fixture boundary, and covered capabilities. - `pnpm check:token-gates`: passes. - Full repository typecheck and build passed after the master merge. The final UI typecheck passes after the setup fix. Final full build and Storybook build pass on `d4c923d3ce0`. The built index retains all six prior story IDs and adds the unfinished-chat-setup ID. Chromium checks pass for all seven states, including the setup action and toggle behavior. All 54 successful CI checks pass on this head, with two intentional skips, including all general and serialized test shards, E2E, typecheck, and build. The fresh Apex review is 5/5 on this head with no unresolved findings. The separate, unsharded local `pnpm test:run` invocation has not completed; complete suite verification comes from CI. - The stories use local account fixtures and stub navigation. They do not prove a live provider connection or persistence. ## Risks - Low risk. The change is UI only, with no API or data change. - Behavior change: users must click to see the connections on a healthy card with two or more connections. Cards with a problem stay open, so a broken connection is not hidden. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used in Claude Code with tool use (shell, file edit, test runs). OpenAI GPT-6 (`gpt-6`) through Codex assisted conflict resolution, regression tests, and repository verification. This session does not expose an exact context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Devin Foley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip --- ui/src/pages/apps/Browse.test.tsx | 89 +++++ ui/src/pages/apps/Browse.tsx | 327 ++++++++++-------- .../apps-connection-count-toggle.stories.tsx | 124 +++++++ 3 files changed, 399 insertions(+), 141 deletions(-) create mode 100644 ui/storybook/stories/connection-setup/apps-connection-count-toggle.stories.tsx 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" } } }), + ]), + ] }, +};