feat(connectors): show and select personal AI account defaults (#19845)

Show a crown on each saved personal AI provider default. Add a provider-specific menu action to other eligible accounts. Share saved-state queries, block duplicate writes, and keep the saved crown until a write succeeds.

Keep account details available for accessible grants. Show session lookup errors with Retry. Resolve list permissions once per request and index account lookups. Add UI and API regression coverage and account documentation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-07 10:26:08 -06:00
1 parent 824b25654c
commit 377bc87df1
18 files changed
+742 -59

No files matched your search

+6 -1
View File
@@ -57,6 +57,11 @@ const sessionQueryOptions = {
queryFn: () => authApi.getSession(),
} as const;
/** Observe the shared session read when a surface needs error and retry state. */
export function useAccountSessionQuery() {
return useQuery(sessionQueryOptions);
}
/**
* The signed-in account, as the cache currently understands it.
*
@@ -79,7 +84,7 @@ export function useAccountIdentity(): {
bootstrap: BoardBootstrap | undefined;
updatedAt: number;
} {
const { data: session, isSuccess, isError, dataUpdatedAt } = useQuery(sessionQueryOptions);
const { data: session, isSuccess, isError, dataUpdatedAt } = useAccountSessionQuery();
const userId = session?.user.id ?? null;
return {
userId, settled: isSuccess, failed: isError, updatedAt: dataUpdatedAt,
@@ -1,20 +1,23 @@
import { useState, type ReactNode } from "react";
import { RefreshCw, Star, Unplug } from "lucide-react";
import { RefreshCw, Unplug } from "lucide-react";
import type { ConnectionGrant } from "@paperclipai/shared";
import { RevokeGrantDialog } from "@/pages/apps/app-detail/IdentitiesSection";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AiConnectionSummary } from "./model";
import { AI_PROVIDERS, type AiConnectionSummary } from "./model";
import { describeError } from "@/api/errors";
import { AiDefaultCrown } from "./AiConnectionDefault";
import { isOwnAiAccount } from "./useAiConnectionDefaults";
/** AI-only account controls; identity, access and navigation belong to AppDetail. */
export function AiConnectionAccountControls({
account, grant, currentUserId, readOnly, onMakeDefault, onReconnect, onRevoke, revocationDetails,
account, grant, currentUserId, readOnly, defaultPending, onMakeDefault, onReconnect, onRevoke, revocationDetails,
}: {
account: AiConnectionSummary;
grant: ConnectionGrant;
currentUserId: string;
readOnly?: boolean;
defaultPending?: boolean;
onMakeDefault: () => void;
onReconnect: () => void;
onRevoke: () => void | Promise<void>;
@@ -23,22 +26,23 @@ export function AiConnectionAccountControls({
const [revoking, setRevoking] = useState(false);
const [revokePending, setRevokePending] = useState(false);
const [revokeError, setRevokeError] = useState<string>();
const ownPersonal = account.ownership === "personal" && account.ownerUserId === currentUserId;
const eligibleForDefault = isOwnAiAccount(account, currentUserId);
const available = account.status === "connected";
const activeDefault = account.isDefault && available;
const providerName = AI_PROVIDERS[account.provider].name;
return (
<section className="space-y-4" aria-label="AI account settings">
{ownPersonal && !account.routing && (
{eligibleForDefault && (
<div className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-3",
activeDefault && "border-(--status-task-done)/30 bg-(--status-task-done)/5",
)}>
<div className="flex items-center gap-3">
<Star aria-hidden className={cn("size-5 shrink-0", activeDefault ? "fill-current text-(--status-task-icon-done)" : "text-muted-foreground")} />
<h3 role={account.isDefault ? "status" : undefined} className={cn("text-sm font-semibold", account.isDefault && !available && "text-destructive")}>{account.isDefault && !available ? "Default unavailable" : "Personal default"}</h3>
{account.isDefault && <AiDefaultCrown account={account} />}
<h3 role={account.isDefault ? "status" : undefined} className={cn("text-sm font-semibold", account.isDefault && !available && "text-destructive")}>{account.isDefault ? available ? `Default for ${providerName}` : "Default unavailable" : `Your ${providerName} default`}</h3>
</div>
{!account.isDefault && (!readOnly ? (
<Button variant="outline" size="sm" disabled={!available} onClick={onMakeDefault}>Make default</Button>
{!account.isDefault && (!readOnly && account.canSetDefault ? (
<Button variant="outline" size="sm" disabled={!available || Boolean(account.unavailableReason) || defaultPending} onClick={onMakeDefault}>Set as default for {providerName}</Button>
) : <span className="text-xs text-muted-foreground">Not default</span>)}
</div>
)}
@@ -6,8 +6,10 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { AiConnectionAccountIdentity, AiManagedConnectionSummary, ConnectionGrant, ToolConnection } from "@paperclipai/shared";
import { AiConnectionAccountDetails } from "./AiConnectionAccountDetails";
import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails";
import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn(async () => ({ user: { id: "user" } })) } }));
const account: AiManagedConnectionSummary = {
id: "connection", grantId: "grant", companyId: "company", provider: "anthropic", method: "subscription",
@@ -59,9 +61,9 @@ afterEach(async () => {
async function render(value = account, selectedGrant = grant, completePanel = false) {
await act(async () => {
root.render(<QueryClientProvider client={client}>{completePanel
root.render(<QueryClientProvider client={client}><TooltipProvider>{completePanel
? <ManagedAiConnectionDetails connection={{ id: value.id, companyId: value.companyId } as ToolConnection} />
: <AiConnectionAccountDetails account={value} grant={selectedGrant} />}</QueryClientProvider>);
: <AiConnectionAccountDetails account={value} grant={selectedGrant} />}</TooltipProvider></QueryClientProvider>);
});
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
@@ -79,7 +81,7 @@ it("shows the provider email and organization separately from the Paperclip owne
it("explains profile permission denial while preserving connected account controls", async () => {
observation = { ...known, status: "unavailable", accountLabel: null, organizationLabel: null, errorCode: "permission_denied", message: "Anthropic did not permit this connection to read the account email." };
await render(account, grant, true);
expect(host.textContent).toContain("Personal default");
expect(host.textContent).toContain("Default for Claude");
expect(host.textContent).toContain("Reconnect");
expect(host.textContent).toContain("did not permit");
expect(host.textContent).not.toContain("avery@example.test");
@@ -91,12 +93,12 @@ it("explains profile permission denial while preserving connected account contro
it("keeps maintenance controls visible during a stalled lookup and recovers only the identity request", async () => {
blocked = true;
await render(account, grant, true);
expect(host.textContent).toContain("Personal default");
expect(host.textContent).toContain("Default for Claude");
expect(host.textContent).toContain("Reconnect");
expect(host.textContent).toContain("Checking account details…");
await act(async () => { await vi.advanceTimersByTimeAsync(20_001); });
expect(signals[0].aborted).toBe(true);
expect(host.textContent).toContain("Personal default");
expect(host.textContent).toContain("Default for Claude");
expect(host.textContent).not.toContain("Checking account details…");
const ordinaryCalls = fetchMock.mock.calls.length - identityCalls().length;
blocked = false;
@@ -0,0 +1,44 @@
import { useState } from "react";
import { Crown, Loader2 } from "lucide-react";
import type { AiManagedConnectionSummary } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AI_PROVIDERS } from "./model";
export function AiDefaultCrown({ account }: { account: Pick<AiManagedConnectionSummary, "provider"> }) {
const [open, setOpen] = useState(false);
const label = `Your default for ${AI_PROVIDERS[account.provider].name}`;
return <Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button type="button" aria-label={label} className="shrink-0 rounded-sm text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={event => { event.preventDefault(); setOpen(true); }}>
<Crown className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>;
}
export function AiDefaultMenuItem({ account, pending, onSelect }: {
account: AiManagedConnectionSummary; pending: boolean; onSelect: () => void;
}) {
const provider = AI_PROVIDERS[account.provider].name;
const reason = account.unavailableReason ?? (account.status !== "connected" ? "Reconnect this account before making it your default." : null);
return <DropdownMenuItem disabled={account.isDefault || pending || Boolean(reason)} onSelect={onSelect}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Crown aria-hidden="true" />}
<span className="flex min-w-0 flex-col gap-1">
<span>{account.isDefault ? `Default for ${provider}` : `Set as default for ${provider}`}</span>
{!account.isDefault && reason && <span className="text-xs text-muted-foreground">{reason}</span>}
</span>
</DropdownMenuItem>;
}
export function AiDefaultError({ provider, pending, onRetry }: {
provider: string; pending: boolean; onRetry: () => void;
}) {
return <div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-destructive">
<p>Couldn’t change your {provider} default.</p>
<Button variant="outline" size="sm" disabled={pending} onClick={onRetry}>Retry</Button>
</div>;
}
@@ -5,12 +5,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { AiManagedConnectionSummary, ToolConnection } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { authApi } from "@/api/auth";
import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails";
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn() } }));
vi.mock("./AiConnectionAccountControls", () => ({
AiConnectionAccountControls: ({ account }: { account: AiManagedConnectionSummary }) => (
<section aria-label="AI account settings">{account.name}</section>
AiConnectionAccountControls: ({ account, grant }: { account: AiManagedConnectionSummary; grant: { id: string } }) => (
<section aria-label="AI account settings" data-grant-id={grant.id}>{account.name}</section>
),
}));
vi.mock("./AiConnectionUsagePanel", () => ({ AiConnectionUsagePanel: () => null }));
@@ -38,6 +40,7 @@ beforeEach(() => {
blocked = null;
denied = false;
signals = [];
vi.mocked(authApi.getSession).mockReset().mockResolvedValue({ user: { id: "user" } } as Awaited<ReturnType<typeof authApi.getSession>>);
fetchMock.mockReset().mockImplementation((url: string, init: RequestInit) => {
if (url.endsWith("/active-runs")) return Promise.resolve(Response.json([]));
const kind = url.endsWith("/grants") ? "grants" : "accounts";
@@ -49,7 +52,7 @@ beforeEach(() => {
if (denied) return Promise.resolve(Response.json({ error: "Access denied" }, { status: 403 }));
return Promise.resolve(Response.json(kind === "grants" ? grants : accounts));
});
client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity } } });
client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity, retry: false } } });
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
@@ -67,7 +70,8 @@ async function render() {
await act(async () => {
root.render(<QueryClientProvider client={client}><ManagedAiConnectionDetails connection={connection} /></QueryClientProvider>);
});
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
}
it("reuses the grants already loaded by the permissions page", async () => {
@@ -77,6 +81,73 @@ it("reuses the grants already loaded by the permissions page", async () => {
expect(fetchMock.mock.calls.some(([url]) => url.endsWith("/grants"))).toBe(false);
});
it("reports a failed session lookup and retries it before loading account data", async () => {
vi.mocked(authApi.getSession).mockRejectedValueOnce(new Error("Session lookup failed"));
await render();
expect(host.querySelector('[role="alert"]')).not.toBeNull();
expect(host.textContent).not.toContain("Loading AI account…");
expect(host.querySelector("button")?.textContent).toBe("Retry");
const accountReads = () => fetchMock.mock.calls.filter(([url]) => url.endsWith("/ai-connections"));
expect(accountReads()).toHaveLength(0);
expect(client.getQueryData(["ai-connections", "company", undefined, null])).toBeUndefined();
await act(async () => {
host.querySelector("button")!.click();
await vi.advanceTimersByTimeAsync(10);
});
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
expect(authApi.getSession).toHaveBeenCalledTimes(2);
expect(accountReads()).toHaveLength(1);
expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name);
expect(host.querySelector('[role="alert"]')).toBeNull();
});
it("waits for session recovery before retrying an earlier account error", async () => {
await render();
denied = true;
await act(async () => {
await client.invalidateQueries({ queryKey: ["ai-connections"] });
await vi.advanceTimersByTimeAsync(10);
});
vi.mocked(authApi.getSession).mockRejectedValue(new Error("Session lookup failed"));
await act(async () => {
await client.invalidateQueries({ queryKey: queryKeys.auth.session });
await vi.advanceTimersByTimeAsync(10);
});
const calls = fetchMock.mock.calls.length;
let recover!: (session: Awaited<ReturnType<typeof authApi.getSession>>) => void;
vi.mocked(authApi.getSession).mockImplementation(() => new Promise(resolve => { recover = resolve; }));
await act(async () => {
host.querySelector("button")!.click();
await vi.advanceTimersByTimeAsync(10);
});
expect(fetchMock).toHaveBeenCalledTimes(calls);
expect(host.querySelector('[aria-label="AI account settings"]')).toBeNull();
denied = false;
await act(async () => {
recover({ user: { id: "user" } } as Awaited<ReturnType<typeof authApi.getSession>>);
await vi.advanceTimersByTimeAsync(10);
});
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name);
});
it.each(["personal", "shared", "missing-first"] as const)("keeps an accessible %s grant visible without a saved personal default", async mode => {
const personal = { ...account, ownerUserId: "user", grantId: "personal-grant", name: "Personal account", isDefault: false };
const shared = { ...account, ownership: "shared" as const, grantId: "shared-grant", name: "Shared account", isDefault: false };
const other = { ...shared, grantId: "other-shared-grant", name: "Other shared account" };
const choices = mode === "personal" ? [shared, personal] : [other, shared];
const available = mode === "missing-first" ? [shared] : choices;
fetchMock.mockImplementation((url: string) => Promise.resolve(Response.json(url.endsWith("/active-runs") ? []
: url.endsWith("/grants") ? { ...grants, grants: available.map(choice => ({ id: choice.grantId })) }
: { ...accounts, connections: choices })));
await render();
const expected = mode === "personal" ? personal : mode === "missing-first" ? shared : other;
const controls = host.querySelector('[aria-label="AI account settings"]');
expect(controls?.textContent).toBe(expected.name);
expect(controls?.getAttribute("data-grant-id")).toBe(expected.grantId);
expect(host.textContent).not.toContain("not available to you");
});
it.each(["accounts", "grants"] as const)("bounds a stuck %s read, retries once, and recovers from the retry button", async kind => {
blocked = kind;
await render();
@@ -13,6 +13,10 @@ import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared";
import { aiMethodLabel } from "./model";
import { queryKeys } from "@/lib/queryKeys";
import { retryTransientOnly } from "@/lib/query-client";
import { useAccountSessionQuery } from "@/api/companies-query";
import { useAiConnectionDefaults } from "./useAiConnectionDefaults";
import { AiDefaultError } from "./AiConnectionDefault";
import { AI_PROVIDERS } from "./model";
export function ManagedAiConnectionRow({
connection,
@@ -42,25 +46,22 @@ export function ManagedAiConnectionDetails({
queryFn: () =>
aiConnectionsApi.activeRuns(connection.companyId, connection.id),
});
const accounts = useQuery({
queryKey: ["ai-connections", connection.companyId],
queryFn: ({ signal }) => aiConnectionsApi.list(connection.companyId, undefined, { signal }),
retry: retryTransientOnly(1),
const session = useAccountSessionQuery();
const defaults = useAiConnectionDefaults(connection.companyId, {
userId: session.data?.user.id ?? null,
settled: session.isSuccess,
});
const accounts = defaults.accounts;
const grants = useQuery({
queryKey: queryKeys.tools.connectionGrants(connection.id),
queryFn: ({ signal }) => toolsApi.listConnectionGrants(connection.id, { signal }),
retry: retryTransientOnly(1),
});
const runsView = useQueryView(runs);
const sessionView = useQueryView(session);
const accountsView = useQueryView(accounts);
const grantsView = useQueryView(grants);
const refresh = () => client.invalidateQueries();
const makeDefault = useMutation({
mutationFn: (id: string) =>
aiConnectionsApi.setDefault(connection.companyId, id),
onSuccess: refresh,
});
const revoke = useMutation({
mutationFn: (id: string) =>
toolsApi.revokeConnectionGrant(connection.id, id),
@@ -70,20 +71,27 @@ export function ManagedAiConnectionDetails({
mutationFn: (id: string) => heartbeatsApi.cancel(id),
onSuccess: refresh,
});
const account = accounts.data?.connections.find(
(a) => a.id === connection.id,
);
const visibleGrants = new Set(grants.data?.grants.map(grant => grant.id));
const candidates = defaults.data?.connections.filter(account => account.id === connection.id && account.companyId === connection.companyId && visibleGrants.has(account.grantId)) ?? [];
// Routed personal accounts also need details; default eligibility excludes them.
const ownPersonal = (account: (typeof candidates)[number]) => account.ownership === "personal" && account.ownerUserId === defaults.data?.currentUserId;
const account = candidates.find(account => ownPersonal(account) && account.isDefault)
?? candidates.find(ownPersonal) ?? candidates[0];
const grant = grants.data?.grants.find((g) => g.id === account?.grantId);
const loadError = [accountsView, grantsView].find((view) => view.kind === "error")?.error;
const error = makeDefault.error ?? stop.error;
const loadError = [sessionView, accountsView, grantsView].find((view) => view.kind === "error")?.error;
const error = stop.error;
const loadFailure = loadError ? (
<div role="alert" className="space-y-2">
<p className="text-sm text-destructive">{describeError(loadError).body}</p>
<Button
variant="outline"
size="sm"
disabled={accounts.isFetching || grants.isFetching}
disabled={sessionView.isFetching || accounts.isFetching || grants.isFetching}
onClick={() => {
if (sessionView.kind === "error") {
sessionView.retry();
return;
}
if (accounts.error) void accounts.refetch();
if (grants.error) void grants.refetch();
}}
@@ -96,7 +104,7 @@ export function ManagedAiConnectionDetails({
if (!account || !grant)
return (
<p role="status" className="text-sm text-muted-foreground">
{accounts.isPending || grants.isPending || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
{accounts.isPending || grants.isPending || sessionView.kind === "reconnecting" || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
? "Loading AI account…"
: "This account is not available to you."}
</p>
@@ -105,13 +113,16 @@ export function ManagedAiConnectionDetails({
<div className="space-y-4">
{loadFailure}
{error && <p role="alert" className="text-sm text-destructive">{describeError(error).body}</p>}
{defaults.error && defaults.failedProvider && <AiDefaultError provider={AI_PROVIDERS[defaults.failedProvider].name}
pending={defaults.pending(defaults.failedProvider)} onRetry={defaults.retry} />}
<AiConnectionAccountDetails account={account} grant={grant} />
{account.routing && <details className="rounded-lg border border-border p-4"><summary className="cursor-pointer text-sm font-medium">Provider settings</summary><dl className="mt-4 grid grid-cols-2 gap-3 text-sm"><dt className="text-muted-foreground">Destination</dt><dd className="break-all">{account.routing.baseUrl ?? (account.routing.kind === "bedrock" ? account.routing.region : "OpenRouter")}</dd><dt className="text-muted-foreground">API format</dt><dd>{account.routing.kind === "openrouter" ? "Selected by harness" : account.routing.protocol}</dd><dt className="text-muted-foreground">Authentication</dt><dd>{account.routing.auth}</dd><dt className="text-muted-foreground">Models</dt><dd>{account.routing.models.map(m => m.label ?? m.id).join(", ") || "Enter a model ID on the agent"}</dd></dl></details>}
<AiConnectionAccountControls
account={account}
grant={grant}
currentUserId={accounts.data!.currentUserId}
onMakeDefault={() => makeDefault.mutate(grant.id)}
defaultPending={defaults.pending(account.provider)}
onMakeDefault={() => defaults.choose(account)}
onRevoke={() => revoke.mutateAsync(grant.id).then(() => undefined)}
revocationDetails={
<div className="space-y-2">
@@ -0,0 +1,104 @@
import { useEffect, useMemo, useRef } from "react";
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AiConnectionList, AiManagedConnectionSummary, AiProvider, ToolConnection } from "@paperclipai/shared";
import { aiConnectionRouterPluginKey } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { describeError } from "@/api/errors";
import { useOptionalToastActions } from "@/context/ToastContext";
import { retryTransientOnly } from "@/lib/query-client";
import { AI_PROVIDERS } from "./model";
export function isOwnAiAccount(account: Pick<AiManagedConnectionSummary, "ownership" | "ownerUserId" | "routing">, userId: string) {
return account.ownership === "personal" && account.ownerUserId === userId && !account.routing;
}
export function indexAiAccounts(accounts: AiManagedConnectionSummary[], userId: string) {
const groups = new Map<string, AiManagedConnectionSummary[]>();
for (const account of accounts) {
if (!isOwnAiAccount(account, userId)) continue;
const key = JSON.stringify([account.companyId, account.id]);
const group = groups.get(key);
if (group) group.push(account);
else groups.set(key, [account]);
}
return new Map([...groups].map(([key, candidates]) => [key,
candidates.length === 1 ? candidates[0] : candidates.find(account => account.isDefault),
]));
}
type DefaultTarget = { account: AiManagedConnectionSummary; retry: boolean; scope: string };
export function useAiConnectionDefaults(companyId: string | null, identity: { userId: string | null; settled: boolean }, enabled = true) {
const client = useQueryClient();
const toast = useOptionalToastActions();
const scope = JSON.stringify([companyId, identity.userId, identity.settled]);
const activeScope = useRef<string | null>(scope);
activeScope.current = scope;
useEffect(() => {
activeScope.current = scope;
return () => { activeScope.current = null; };
}, [scope]);
const listKey = ["ai-connections", companyId, undefined, identity.userId];
const accounts = useQuery({
queryKey: listKey,
queryFn: async ({ signal }) => {
const result = await aiConnectionsApi.list(companyId!, undefined, { signal, fresh: true });
if (identity.userId && result.currentUserId !== identity.userId) throw new Error("Your account changed. Refresh your connections.");
return result;
},
enabled: Boolean(companyId) && identity.settled && enabled,
retry: retryTransientOnly(1),
refetchOnWindowFocus: "always",
});
const mutationKey = ["ai-default", companyId, identity.userId];
const pendingProviders = useMutationState({
filters: { mutationKey, status: "pending" },
select: mutation => (mutation.state.variables as DefaultTarget).account.provider,
});
const mutation = useMutation({
mutationKey,
mutationFn: async (target: DefaultTarget) => {
const snapshot = target.retry ? (await accounts.refetch({ throwOnError: true })).data : accounts.data;
const account = snapshot?.connections.find(candidate => candidate.id === target.account.id && candidate.grantId === target.account.grantId);
if (activeScope.current !== target.scope || !identity.settled || !snapshot || !account ||
account.companyId !== companyId || !isOwnAiAccount(account, snapshot.currentUserId) ||
!account.canSetDefault || account.status !== "connected" || account.unavailableReason) {
throw new Error("This account is unavailable as your default. Refresh or reconnect it, then try again.");
}
if (!account.isDefault) await aiConnectionsApi.setDefault(account.companyId, account.grantId);
},
onSuccess: async (_result, target) => {
await client.invalidateQueries({ queryKey: ["ai-connections", target.account.companyId] });
if (activeScope.current !== target.scope) return;
const saved = client.getQueryData<AiConnectionList>(listKey)?.connections.find(account => account.grantId === target.account.grantId);
toast?.pushToast(saved?.isDefault
? { tone: "success", title: `${target.account.name} is now your ${AI_PROVIDERS[target.account.provider].name} default` }
: { tone: "info", title: `Your ${AI_PROVIDERS[target.account.provider].name} default was updated`, body: "The latest saved selection is shown in Connectors." });
},
onError: (error, target) => {
if (activeScope.current !== target.scope) return;
toast?.pushToast({ tone: "error", title: `Couldn’t change your ${AI_PROVIDERS[target.account.provider].name} default`,
body: describeError(error).body, action: { label: "Retry", onClick: () => choose(target.account, true, target.scope) } });
},
});
function choose(account: AiManagedConnectionSummary, retry = false, targetScope = scope) {
if (activeScope.current !== targetScope || !identity.settled || !companyId) return;
if (client.getMutationCache().findAll({ mutationKey, status: "pending" }).some(
entry => (entry.state.variables as DefaultTarget).account.provider === account.provider,
)) return;
mutation.mutate({ account, retry, scope: targetScope });
}
const data = identity.settled && (!identity.userId || accounts.data?.currentUserId === identity.userId) ? accounts.data : undefined;
const indexedAccounts = useMemo(() => data ? indexAiAccounts(data.connections, data.currentUserId) : undefined, [data]);
return {
accounts,
data,
accountForConnection: (connection: ToolConnection) => connection.connectionPurpose === "ai" && !aiConnectionRouterPluginKey(connection)
? indexedAccounts?.get(JSON.stringify([connection.companyId, connection.id])) : undefined,
choose,
pending: (provider: AiProvider) => pendingProviders.includes(provider),
error: mutation.variables?.scope === scope ? mutation.error : null,
failedProvider: mutation.variables?.account.provider,
retry: () => { if (mutation.variables) choose(mutation.variables.account, true, mutation.variables.scope); },
};
}
+1
View File
@@ -181,6 +181,7 @@ vi.mock("@/context/BreadcrumbContext", () => ({
const pushToastMock = vi.hoisted(() => vi.fn());
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
useOptionalToastActions: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+156
View File
@@ -9,6 +9,7 @@ import { ApiError } from "@/api/client";
import { aiConnectionRouterAppDefinition, CONNECTABLE_APP_DEFINITIONS, getAppStoreDefinition } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { AiManagedConnectionSummary } from "@paperclipai/shared";
import type { AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog";
const aggregatorCatalogMock = vi.hoisted(() => [] as AggregatorAppCatalogEntry[]);
@@ -58,6 +59,9 @@ vi.mock("@/api/publicMcp", () => ({ publicMcpApi: { connections: assistantConnec
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listAiConnectionsMock = vi.hoisted(() => vi.fn());
const setAiDefaultMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { list: listAiConnectionsMock, setDefault: setAiDefaultMock } }));
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const archiveConnectionMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
@@ -118,6 +122,7 @@ vi.mock("@/context/BreadcrumbContext", () => ({
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
useOptionalToastActions: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -231,6 +236,8 @@ describe("Connectors landing page", () => {
});
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listAiConnectionsMock.mockReset().mockResolvedValue({ currentUserId: "board-user", canManageConnections: true, connections: [] });
setAiDefaultMock.mockReset().mockResolvedValue({ ok: true });
listUserDirectoryMock.mockResolvedValue({ users: [] });
archiveConnectionMock.mockResolvedValue(connection({ status: "archived" }));
container = document.createElement("div");
@@ -1835,6 +1842,155 @@ describe("Connectors landing page", () => {
expect(container.textContent).not.toContain("All apps");
});
function aiFixture(overrides: Partial<AiManagedConnectionSummary>[] = []) {
let accounts: AiManagedConnectionSummary[] = [
{ id: "openai-sub", grantId: "grant-sub", companyId: "company-1", provider: "openai", method: "subscription", name: "My ChatGPT subscription", ownership: "personal", ownerUserId: "board-user", isDefault: true, canSetDefault: true, status: "connected" },
{ id: "openai-key", grantId: "grant-key", companyId: "company-1", provider: "openai", method: "api_key", name: "My OpenAI API key", ownership: "personal", ownerUserId: "board-user", isDefault: false, canSetDefault: true, status: "connected" },
{ id: "claude-key", grantId: "grant-claude", companyId: "company-1", provider: "anthropic", method: "api_key", name: "My Claude API key", ownership: "personal", ownerUserId: "board-user", isDefault: true, canSetDefault: true, status: "connected" },
].map((account, index) => ({ ...account, ...overrides[index] })) as AiManagedConnectionSummary[];
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("openai"), getAppStoreDefinition("anthropic")] });
listApplicationsMock.mockResolvedValue({ applications: ["openai", "anthropic"].map(provider => application({ id: `app-${provider}`, name: provider, applicationKey: `app-gallery:${provider}:one`, metadata: { sourceTemplateKey: provider } })) });
listConnectionsMock.mockResolvedValue({ connections: accounts.map(account => connection({ id: account.id, companyId: account.companyId, applicationId: `app-${account.provider}`, connectionPurpose: "ai", credentialPolicy: account.ownership === "personal" ? "per_user" : "shared", name: account.name,
config: { sourceTemplateKey: account.provider, ai: { provider: account.provider, method: account.method } } })) });
listAiConnectionsMock.mockImplementation(async () => ({ currentUserId: "board-user", canManageConnections: true, connections: accounts }));
const save = async (_companyId: string, grantId: string) => {
const target = accounts.find(account => account.grantId === grantId)!;
accounts = accounts.map(account => account.provider === target.provider && account.ownerUserId === target.ownerUserId
? { ...account, isDefault: account.grantId === grantId } : account);
return { ok: true };
};
setAiDefaultMock.mockImplementation(save);
return { save, replace: (next: AiManagedConnectionSummary[]) => { accounts = next; }, accounts: () => accounts };
}
async function aiMenu(name = "My OpenAI API key") {
const row = container.querySelector('[data-app-slug="openai"]')!;
if (!row.querySelector(`[aria-label="Manage ${name} connection"]`)) await clickButton("2 connections", row);
await act(() => { row.querySelector(`[aria-label="Manage ${name} connection"]`)!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.includes("default for OpenAI") || item.textContent === "Default for OpenAI");
}
it("bounds account lookup work when a large provider group expands and saves", async () => {
const fixture = aiFixture();
const count = 400;
let reads = 0;
const accounts = Array.from({ length: count }, (_, index) => ({
...fixture.accounts()[1],
get id() {
if (++reads > count * 30) throw new Error("Account lookup exceeded its linear work budget");
return `large-account-${index}`;
},
grantId: `large-grant-${index}`, name: `OpenAI account ${index}`, isDefault: index === 0,
}));
fixture.replace(accounts);
listConnectionsMock.mockResolvedValue({ connections: accounts.map(account => connection({
id: account.id, companyId: account.companyId, applicationId: "app-openai", connectionPurpose: "ai", credentialPolicy: "per_user", name: account.name,
config: { sourceTemplateKey: "openai", ai: { provider: "openai", method: "api_key" } },
})) });
setAiDefaultMock.mockImplementation(() => new Promise(() => {}));
reads = 0;
await renderBrowse();
const row = container.querySelector('[data-app-slug="openai"]')!;
await clickButton(`${count} connections`, row);
expect(row.querySelectorAll('[aria-label^="Manage OpenAI account"]')).toHaveLength(count);
expect(row.querySelector('[aria-label="Your default for OpenAI"]')).not.toBeNull();
await act(() => { row.querySelector('[aria-label="Manage OpenAI account 1 connection"]')!
.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
const item = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find(element => element.textContent === "Set as default for OpenAI")!;
await act(() => item.click());
await flushReact();
expect(setAiDefaultMock).toHaveBeenCalledExactlyOnceWith("company-1", "large-grant-1");
expect(reads).toBeLessThanOrEqual(count * 30);
});
it("sets a provider default by grant, keeps the old crown pending, and refetches the saved choice", async () => {
const fixture = aiFixture();
let release!: () => void;
setAiDefaultMock.mockImplementation((company, grant) => new Promise<void>(resolve => { release = () => { void fixture.save(company, grant); resolve(); }; }));
const client = await renderBrowse();
await flushReact();
const item = await aiMenu();
const oldCrown = container.querySelector('[aria-label="Your default for OpenAI"]')!;
expect(oldCrown.parentElement?.textContent).toContain("My ChatGPT subscription");
expect(item?.textContent).toBe("Set as default for OpenAI");
await act(() => item!.click());
await flushReact();
expect(setAiDefaultMock).toHaveBeenCalledExactlyOnceWith("company-1", "grant-key");
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
expect((await aiMenu())?.getAttribute("aria-disabled")).toBe("true");
await act(() => release());
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
expect(container.querySelector('[aria-label="Your default for Claude"]')?.parentElement?.textContent).toContain("My Claude API key");
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "success", title: "My OpenAI API key is now your OpenAI default" }));
await act(async () => { await client.invalidateQueries({ queryKey: ["ai-connections", "company-1"] }); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
});
it("retains a failed selection and rechecks the same grant before an explicit retry", async () => {
const fixture = aiFixture();
setAiDefaultMock.mockRejectedValueOnce(new Error("Save failed"));
await renderBrowse();
const item = await aiMenu();
await act(() => item!.click());
await flushReact();
expect(container.textContent).toContain("Couldn’t change your OpenAI default");
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
const before = listAiConnectionsMock.mock.calls.length;
fixture.replace(fixture.accounts().map(account => account.grantId === "grant-key" ? { ...account, status: "revoked" } : account));
await clickButton("Retry", container);
expect(listAiConnectionsMock.mock.calls.length).toBeGreaterThan(before);
expect(setAiDefaultMock).toHaveBeenCalledTimes(1);
fixture.replace(fixture.accounts().map(account => ({ ...account, status: "connected" })));
await clickButton("Retry", container);
expect(setAiDefaultMock).toHaveBeenCalledTimes(2);
expect(setAiDefaultMock).toHaveBeenLastCalledWith("company-1", "grant-key");
});
it.each([
{ ownership: "shared", ownerUserId: undefined },
{ ownerUserId: "another-person" },
{ canSetDefault: false },
{ routing: { kind: "openrouter", auth: "bearer", models: [] } },
] as Partial<AiManagedConnectionSummary>[])("does not offer personal-default actions for an ineligible account: %j", async override => {
aiFixture([{}, override]);
await renderBrowse();
expect(await aiMenu()).toBeUndefined();
expect(setAiDefaultMock).not.toHaveBeenCalled();
});
it("keeps an unavailable default crowned and disables selection of an unavailable replacement", async () => {
aiFixture([{ status: "needs_attention" }, { status: "expired" }]);
await renderBrowse();
const item = await aiMenu();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My ChatGPT subscription");
expect(container.textContent).toContain("Default unavailable");
expect(item?.getAttribute("aria-disabled")).toBe("true");
expect(item?.textContent).toContain("Reconnect this account");
});
it("refetches cross-tab changes on focus and drops the previous user's crowns on an identity change", async () => {
const fixture = aiFixture();
const client = await renderBrowse();
await aiMenu();
await act(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); });
await fixture.save("company-1", "grant-key");
await act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')?.parentElement?.textContent).toContain("My OpenAI API key");
accountIdentity.userId = "another-person";
listAiConnectionsMock.mockImplementation(() => new Promise(() => {}));
await act(() => { root.render(<QueryClientProvider client={client}><TooltipProvider><Browse /></TooltipProvider></QueryClientProvider>); });
await flushReact();
expect(container.querySelector('[aria-label="Your default for OpenAI"]')).toBeNull();
expect(container.querySelector('[aria-label="Your default for Claude"]')).toBeNull();
focusManager.setFocused(undefined);
});
it("shows existing accounts and an actionable warning when the gallery request fails", async () => {
listGalleryMock.mockRejectedValue(new Error("Gallery unavailable"));
listApplicationsMock.mockResolvedValue({
+26 -4
View File
@@ -13,6 +13,9 @@ import {
} from "@paperclipai/shared";
import { AssistantConnectionCard, useAssistantConnections } from "./AssistantConnection";
import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails";
import { useAiConnectionDefaults } from "@/components/ai-connections/useAiConnectionDefaults";
import { AiDefaultCrown, AiDefaultError, AiDefaultMenuItem } from "@/components/ai-connections/AiConnectionDefault";
import { AI_PROVIDERS } from "@/components/ai-connections/model";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -438,6 +441,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const aiDefaults = useAiConnectionDefaults(selectedCompanyId, { userId: viewingUserId, settled: identitySettled },
connectionsQuery.data?.connections.some(connection => connection.connectionPurpose === "ai" && !aiConnectionRouterPluginKey(connection)) ?? false);
const aiDefaultsView = useQueryView(aiDefaults.accounts);
const chatEndpointsQuery = useQuery({
queryKey: queryKeys.chatEndpoints.list(selectedCompanyId ?? "__none__"),
queryFn: () => chatEndpointsApi.list(selectedCompanyId!),
@@ -1013,6 +1019,10 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
) : null}
{refreshAggregator.error ? <p role="alert" className="text-sm text-destructive">Couldn’t refresh the gateway. Last known accounts are shown.</p> : null}
{aiDefaultsView.kind === "error" && <QueryErrorState size="inline" error={aiDefaultsView.error} action="load AI account defaults"
onRetry={aiDefaultsView.retry} retrying={aiDefaultsView.isFetching} />}
{aiDefaults.error && aiDefaults.failedProvider && <AiDefaultError provider={AI_PROVIDERS[aiDefaults.failedProvider].name}
pending={aiDefaults.pending(aiDefaults.failedProvider)} onRetry={aiDefaults.retry} />}
{loading ? (
<div className="space-y-3" aria-label="Loading connectors">
@@ -1031,6 +1041,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
{paginatedRows.map((row) => (
<ConnectorCard
renderAccountDetails={renderAccountDetails}
aiDefaults={aiDefaults}
key={row.key}
row={row}
userProfileById={userProfileById}
@@ -1134,6 +1145,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
export function ConnectorCard({
renderAccountDetails,
aiDefaults,
row,
userProfileById,
chatAgentById,
@@ -1157,6 +1169,7 @@ export function ConnectorCard({
searchQuery = "",
}: {
renderAccountDetails?: (connection: ToolConnection) => ReactNode;
aiDefaults?: ReturnType<typeof useAiConnectionDefaults>;
row: ConnectorRowModel;
userProfileById: ReadonlyMap<string, ConnectionOwnerProfile>;
chatAgentById?: ReadonlyMap<string, AvatarAgent>;
@@ -1286,6 +1299,7 @@ export function ConnectorCard({
{row.connections.map((connection) => (
<ConnectionAccountRow
details={renderAccountDetails?.(connection)}
aiDefaults={aiDefaults}
key={connection.id}
row={row}
setupEnabled={setupEnabled}
@@ -1428,6 +1442,7 @@ export function ConnectorCard({
function ConnectionAccountRow({
details,
aiDefaults,
row,
connection,
owner,
@@ -1441,6 +1456,7 @@ function ConnectionAccountRow({
setupEnabled = true,
}: {
details?: ReactNode;
aiDefaults?: ReturnType<typeof useAiConnectionDefaults>;
row: ConnectorRowModel;
connection: ToolConnection;
owner: ConnectionOwnerProfile | null;
@@ -1460,9 +1476,12 @@ function ConnectionAccountRow({
row.name,
owner,
);
const account = aiDefaults?.accountForConnection(connection);
return (
<ConnectionAccountRowLayout state={state} accountName={accountName} owner={owner} details={<>{details}{discovery?.discovery.message || discovery?.sync.status === "error" ? <p className="text-xs text-muted-foreground" role="status">{discovery.discovery.message ?? discovery.sync.error}</p> : null}</>}
<ConnectionAccountRowLayout state={state} accountName={accountName} owner={owner}
afterName={account?.isDefault ? <AiDefaultCrown account={account} /> : undefined}
details={<>{details}{account?.isDefault && account.status !== "connected" && <p className="text-xs text-destructive">Default unavailable</p>}{discovery?.discovery.message || discovery?.sync.status === "error" ? <p className="text-xs text-muted-foreground" role="status">{discovery.discovery.message ?? discovery.sync.error}</p> : null}</>}
openLabel={`Open ${accountName} permissions`} onOpen={() => onNavigate(`/apps/${connection.id}/permissions`)}>
{setupEnabled && (state.kind === "attention" || state.kind === "draft") ? (
<Button
@@ -1490,6 +1509,8 @@ function ConnectionAccountRow({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{account?.canSetDefault && aiDefaults && !aiDefaults.accounts.error && <AiDefaultMenuItem account={account}
pending={aiDefaults.pending(account.provider)} onSelect={() => aiDefaults.choose(account)} />}
<DropdownMenuItem
onSelect={() => onNavigate(`/apps/${connection.id}/permissions`)}
>
@@ -1513,8 +1534,9 @@ function ConnectionAccountRow({
}
/** Shared presentation for native and aggregator-backed accounts. */
function ConnectionAccountRowLayout({ state, accountName, owner, source, details, openLabel, onOpen, children }: {
function ConnectionAccountRowLayout({ state, accountName, afterName, owner, source, details, openLabel, onOpen, children }: {
state: ConnectionState; accountName: string; owner?: ConnectionOwnerProfile | null; source?: ReactNode; details?: ReactNode;
afterName?: ReactNode;
openLabel: string; onOpen: () => void; children: ReactNode;
}) {
return (
@@ -1522,14 +1544,14 @@ function ConnectionAccountRowLayout({ state, accountName, owner, source, details
<div className="flex min-w-0 flex-1 items-start gap-2.5">
<ConnectionStatusIcon state={state} />
<div className="min-w-0">
<button
<div className="flex min-w-0 items-center gap-2"><button
type="button"
className="block max-w-full cursor-pointer truncate text-left text-sm font-medium text-foreground hover:underline focus-visible:underline"
aria-label={openLabel}
onClick={onOpen}
>
{accountName}
</button>
</button>{afterName}</div>
{details}
{state.message ? (
<div