From 5f48df4e0be2f66a2e73f785c4fe86171008bf66 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 21 Sep 2026 14:00:06 -0500 Subject: [PATCH] Add chat connection management and removal menus Co-Authored-By: Paperclip --- doc/connections/CHAT-CONNECTOR-UX.md | 6 ++ ui/src/pages/apps/Browse.test.tsx | 71 ++++++++++++++++- ui/src/pages/apps/Browse.tsx | 77 +++++++++++++++---- .../chat-connection-actions.stories.tsx | 43 +++++++++++ 4 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 ui/storybook/stories/chat-connection-actions.stories.tsx diff --git a/doc/connections/CHAT-CONNECTOR-UX.md b/doc/connections/CHAT-CONNECTOR-UX.md index 66d3e8a279..167ae96886 100644 --- a/doc/connections/CHAT-CONNECTOR-UX.md +++ b/doc/connections/CHAT-CONNECTOR-UX.md @@ -346,3 +346,9 @@ the file through Basic Information → Display Information → App icon & Previe confirm the crop, then save in Slack. Confirmation/skipping is browser-local, scoped to the company and endpoint; it is not proof of a Slack configuration change. No new Slack scope or API mutation is needed. + +Chat connections in the catalog keep **Finish setup** visible for drafts. Put +**Manage** and **Remove connection** in a three-dot menu on every connection row, +including drafts. Confirm removal, explain that existing tasks remain, and keep +the dialog available for retry if removal fails. Use the existing provider lifecycle +action; removing a Paperclip connection does not delete the provider app or bot. diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 78b723a1a8..895788d0f1 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -17,9 +17,10 @@ const pushToastMock = vi.hoisted(() => vi.fn()); const navigateMock = vi.hoisted(() => vi.fn()); const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); const experimentalMock = vi.hoisted(() => vi.fn()); +const chatSetupMock = vi.hoisted(() => vi.fn()); const chatListMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } })); -vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { list: chatListMock } })); +vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { list: chatListMock, setup: chatSetupMock } })); vi.mock("@/api/tools", () => ({ toolsApi: { @@ -135,6 +136,7 @@ describe("Connectors landing page", () => { beforeEach(() => { experimentalMock.mockResolvedValue({ enableChatConnectors: true }); chatListMock.mockResolvedValue([]); + chatSetupMock.mockReset().mockResolvedValue({ status: "archived" }); listGalleryMock.mockResolvedValue({ apps: [ galleryEntry({ @@ -231,7 +233,7 @@ describe("Connectors landing page", () => { } expect(container.textContent).not.toContain("Private bot"); expect(container.textContent).not.toContain("Chat with agents"); - await act(() => container.querySelector('button[aria-label="Connect GitHub"]')!.click()); + await act(() => void container.querySelector('button[aria-label="Connect GitHub"]')!.click()); expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github"); }); @@ -241,14 +243,14 @@ describe("Connectors landing page", () => { const client = await renderBrowse(); expect(chatListMock).toHaveBeenCalledWith("company-1"); expect(container.querySelector('[data-app-slug="telegram"]')).not.toBeNull(); - await act(() => container.querySelector('button[aria-label="Add connection GitHub"]')!.click()); + await act(() => void container.querySelector('button[aria-label="Add connection GitHub"]')!.click()); expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&toolHref=%2Fapps%2Fconnect%3Fsource%3Dgithub"); await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false }); }); await flushReact(); expect(container.querySelector('[data-app-slug="telegram"]')).toBeNull(); expect(container.textContent).not.toContain("Chat agent"); expect(container.querySelector('a[href*="/apps/chat/"]')).toBeNull(); - await act(() => container.querySelector('button[aria-label="Connect GitHub"]')!.click()); + await act(() => void container.querySelector('button[aria-label="Connect GitHub"]')!.click()); expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github"); }); @@ -468,6 +470,67 @@ describe("Connectors landing page", () => { ); }); + it.each(["slack", "discord", "telegram", "github", "microsoft-teams", "agentmail", "imessage-photon"])( + "puts Manage and removal in the %s chat menu while keeping draft setup visible", + async (provider) => { + chatListMock.mockResolvedValue([ + { id: "chat-active", provider, status: "active", assignedAgentName: "Active agent" }, + { id: "chat-draft", provider, status: "draft", assignedAgentName: "Draft agent" }, + { id: "chat-archived", provider, status: "archived", assignedAgentName: "Removed agent" }, + ]); + await renderBrowse(); + expect(container.textContent).not.toContain("Removed agent"); + expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Manage")).toBe(false); + const finish = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Finish setup"); + await act(() => finish!.click()); + expect(navigateMock).toHaveBeenLastCalledWith(`/apps/chat/connect?provider=${provider}&purpose=chat&resume=chat-draft`); + expect(container.querySelector('button[aria-label^="Manage Draft agent"]')).toBeTruthy(); + await act(() => void container.querySelector('button[aria-label^="Manage Active agent"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + await flushReact(); + const manage = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Manage"); + await act(() => manage!.click()); + expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/chat-active/settings"); + }, + ); + + it.each(["active", "draft"])("confirms chat removal for %s connections and refreshes the list", async (status) => { + chatListMock.mockResolvedValue([{ id: "chat-1", provider: "slack", status, assignedAgentName: "CEO" }]); + const client = await renderBrowse(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + await act(() => void container.querySelector('button[aria-label="Manage CEO Slack connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + await flushReact(); + const remove = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Remove connection"); + await act(() => remove!.click()); + await flushReact(); + expect(chatSetupMock).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Existing Paperclip tasks and conversation history remain available."); + chatListMock.mockResolvedValue([]); + await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + expect(chatSetupMock).toHaveBeenCalledWith("chat-1", { action: "remove" }); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.chatEndpoints.list("company-1") }); + expect(container.textContent).not.toContain("CEO"); + }); + + it("keeps chat removal open for retry when the server rejects removal", async () => { + chatListMock.mockResolvedValue([{ id: "chat-1", provider: "slack", status: "draft", assignedAgentName: "CEO" }]); + chatSetupMock.mockRejectedValueOnce(new Error("Connection is busy. Try again.")); + await renderBrowse(); + await act(() => void container.querySelector('button[aria-label="Manage CEO Slack connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + await flushReact(); + await act(() => Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + expect(document.querySelector('[role="alertdialog"]')).toBeTruthy(); + expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "error", body: "Connection is busy. Try again." })); + await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Cancel")!.click()); + await flushReact(); + expect(chatSetupMock).toHaveBeenCalledTimes(1); + expect(document.querySelector('[role="alertdialog"]')).toBeNull(); + }); + it("keeps an interrupted account visible and resumes setup from its account row", async () => { listApplicationsMock.mockResolvedValue({ applications: [application()] }); listConnectionsMock.mockResolvedValue({ diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 8bf3c74d1d..4c1d66e3c8 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -103,6 +103,7 @@ type ConnectionState = { }; type ConnectionRemovalTarget = { + kind?: "chat"; id: string; accountName: string; providerName: string; @@ -320,9 +321,17 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne enabled: !!selectedCompanyId, }); const removeConnection = useMutation({ - mutationFn: (target: ConnectionRemovalTarget) => - toolsApi.archiveConnection(target.id), + mutationFn: async (target: ConnectionRemovalTarget) => { + if (target.kind === "chat") { + await chatEndpointsApi.setup(target.id, { action: "remove" }); + } else { + await toolsApi.archiveConnection(target.id); + } + }, onSuccess: (_connection, target) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.chatEndpoints.list(selectedCompanyId!), + }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!), }); @@ -335,7 +344,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne pushToast({ title: "Connection removed", body: - target.remainingConnectionCount > 0 + target.kind === "chat" + ? `${target.providerName} is disconnected. Existing Paperclip tasks remain available.` + : target.remainingConnectionCount > 0 ? `${target.providerName} still has ${target.remainingConnectionCount} active ${target.remainingConnectionCount === 1 ? "connection" : "connections"} available to agents.` : `${target.providerName} is no longer available to agents through this connection. Its saved credentials were deleted.`, tone: "success", @@ -532,6 +543,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne for (const endpoint of chatConnectorsEnabled ? (chatEndpointsQuery.data ?? []) : []) { + if (endpoint.status === "archived") continue; let target = [...rowsBySlug.values()].find( (row) => chatProviderForSlug(row.slug) === endpoint.provider, ); @@ -711,7 +723,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne Remove {connectionToRemove?.accountName ?? "this"} connection? - {connectionToRemove && + {connectionToRemove?.kind === "chat" + ? `This connection will stop receiving new work from ${connectionToRemove.providerName}. Existing Paperclip tasks and conversation history remain available. This does not delete the app, bot, or account in ${connectionToRemove.providerName}.` + : connectionToRemove && connectionToRemove.remainingConnectionCount > 0 ? `This connection's saved credentials are deleted and agents lose access through it immediately. They can still use ${connectionToRemove.providerName} through ${connectionToRemove.remainingConnectionCount} other active ${connectionToRemove.remainingConnectionCount === 1 ? "connection" : "connections"}.` : "The saved credentials are deleted and agents lose access immediately. Connecting it again later requires a new sign-in or key."} @@ -864,19 +878,48 @@ export function ConnectorCard({ {endpoint.status.replace(/_/g, " ")} - +
+ {endpoint.status === "draft" ? ( + + ) : null} + + + + + + onNavigate(`/apps/chat/${endpoint.id}/settings`)}> + Manage + + + onRequestRemove({ + kind: "chat", + id: endpoint.id, + accountName: `${endpoint.assignedAgentName} · ${row.name}`, + providerName: row.name, + remainingConnectionCount: 0, + })} + > + + Remove connection + + + +
))} diff --git a/ui/storybook/stories/chat-connection-actions.stories.tsx b/ui/storybook/stories/chat-connection-actions.stories.tsx new file mode 100644 index 0000000000..b96e48bafd --- /dev/null +++ b/ui/storybook/stories/chat-connection-actions.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { ConnectorCard } from "@/pages/apps/Browse"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; + +const endpoint: ChatEndpoint = { + id: "slack-active", companyId: "demo", provider: "slack", status: "active", + assignedAgentId: "ceo", assignedAgentName: "CEO", providerAccountLabel: "Paperclip", + allowUnlinkedPeople: false, +}; + +const meta = { + title: "Connections/Chat connection actions", + component: ConnectorCard, + parameters: { layout: "padded" }, + args: { + row: { + key: "slack", slug: "slack", name: "Slack", brandKey: "slack", + description: "Give agents Slack tools or let people start and continue Paperclip work from Slack.", + entry: null, applications: [], connections: [], + chatEndpoints: [endpoint, { ...endpoint, id: "slack-draft", status: "draft", assignedAgentName: "Carl" }], + }, + userProfileById: new Map(), chatConnectorsEnabled: true, + onNavigate: fn(), onRequestRemove: fn(), + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const ActiveAndDraft: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("button", { name: "Finish setup" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Manage CEO Slack connection" })); + const body = within(document.body); + await expect(body.getByRole("menuitem", { name: "Manage" })).toBeVisible(); + await userEvent.click(body.getByRole("menuitem", { name: "Remove connection" })); + await expect(args.onRequestRemove).toHaveBeenCalledWith(expect.objectContaining({ kind: "chat", id: "slack-active" })); + await userEvent.click(canvas.getByRole("button", { name: "Manage Carl Slack connection" })); + await expect(body.getByRole("menuitem", { name: "Remove connection" })).toBeVisible(); + await userEvent.keyboard("{Escape}"); + }, +};