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

@@ -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); },
};
}