From 377bc87df149cfc005806aebf227bd21fddc2323 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:26:08 -0600 Subject: [PATCH] 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 Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 20 +++ packages/shared/src/ai-connections.ts | 1 + server/src/__tests__/ai-connections.test.ts | 63 +++++++ server/src/routes/ai-connections.ts | 23 ++- tests/ai-connections-app/app.spec.ts | 102 +++++++++++- ui/src/api/companies-query.ts | 7 +- .../AiConnectionAccountControls.tsx | 22 ++- .../AiConnectionAccountDetails.test.tsx | 12 +- .../ai-connections/AiConnectionDefault.tsx | 44 +++++ .../ManagedAiConnectionDetails.test.tsx | 79 ++++++++- .../ManagedAiConnectionDetails.tsx | 45 +++-- .../ai-connections/useAiConnectionDefaults.ts | 104 ++++++++++++ ui/src/pages/apps/AppDetail.test.tsx | 1 + ui/src/pages/apps/Browse.test.tsx | 156 ++++++++++++++++++ ui/src/pages/apps/Browse.tsx | 30 +++- .../prototypes/AiConnectionsReview.tsx | 3 +- ui/storybook/prototypes/AiConnectorPages.tsx | 29 +++- .../runs-adapters/ai-connections.stories.tsx | 60 ++++++- 18 files changed, 742 insertions(+), 59 deletions(-) create mode 100644 ui/src/components/ai-connections/AiConnectionDefault.tsx create mode 100644 ui/src/components/ai-connections/useAiConnectionDefaults.ts diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 0372f939330..bef4ac78c97 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -73,6 +73,26 @@ Environment selection does not change agent execution settings. API keys are validated against fixed provider endpoints; redirects and caller-supplied validation URLs are rejected. +In **Connectors**, expand a provider's accounts. A crown beside an account's name +marks your saved default for that provider in this company. Hover, focus, or tap +the crown for its label. In another personal account's menu, choose **Set as +default for OpenAI**, **Set as default for Claude**, or the matching provider. +Subscription and API-key accounts share one provider default. The current default +has a disabled **Default for {provider}** menu item. The list API's `canSetDefault` +flag projects human self-service configure authority once per list request. +Agent list responses omit this optional UI field and retain their existing +write authorization. Shared accounts, other owners, +routed profiles, and callers without permission do not receive this action. + +While saving, the previous crown stays visible and other default writes for that +provider are disabled. After success, scoped account data and agent selectors +refresh from the server. Failed writes retain the previous selection and offer +an explicit Retry for the same grant, with eligibility checked again. Returning +to the tab refreshes defaults changed elsewhere. An unavailable selected account +keeps its crown and **Default unavailable** state until it is reconnected or +explicitly replaced. Changing the preference grants no access and affects the +next normal run admission; pinned selections and active runs retain their accounts. + `runtimeConfig.aiConnection` contains `provider`, `mode`, and `method`. For responsible-user selections, `method` is a legacy wire hint retained for rolling upgrades; the resolver uses the selected account’s actual method: - `responsible_user`: resolve the run's responsible user's personal provider default, using that account's subscription or API key. The `method` hint does not restrict the responsible user's account. diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index fb784919866..d16164c6306 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -186,6 +186,7 @@ export interface AiManagedConnectionSummary { ownerUserId?: string; ownerName?: string; isDefault: boolean; + canSetDefault?: boolean; status: "connected" | "needs_attention" | "expired" | "revoked"; unavailableReason?: string; usageProbeSupported?: boolean; diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 07b9616c0a8..c18d8c7941c 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -2455,6 +2455,15 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); }); const personal = await service.select({ ...input, userId: "alice" }); const base = `/api/companies/${companyId}/ai-connections`; + const ownerList = await request(app).get(base); + expect(ownerList.status).toBe(200); + expect(ownerList.body.connections.find((account: { grantId: string }) => account.grantId === personal.grant.id)?.canSetDefault).toBe(true); + const viewerList = await request(app).get(base).set("x-test-role", "viewer"); + expect(viewerList.status).toBe(200); + expect(viewerList.body.connections.every((account: { canSetDefault: boolean }) => account.canSetDefault === false)).toBe(true); + const otherUserList = await request(app).get(base).set("x-test-user", "bob"); + expect(otherUserList.status).toBe(200); + expect(otherUserList.body.connections.some((account: { grantId: string; canSetDefault: boolean }) => account.grantId === personal.grant.id && account.canSetDefault)).toBe(false); expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connections`)).status).toBe(403); expect((await request(app).put(`${base}/default`).set("x-test-user", "bob").send({ grantId: personal.grant.id })).status).toBe(403); expect((await request(app).put(`${base}/default`).set("x-test-role", "viewer").send({ grantId: personal.grant.id })).status).toBe(403); @@ -2467,6 +2476,60 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" expect(network).not.toHaveBeenCalled(); } finally { network.mockRestore(); } }); + it("keeps personal-default list queries constant and other reads responsive with a blocked membership read", async () => { + const testCompany = randomUUID(), owner = "default-list-cost"; + await db.insert(companies).values({ id: testCompany, name: "Default list cost", issuePrefix: "DLC" }); + await db.insert(companyMemberships).values({ companyId: testCompany, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const first = await service.save(testCompany, owner, { provider: "openai", method: "api_key", ownership: "personal", name: "Default cost", agentIds: [], allAgents: false }, "fixture-cost"); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId: owner, companyIds: [testCompany], memberships: [{ companyId: testCompany, membershipRole: "member", status: "active" }] }; + next(); + }); + app.get("/probe", async (_req, res) => { await db.execute(sql`select 1`); res.sendStatus(200); }); + app.use("/api", aiConnectionRoutes(db)); + const url = `/api/companies/${testCompany}/ai-connections`; + const selects = vi.spyOn(db, "select"); + let smallQueries: number, largeQueries: number; + try { + expect((await request(app).get(url)).status).toBe(200); + smallQueries = selects.mock.calls.length; + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, first.connectionId)); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, first.grantId)); + const ids = Array.from({ length: 199 }, () => randomUUID()); + await db.insert(toolConnections).values(ids.map(id => ({ ...connection, id, uid: id, name: `Cost ${id}` }))); + await db.insert(connectionGrants).values(ids.map(connectionId => ({ ...grant, id: randomUUID(), connectionId }))); + selects.mockClear(); + const large = await request(app).get(url); + largeQueries = selects.mock.calls.length; + expect(large.status).toBe(200); + expect(large.body.connections).toHaveLength(200); + expect(large.body.connections.every((account: { canSetDefault: boolean }) => account.canSetDefault)).toBe(true); + expect(largeQueries).toBe(smallQueries); + expect(largeQueries).toBeLessThan(30); + } finally { selects.mockRestore(); } + + const held = deferredSignal(), release = deferredSignal(); + const holder = db.transaction(async tx => { + await tx.execute(sql`lock table company_memberships in access exclusive mode`); + held.resolve(); await release.promise; + }); + await held.promise; + const pending = request(app).get(url).then(response => response); + try { + let blocked = false; + const deadline = Date.now() + 5_000; + while (!blocked && Date.now() < deadline) { + const rows = await db.execute(sql`select pid from pg_stat_activity where wait_event_type = 'Lock' and query like '%company_memberships%'`); + blocked = rows.length > 0; + if (!blocked) await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(blocked).toBe(true); + expect((await request(app).get("/probe").timeout(2_000)).status).toBe(200); + } finally { release.resolve(); await holder; } + expect((await pending).status).toBe(200); + }); + it.each(["anthropic", "openai"] as const)("reports a missing %s browser process after a server restart", async provider => { const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")).limit(1); const id = randomUUID(); diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index 69a984263a0..23203f7ad9f 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -227,15 +227,28 @@ export function aiConnectionRoutes(db: Db, options: Parameters ({ connectionId: account.id })); + const viewer = (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships) + ?.some(m => m.companyId === companyId && m.membershipRole === "viewer"); + const management = connectionManagementService(db); + const context = connectionRequestContext(req); + const personalDefaultCapability = req.actor.type === "board" ? !viewer && (await management.capability(req.actor, "connections:configure", { + type: "connection_management", companyId, purpose: "ai", selfServiceUserId: currentUserId, + operation: "PUT /companies/:companyId/ai-connections/default", readOnly: false, + }, { mutation: false, readContext: context })).allowed : undefined; + const connections = accounts.map(account => ({ + ...account, + ...(personalDefaultCapability === undefined ? {} : { + canSetDefault: personalDefaultCapability && account.ownership === "personal" && account.ownerUserId === currentUserId && !account.routing, + }), + })); res.json({ currentUserId, pools: await filterConnectionManagementRows(db, req, "connections:read", await pools.selectable(companyId, currentUserId), pool => ({ connectionId: pool.id })), canManageConnections: await canManageAiConnections(db, req, companyId), - connections: await filterConnectionManagementRows(db, req, "connections:read", await service.list( - companyId, - currentUserId, - agentId as string | undefined, - ), account => ({ connectionId: account.id })), + connections, } satisfies AiConnectionList); }); router.get( diff --git a/tests/ai-connections-app/app.spec.ts b/tests/ai-connections-app/app.spec.ts index 78cd5b4c48f..0acea3c9752 100644 --- a/tests/ai-connections-app/app.spec.ts +++ b/tests/ai-connections-app/app.spec.ts @@ -17,11 +17,11 @@ test("existing Connections lists AI providers and keeps account management compa await expect(page.getByRole("button", { name: new RegExp(`^(Add account|Connect) ${provider}$`) })).toBeVisible(); } const { connections } = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); - const account = connections.find((entry: { ownership: string }) => entry.ownership === "personal"); + const account = connections.find((entry: { ownership: string; isDefault: boolean }) => entry.ownership === "personal" && entry.isDefault); expect(account, "The isolated drive should include an imported personal account").toBeTruthy(); await page.goto(`/${prefix}/apps/${account.id}/permissions`); await expect(page.getByRole("heading", { name: account.name, exact: true })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Personal default", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: /^Default for / })).toBeVisible(); await expect(page.getByRole("heading", { name: "Agent usage", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Back to Connectors", exact: true })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Which humans can use this credential?" })).toBeVisible(); @@ -91,6 +91,104 @@ test("connection setup waits for provider details before enabling Continue", asy await expect(page).toHaveURL(/stage=setup/); }); +test("personal provider defaults move the crown only after saving and survive refresh", async ({ page, request }, testInfo) => { + const list = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); + const own = list.connections.filter((account: { provider: string; ownership: string; ownerUserId: string; canSetDefault: boolean; status: string }) => + account.provider === "openai" && account.ownership === "personal" && account.ownerUserId === list.currentUserId && account.canSetDefault && account.status === "connected"); + const previous = own.find((account: { isDefault: boolean }) => account.isDefault); + const target = own.find((account: { isDefault: boolean }) => !account.isDefault); + expect(previous, "Seed a selected personal OpenAI account").toBeTruthy(); + expect(target, "Seed a second personal OpenAI account").toBeTruthy(); + const others = list.connections.filter((account: { provider: string; isDefault: boolean }) => account.provider !== "openai" && account.isDefault).map((account: { grantId: string }) => account.grantId).sort(); + await page.goto(`/${prefix}/apps?source=installed`); + const announcement = page.getByRole("button", { name: "Dismiss announcement" }); + if (await announcement.isVisible()) await announcement.click(); + const provider = page.locator('[data-app-slug="openai"]'); + const expand = async () => { + const trigger = provider.getByRole("button", { name: /^\d+ connections$/ }); + if (await trigger.getAttribute("data-state") === "closed") await trigger.click(); + }; + await expect(provider).toBeVisible(); + await expand(); + const crown = provider.getByRole("button", { name: "Your default for OpenAI" }); + await expect(crown.locator("..")).toContainText(previous.name); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let writes = 0; + await page.route(`**/api/companies/${companyId}/ai-connections/default`, async route => { + writes++; + expect(route.request().postDataJSON()).toEqual({ grantId: target.grantId }); + await held; + await route.continue(); + }); + const manage = provider.getByRole("button", { name: `Manage ${target.name} connection` }); + await manage.click(); + await page.screenshot({ path: testInfo.outputPath("default-menu.png") }); + await page.getByRole("menuitem", { name: "Set as default for OpenAI", exact: true }).click(); + await expect.poll(() => writes).toBe(1); + await expect(crown.locator("..")).toContainText(previous.name); + await manage.click(); + await expect(page.getByRole("menuitem", { name: "Set as default for OpenAI", exact: true })).toHaveAttribute("aria-disabled", "true"); + await page.keyboard.press("Escape"); + release(); + await expect(crown.locator("..")).toContainText(target.name); + await expect(manage).toBeFocused(); + await page.screenshot({ path: testInfo.outputPath("default-saved.png"), fullPage: true }); + await page.unroute(`**/api/companies/${companyId}/ai-connections/default`); + const saved = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); + expect(saved.connections.filter((account: { provider: string; isDefault: boolean }) => account.provider === "openai" && account.isDefault).map((account: { grantId: string }) => account.grantId)).toEqual([target.grantId]); + expect(saved.connections.filter((account: { provider: string; isDefault: boolean }) => account.provider !== "openai" && account.isDefault).map((account: { grantId: string }) => account.grantId).sort()).toEqual(others); + await page.reload(); + await expand(); + await expect(crown.locator("..")).toContainText(target.name); + await crown.scrollIntoViewIfNeeded(); + await crown.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + await expect(page.getByRole("tooltip")).toHaveText("Your default for OpenAI"); + + await page.route(`**/api/companies/${companyId}/ai-connections/default`, async route => { + await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "Disposable QA save failure" }) }); + }, { times: 1 }); + await provider.getByRole("button", { name: `Manage ${previous.name} connection` }).click(); + await page.getByRole("menuitem", { name: "Set as default for OpenAI", exact: true }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Couldn’t change your OpenAI default" })).toBeVisible(); + await expect(crown.locator("..")).toContainText(target.name); + await page.getByRole("alert").filter({ hasText: "Couldn’t change your OpenAI default" }).getByRole("button", { name: "Retry", exact: true }).click(); + await expect(crown.locator("..")).toContainText(previous.name); + const shared = saved.connections.find((account: { provider: string; ownership: string }) => account.provider === "openai" && account.ownership === "shared"); + if (shared) { + await provider.getByRole("button", { name: `Manage ${shared.name} connection` }).click(); + await expect(page.getByRole("menuitem", { name: /default for OpenAI/ })).toHaveCount(0); + await page.keyboard.press("Escape"); + } +}); + +test("personal provider defaults expose crown meaning on touch in both themes at 390px", async ({ browser, request }, testInfo) => { + const list = await (await request.get(`/api/companies/${companyId}/ai-connections`)).json(); + const selected = list.connections.find((account: { provider: string; isDefault: boolean }) => account.provider === "openai" && account.isDefault); + expect(selected).toBeTruthy(); + for (const colorScheme of ["light", "dark"] as const) { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, colorScheme, reducedMotion: "reduce" }); + const page = await context.newPage(); + await page.goto(`${testInfo.project.use.baseURL}/${prefix}/apps?source=installed`); + const announcement = page.getByRole("button", { name: "Dismiss announcement" }); + if (await announcement.isVisible()) await announcement.click(); + const provider = page.locator('[data-app-slug="openai"]'); + await provider.getByRole("button", { name: /^\d+ connections$/ }).tap(); + const crown = provider.getByRole("button", { name: "Your default for OpenAI" }); + await crown.scrollIntoViewIfNeeded(); + await crown.tap(); + await expect(page.getByRole("tooltip")).toHaveText("Your default for OpenAI"); + await page.keyboard.press("Escape"); + await provider.getByRole("button", { name: `Manage ${selected.name} connection` }).tap(); + await expect(page.getByRole("menuitem", { name: "Default for OpenAI", exact: true })).toHaveAttribute("aria-disabled", "true"); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`default-mobile-${colorScheme}.png`) }); + await context.close(); + } +}); + test("new OpenRouter agents use the visible binding and provider model catalog", async ({ page }) => { let tested: { aiConnection?: unknown; adapterConfig?: { model?: string } } | undefined; await page.route(`**/api/companies/${companyId}/adapters/opencode_local/models*`, async route => { diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts index c072208bec2..a922da06c46 100644 --- a/ui/src/api/companies-query.ts +++ b/ui/src/api/companies-query.ts @@ -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, diff --git a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx index f7d4c42291c..27e4c17a594 100644 --- a/ui/src/components/ai-connections/AiConnectionAccountControls.tsx +++ b/ui/src/components/ai-connections/AiConnectionAccountControls.tsx @@ -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; @@ -23,22 +26,23 @@ export function AiConnectionAccountControls({ const [revoking, setRevoking] = useState(false); const [revokePending, setRevokePending] = useState(false); const [revokeError, setRevokeError] = useState(); - 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 (
- {ownPersonal && !account.routing && ( + {eligibleForDefault && (
- -

{account.isDefault && !available ? "Default unavailable" : "Personal default"}

+ {account.isDefault && } +

{account.isDefault ? available ? `Default for ${providerName}` : "Default unavailable" : `Your ${providerName} default`}

- {!account.isDefault && (!readOnly ? ( - + {!account.isDefault && (!readOnly && account.canSetDefault ? ( + ) : Not default)}
)} diff --git a/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx b/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx index b4ea87446f9..72f4ba38d9f 100644 --- a/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx +++ b/ui/src/components/ai-connections/AiConnectionAccountDetails.test.tsx @@ -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({completePanel + root.render({completePanel ? - : }); + : }); }); 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; diff --git a/ui/src/components/ai-connections/AiConnectionDefault.tsx b/ui/src/components/ai-connections/AiConnectionDefault.tsx new file mode 100644 index 00000000000..b5ee6fe8d93 --- /dev/null +++ b/ui/src/components/ai-connections/AiConnectionDefault.tsx @@ -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 }) { + const [open, setOpen] = useState(false); + const label = `Your default for ${AI_PROVIDERS[account.provider].name}`; + return + + + + {label} + ; +} + +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 + {pending ? ; +} + +export function AiDefaultError({ provider, pending, onRetry }: { + provider: string; pending: boolean; onRetry: () => void; +}) { + return
+

Couldn’t change your {provider} default.

+ +
; +} diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx index 815fd8d871b..d536f9966fc 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.test.tsx @@ -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 }) => ( -
{account.name}
+ AiConnectionAccountControls: ({ account, grant }: { account: AiManagedConnectionSummary; grant: { id: string } }) => ( +
{account.name}
), })); 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>); 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(); }); - 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>) => 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>); + 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(); diff --git a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx index 1ee75cdf5f7..1281d255fbf 100644 --- a/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx +++ b/ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx @@ -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 ? (

{describeError(loadError).body}

+ {afterName}
{details} {state.message ? (
; + if (props.host === "connections") return ; return ; } diff --git a/ui/storybook/prototypes/AiConnectorPages.tsx b/ui/storybook/prototypes/AiConnectorPages.tsx index 54b746e9daf..b77ba853848 100644 --- a/ui/storybook/prototypes/AiConnectorPages.tsx +++ b/ui/storybook/prototypes/AiConnectorPages.tsx @@ -14,7 +14,7 @@ import { AiConnectionAuth, type AiAuthState } from "@/components/ai-connections/ import { CredentialModeLink } from "@/components/onboarding/CredentialModeLink"; import { AI_PROVIDERS, aiMethodLabel, type AiConnectionSummary, type AiProvider, type AiAuthMethod } from "@/components/ai-connections/model"; import { AI_REVIEW_CONNECTIONS } from "../fixtures/aiConnections"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents, storybookAuthSession } from "../fixtures/paperclipData"; const companyId = "company-storybook"; const date = new Date("2026-09-10T12:00:00Z"); @@ -56,17 +56,19 @@ function grantsFor(account: AiConnectionSummary, readOnly: boolean): ConnectionG } /** Mount the production route components against an isolated, deterministic in-memory API. */ -export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, detail = false, readOnly = false }: { - initialConnections?: AiConnectionSummary[]; detail?: boolean; readOnly?: boolean; +export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, detail = false, readOnly = false, failDefaultOnce = false }: { + initialConnections?: AiConnectionSummary[]; detail?: boolean; readOnly?: boolean; failDefaultOnce?: boolean; }) { const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, refetchOnWindowFocus: false }, mutations: { retry: false } } })); const [store] = useState(() => ({ profiles: new Map(initialConnections.filter((row) => row.id === "claude-dotta").map((row) => [row.id, { id: `profile-${row.id}`, companyId, profileKey: `app:${row.id}`, entries: [], bindings: ["nova"].map((targetId) => ({ targetType: "agent", targetId })), - }])), accounts: initialConnections.map((row) => ({ ...row })), removed: new Set(), installs: new Map(), audience: new Map() })); + }])), accounts: initialConnections.map((row) => ({ ...row, companyId, canSetDefault: !readOnly && row.ownership === "personal" && row.ownerUserId === "dotta" && !row.routing })) as AiConnectionSummary[], + failedDefault: false, removed: new Set(), installs: new Map(), audience: new Map() })); const [ready, setReady] = useState(false); const [, render] = useState(0); const navigate = useNavigate(); function update(account: AiConnectionSummary) { + account = { ...account, companyId, canSetDefault: !readOnly && account.ownership === "personal" && account.ownerUserId === "dotta" && !account.routing }; store.accounts = store.accounts.some((row) => row.id === account.id) ? store.accounts.map((row) => row.id === account.id ? account : row) : [...store.accounts, account]; void client.invalidateQueries(); render((n) => n + 1); } @@ -81,9 +83,20 @@ export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, d const rows = store.accounts.filter((row) => !store.removed.has(row.id)); const applications: ToolApplication[] = gallery.map((app) => ({ id: `app-${app.slug}`, companyId, applicationKey: `app-gallery:${app.slug}:review`, name: app.name, description: app.description, type: "mcp_http", status: "active", pluginId: null, ownerAgentId: null, ownerUserId: "dotta", metadata: { sourceTemplateKey: app.slug }, archivedAt: null, createdAt: date, updatedAt: date })); const reply = (value: unknown) => Promise.resolve(Response.json(value)); + if (path === "/api/auth/get-session") return reply({ ...storybookAuthSession, user: { ...storybookAuthSession.user, id: "dotta", name: "Dotta" } }); + if (path === `/api/companies/${companyId}/ai-connections`) return reply({ currentUserId: "dotta", canManageConnections: !readOnly, connections: rows }); + if (path === `/api/companies/${companyId}/ai-connections/default` && method === "PUT") { + const target = rows.find(row => row.grantId === payload.grantId); + if (!target?.canSetDefault || target.status !== "connected") return Response.json({ error: "This account cannot be selected as your default." }, { status: 403 }); + if (failDefaultOnce && !store.failedDefault) { store.failedDefault = true; return Response.json({ error: "The default could not be saved. Try again." }, { status: 503 }); } + store.accounts = store.accounts.map(row => row.provider === target.provider && row.ownerUserId === "dotta" ? { ...row, isDefault: row.grantId === target.grantId } : row); + return reply({ ok: true }); + } if (path === `/api/companies/${companyId}/tools/gallery`) return reply({ apps: gallery, capabilities: { canCreateOrganizationGrant: !readOnly, organizationGrantReason: null, canSetCompanyInstall: !readOnly, companyInstallReason: null } }); if (path === `/api/companies/${companyId}/tools/applications`) return reply({ applications }); if (path === `/api/companies/${companyId}/tools/connections`) return reply({ connections: rows.map(asConnection) }); + if (path === `/api/companies/${companyId}/chat-endpoints`) return reply([]); + if (path === "/api/mcp/connections") return reply([]); if (path === `/api/companies/${companyId}/tools/profiles`) return reply({ profiles: [...store.profiles.values()] }); if (path === `/api/companies/${companyId}/tools/policies`) return reply({ policies: [] }); if (path === `/api/companies/${companyId}/agents`) return reply(agents); @@ -110,6 +123,10 @@ export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, d return reply({ connectionId: row.id, installs: store.installs.get(row.id) ?? [] }); } if (suffix === "/catalog") return reply({ catalog: [] }); + if (suffix === "/profile-configuration") { + const profile = store.profiles.get(row.id); + return reply({ profiles: profile ? [profile] : [] }); + } return Response.json({ error: `Unsupported review operation: ${suffix}` }, { status: 400 }); } if (path.startsWith(`/api/companies/${companyId}/tools/apps/`) && path.endsWith("/finish")) { @@ -135,14 +152,14 @@ export function AiConnectorPages({ initialConnections = AI_REVIEW_CONNECTIONS, d { const row = store.accounts.find((account) => account.id === connection.id); - return row ?

{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.isDefault ? " · Personal default" : ""}{row.accountLabel ? ` · ${row.accountLabel}` : ""}

: null; + return row ?

{aiMethodLabel(row.provider, row.method)} · {row.ownership === "shared" ? "Company shared" : "Personal"}{row.accountLabel ? ` · ${row.accountLabel}` : ""}

: null; }} />} /> } /> navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&stage=setup&reconnect=${connection.id}`)} renderActions={(connection) => { const account = store.accounts.find((row) => row.id === connection.id); return account ? { - store.accounts = store.accounts.map((row) => row.provider === account.provider && row.method === account.method && row.ownerUserId === "dotta" ? { ...row, isDefault: row.id === account.id } : row); update({ ...account, isDefault: true }); + store.accounts = store.accounts.map((row) => row.provider === account.provider && row.ownerUserId === "dotta" ? { ...row, isDefault: row.id === account.id } : row); update({ ...account, isDefault: true }); }} onReconnect={() => navigate(`/apps/connect?source=${account.provider}&stage=setup&reconnect=${account.id}`)} onRevoke={() => update({ ...account, status: "revoked" })} diff --git a/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx index 835f6067504..1c94ef27271 100644 --- a/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx +++ b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx @@ -1,7 +1,7 @@ import { AiReviewFrame, aiReviewContext } from "../../prototypes/AiReviewFrame"; import { AiTaskConnectionReview } from "../../prototypes/AiTaskConnectionReview"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { AiConnectionsReview } from "../../prototypes/AiConnectionsReview"; import { AiConnectorPages } from "../../prototypes/AiConnectorPages"; import { @@ -259,12 +259,62 @@ export const ChangePersonalDefault: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(await canvas.findByRole("button", { name: "Make default" })); - await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); - await expect(canvas.queryByRole("button", { name: "Make default" })).not.toBeInTheDocument(); + await userEvent.click(await canvas.findByRole("button", { name: "Set as default for Claude" })); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Default for Claude"); + await expect(canvas.queryByRole("button", { name: "Set as default for Claude" })).not.toBeInTheDocument(); }, }; +const connectorDefaultAccounts = AI_REVIEW_CONNECTIONS.map(account => account.id === "openai-api" + ? { ...account, name: "My OpenAI API key", ownership: "personal" as const, ownerUserId: "dotta", ownerName: "Dotta" } + : account); + +export const ConnectorDefaultMenu: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts }, + parameters: { paperclip: { capabilities: ["runs-adapters/validation", "connection-setup/catalog"], status: "current", implementation: "production", boundary: "fixtures", + notes: "Production Browse/ConnectorCard crown and dropdown. Fixture API persists one default per owner/provider across methods; provider authentication and real API authorization are not exercised." } }, + play: async ({ canvasElement }) => { + const heading = await within(canvasElement).findByRole("heading", { name: "OpenAI" }); + const provider = within(heading.closest('[data-app-slug="openai"]')!); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await provider.findByRole("button", { name: "2 connections" })); + await expect(provider.getByLabelText("Your default for OpenAI")).toBeVisible(); + await userEvent.click(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })); + await userEvent.click(await body.findByRole("menuitem", { name: "Set as default for OpenAI" })); + await waitFor(() => expect(provider.getByLabelText("Your default for OpenAI").parentElement).toHaveTextContent("My OpenAI API key")); + await userEvent.click(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })); + await expect(await body.findByRole("menuitem", { name: "Default for OpenAI" })).toHaveAttribute("aria-disabled", "true"); + await userEvent.keyboard("{Escape}"); + await expect(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })).toHaveFocus(); + }, +}; + +export const ConnectorDefaultRetry: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts, failDefaultOnce: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const heading = await canvas.findByRole("heading", { name: "OpenAI" }); + const provider = within(heading.closest('[data-app-slug="openai"]')!); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click(await provider.findByRole("button", { name: "2 connections" })); + await userEvent.click(provider.getByRole("button", { name: "Manage My OpenAI API key connection" })); + await userEvent.click(await body.findByRole("menuitem", { name: "Set as default for OpenAI" })); + const failure = (await canvas.findByText("Couldn’t change your OpenAI default.")).closest('[role="alert"]')!; + await expect(failure).toHaveTextContent("Couldn’t change your OpenAI default"); + await expect(provider.getByLabelText("Your default for OpenAI").parentElement).toHaveTextContent("My ChatGPT subscription"); + await userEvent.click(within(failure).getByRole("button", { name: "Retry" })); + await waitFor(() => expect(provider.getByLabelText("Your default for OpenAI").parentElement).toHaveTextContent("My OpenAI API key")); + }, +}; + +export const ConnectorDefaultUnavailable: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts.map(account => account.id === "openai-personal" ? { ...account, status: "expired" } : account) }, +}; + +export const ConnectorDefaultsReadOnly: Story = { + args: { host: "connections", initialConnections: connectorDefaultAccounts, readOnly: true }, +}; + const authArgs = { initialStage: "auth" as const }; const waiting = { phase: "waiting" as const, @@ -522,7 +572,7 @@ export const ReconnectExisting: Story = { await userEvent.click(canvas.getByRole("button", { name: "Submit code" })); await userEvent.click(canvas.getByRole("button", { name: "Use connection" })); await expect(await canvas.findByRole("heading", { name: "My Claude subscription" })).toBeVisible(); - await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Personal default"); + await expect(canvas.getByLabelText("AI account settings")).toHaveTextContent("Default for Claude"); }, }; export const RevokeConnection: Story = {