mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
feat(apps): collapse provider connections behind an "N connections" toggle (#16097)
## 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 <noreply@paperclip.ing>
This commit is contained in:
3 files changed
+399
-141
No files matched your search
@@ -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<HTMLElement>('[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<HTMLElement>('[data-app-slug="notion"]')!;
|
||||
const toggle = Array.from(notion.querySelectorAll<HTMLButtonElement>("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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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({
|
||||
|
||||
+186
-141
@@ -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<ConnectorRowModel["upstreamApps"]>[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<string, AggregatorAppsResponse | undefined>;
|
||||
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 (
|
||||
<div
|
||||
role="listitem"
|
||||
@@ -1148,151 +1189,155 @@ export function ConnectorCard({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{row.connections.length > 0 ? (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{row.connections.map((connection) => (
|
||||
<ConnectionAccountRow
|
||||
details={renderAccountDetails?.(connection)}
|
||||
key={connection.id}
|
||||
row={row}
|
||||
connection={connection}
|
||||
owner={connectionOwnerProfile(connection, userProfileById)}
|
||||
onNavigate={onNavigate}
|
||||
onRefreshComposio={(aggregatorRefreshState ?? composioRefreshState)?.has(connection.id) && (onRefreshAggregator ?? onRefreshComposio)
|
||||
? () => (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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{row.upstreamCatalogApp && upstreamAccounts.length > 0 ? <div className="divide-y divide-border border-t border-border">
|
||||
{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 <ConnectionAccountRowLayout key={`${connection.id}:${snapshot.toolkit}:${account.id}`} state={state} accountName={name}
|
||||
source={<><AppLogo name={providerName} brandKey={provider} size={16} compact /><span>Managed by {providerName} ·</span><button type="button" className="max-w-full truncate hover:underline" onClick={() => onNavigate(`/apps/${connection.id}/permissions`)}>“{connection.name}”</button></>}
|
||||
onOpen={manage} openLabel={`Manage ${name}`}>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`Manage ${name} connection`}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{managementUrl ? <DropdownMenuItem asChild><a href={managementUrl} target="_blank" rel="noopener noreferrer">Open in {providerName}<ExternalLink className="size-4" /></a></DropdownMenuItem>
|
||||
: <DropdownMenuItem disabled title="Set the management URL on this gateway to open its console">Open in {providerName}<ExternalLink className="size-4" /></DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ConnectionAccountRowLayout>;
|
||||
})}
|
||||
</div> : null}
|
||||
{row.chatEndpoints.length > 0 ? (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{row.chatEndpoints.map((endpoint) => (
|
||||
<div
|
||||
key={endpoint.id}
|
||||
className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<AgentAvatar agent={chatAgentById?.get(endpoint.assignedAgentId) ?? { id: endpoint.assignedAgentId, name: endpoint.assignedAgentName }}
|
||||
size={40} label={`${endpoint.assignedAgentName} avatar`} />
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-full truncate text-left text-sm font-medium hover:underline"
|
||||
onClick={() =>
|
||||
onNavigate(`/apps/chat/${endpoint.id}/settings`)
|
||||
}
|
||||
>
|
||||
{endpoint.assignedAgentName} · {endpoint.provider === "agentmail" ? "Email" : endpoint.provider === "github" ? "Code review bot" : "Chat"}
|
||||
</button>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{endpoint.providerAccountLabel ??
|
||||
endpoint.botLabel ??
|
||||
"Provider identity"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<ConnectionOwnerIdentity owner={connectionOwnerProfile(
|
||||
{ createdByUserId: (endpoint.connectionId ? connectionById?.get(endpoint.connectionId)?.createdByUserId : null) ?? endpoint.sponsorUserId ?? null }, userProfileById,
|
||||
)} />
|
||||
{["paused", "attention", "revoked"].includes(endpoint.status) && <span className="text-xs text-muted-foreground">
|
||||
{endpoint.status === "attention" ? "Needs attention" : endpoint.status === "revoked" ? "Access revoked" : "Paused"}
|
||||
</span>}
|
||||
{endpoint.status === "draft" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onNavigate(`/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`)}
|
||||
>
|
||||
Finish setup
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Manage ${endpoint.assignedAgentName} ${row.name} connection`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Collapsible open={expanded} onOpenChange={open => setExpandedOverride({ open, defaultExpanded })}>
|
||||
{collapsible ? (
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 border-t border-border px-4 py-3 text-left text-sm text-muted-foreground hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition-transform${expanded ? " rotate-90" : ""}`} aria-hidden="true" />
|
||||
<span className="font-medium text-foreground">{connectionCount} connections</span>
|
||||
{attentionCount > 0 ? <span className="text-destructive">· {attentionCount} {attentionCount === 1 ? "needs" : "need"} attention</span> : null}
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{row.connections.length > 0 ? (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{row.connections.map((connection) => (
|
||||
<ConnectionAccountRow
|
||||
details={renderAccountDetails?.(connection)}
|
||||
key={connection.id}
|
||||
row={row}
|
||||
connection={connection}
|
||||
owner={connectionOwnerProfile(connection, userProfileById)}
|
||||
onNavigate={onNavigate}
|
||||
onRefreshComposio={(aggregatorRefreshState ?? composioRefreshState)?.has(connection.id) && (onRefreshAggregator ?? onRefreshComposio)
|
||||
? () => (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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{listedUpstreamAccounts.length > 0 ? <div className="divide-y divide-border border-t border-border">
|
||||
{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 <ConnectionAccountRowLayout key={`${connection.id}:${snapshot.toolkit}:${account.id}`} state={state} accountName={name}
|
||||
source={<><AppLogo name={providerName} brandKey={provider} size={16} compact /><span>Managed by {providerName} ·</span><button type="button" className="max-w-full truncate hover:underline" onClick={() => onNavigate(`/apps/${connection.id}/permissions`)}>“{connection.name}”</button></>}
|
||||
onOpen={manage} openLabel={`Manage ${name}`}>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`Manage ${name} connection`}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onNavigate(`/apps/chat/${endpoint.id}/settings`)}>
|
||||
Manage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onRequestRemove({
|
||||
kind: "chat",
|
||||
provider: endpoint.provider,
|
||||
id: endpoint.id,
|
||||
accountName: `${endpoint.assignedAgentName} · ${row.name}`,
|
||||
providerName: row.name,
|
||||
remainingConnectionCount: 0,
|
||||
})}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove connection
|
||||
</DropdownMenuItem>
|
||||
{managementUrl ? <DropdownMenuItem asChild><a href={managementUrl} target="_blank" rel="noopener noreferrer">Open in {providerName}<ExternalLink className="size-4" /></a></DropdownMenuItem>
|
||||
: <DropdownMenuItem disabled title="Set the management URL on this gateway to open its console">Open in {providerName}<ExternalLink className="size-4" /></DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ConnectionAccountRowLayout>;
|
||||
})}
|
||||
</div> : null}
|
||||
{row.chatEndpoints.length > 0 ? (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{row.chatEndpoints.map((endpoint) => (
|
||||
<div
|
||||
key={endpoint.id}
|
||||
className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<AgentAvatar agent={chatAgentById?.get(endpoint.assignedAgentId) ?? { id: endpoint.assignedAgentId, name: endpoint.assignedAgentName }}
|
||||
size={40} label={`${endpoint.assignedAgentName} avatar`} />
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-full truncate text-left text-sm font-medium hover:underline"
|
||||
onClick={() =>
|
||||
onNavigate(`/apps/chat/${endpoint.id}/settings`)
|
||||
}
|
||||
>
|
||||
{endpoint.assignedAgentName} · {endpoint.provider === "agentmail" ? "Email" : endpoint.provider === "github" ? "Code review bot" : "Chat"}
|
||||
</button>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{endpoint.providerAccountLabel ??
|
||||
endpoint.botLabel ??
|
||||
"Provider identity"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<ConnectionOwnerIdentity owner={connectionOwnerProfile(
|
||||
{ createdByUserId: (endpoint.connectionId ? connectionById?.get(endpoint.connectionId)?.createdByUserId : null) ?? endpoint.sponsorUserId ?? null }, userProfileById,
|
||||
)} />
|
||||
{["paused", "attention", "revoked"].includes(endpoint.status) && <span className="text-xs text-muted-foreground">
|
||||
{endpoint.status === "attention" ? "Needs attention" : endpoint.status === "revoked" ? "Access revoked" : "Paused"}
|
||||
</span>}
|
||||
{chatEndpointNeedsSetup(endpoint) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onNavigate(`/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`)}
|
||||
>
|
||||
Finish setup
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Manage ${endpoint.assignedAgentName} ${row.name} connection`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onNavigate(`/apps/chat/${endpoint.id}/settings`)}>
|
||||
Manage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onRequestRemove({
|
||||
kind: "chat",
|
||||
provider: endpoint.provider,
|
||||
id: endpoint.id,
|
||||
accountName: `${endpoint.assignedAgentName} · ${row.name}`,
|
||||
providerName: row.name,
|
||||
remainingConnectionCount: 0,
|
||||
})}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove connection
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof ConnectorCard>["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> = {}): 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 <main className="mx-auto flex max-w-3xl flex-col gap-3 p-4 sm:p-6" role="list">
|
||||
{rows.map(row => <ConnectorCard key={row.key} row={row} userProfileById={PROFILES} chatConnectorsEnabled
|
||||
defaultExpanded={defaultExpanded} onNavigate={() => {}} onRequestRemove={() => {}}
|
||||
renderAccountDetails={connection => <ManagedAiConnectionRow connection={connection} />} />)}
|
||||
</main>;
|
||||
}
|
||||
|
||||
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<typeof ConnectionCountToggleReview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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" } } }),
|
||||
]),
|
||||
] },
|
||||
};
|
||||
Reference in new issue
Block a user