From c1f1342a96d53834a2a27feb8c96bd726ddfe921 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 16:34:36 -0500 Subject: [PATCH] fix(mcp): surface connection status failures and guide setup Co-Authored-By: Paperclip --- .../pages/apps/AssistantConnection.test.tsx | 18 +++++++-- ui/src/pages/apps/AssistantConnection.tsx | 16 +++++--- ui/storybook/fixtures/publicMcp.ts | 3 +- .../stories/public-mcp-consent.stories.tsx | 1 + .../stories/public-mcp-journey.stories.tsx | 6 ++- ui/storybook/stories/public-mcp-presenter.tsx | 40 +++++++++++++++++++ 6 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 ui/storybook/stories/public-mcp-presenter.tsx diff --git a/ui/src/pages/apps/AssistantConnection.test.tsx b/ui/src/pages/apps/AssistantConnection.test.tsx index 221cc4dc6e..bf31515cb0 100644 --- a/ui/src/pages/apps/AssistantConnection.test.tsx +++ b/ui/src/pages/apps/AssistantConnection.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AssistantConnection } from "./AssistantConnection"; +import { AssistantConnection, AssistantConnectionCard } from "./AssistantConnection"; const mocks = vi.hoisted(() => ({ setup: vi.fn(), connections: vi.fn(), revoke: vi.fn(), breadcrumbs: vi.fn() })); vi.mock("@/api/publicMcp", () => ({ publicMcpApi: mocks })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "butter", selectedCompany: { id: "butter", name: "Butter", logoUrl: null } }) })); @@ -13,8 +13,8 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const grant = { id: "grant", companyId: "butter", companyName: "Butter", clientName: "OpenCode", scopes: ["paperclip:read", "paperclip:write"], createdAt: "2026-10-05T00:00:00Z", revokedAt: null }; let root: Root, container: HTMLDivElement, client: QueryClient; async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); } -async function render() { - await act(async () => root.render()); +async function render(element: React.ReactNode = ) { + await act(async () => root.render({element})); await flush(); } beforeEach(() => { @@ -26,6 +26,18 @@ beforeEach(() => { }); afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.clearAllMocks(); }); describe("assistant setup from Connections", () => { + it("surfaces catalog status failures and recovers without claiming there are no connections", async () => { + mocks.connections.mockRejectedValue(new Error("offline")); + await render(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load your connection status"); + expect(container.querySelector('[aria-label="Set up Assistant Connection (MCP)"]')).toBeNull(); + expect(container.querySelector('[data-connected]')).toBeNull(); + mocks.connections.mockResolvedValue([grant]); + await act(async () => Array.from(container.querySelectorAll('button')).find(b => b.textContent === "Try again")!.click()); + await flush(); + expect(container.querySelector('[aria-label="Manage Assistant Connection (MCP)"]')).not.toBeNull(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); it("uses the canonical URL and explains how OpenCode opens consent without granting access", async () => { await render(); const config = Array.from(container.querySelectorAll("pre")).map(p => p.textContent!).find(p => p.startsWith("{"))!; diff --git a/ui/src/pages/apps/AssistantConnection.tsx b/ui/src/pages/apps/AssistantConnection.tsx index cc065e850a..06aecaa70b 100644 --- a/ui/src/pages/apps/AssistantConnection.tsx +++ b/ui/src/pages/apps/AssistantConnection.tsx @@ -26,18 +26,24 @@ function useConnections(poll = false) { /** Inbound assistant access belongs beside the existing outbound connectors. */ export function AssistantConnectionCard({ onNavigate }: { onNavigate: (href: string) => void }) { - const { rows } = useConnections(); - const active = rows.filter(row => !row.revokedAt); - return
+ const connections = useConnections(); + const active = connections.rows.filter(row => !row.revokedAt); + const action = !connections.isSuccess ? "Open" : active.length ? "Manage" : "Set up"; + return
0) : undefined} className="overflow-hidden rounded-xl border border-border">
- {active.length > 0 &&
+ {connections.isPending &&

Checking your connection status…

} + {connections.isError &&
+

Couldn’t load your connection status.

+ +
} + {connections.isSuccess && active.length > 0 &&