Add chat connection management and removal menus

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-21 14:02:05 -05:00
1 parent 911f0ff34c
commit 5f48df4e0b
4 files changed
+176 -21

No files matched your search

+6
View File
@@ -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.
+67 -4
View File
@@ -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<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
await act(() => void container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Add connection GitHub"]')!.click());
await act(() => void container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
await act(() => void container.querySelector<HTMLButtonElement>('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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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({
+60 -17
View File
@@ -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?
</AlertDialogTitle>
<AlertDialogDescription>
{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({
<span className="text-xs text-muted-foreground">
{endpoint.status.replace(/_/g, " ")}
</span>
<Button
size="sm"
variant="outline"
onClick={() =>
onNavigate(
endpoint.status === "draft"
? `/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`
: `/apps/chat/${endpoint.id}/settings`,
)
}
>
{endpoint.status === "draft" ? "Finish setup" : "Manage"}
</Button>
<div className="flex items-center gap-2">
{endpoint.status === "draft" ? (
<Button
size="sm"
variant="outline"
onClick={() => onNavigate(`/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`)}
>
Finish setup
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Manage ${endpoint.assignedAgentName} ${row.name} connection`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onNavigate(`/apps/chat/${endpoint.id}/settings`)}>
Manage
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => onRequestRemove({
kind: "chat",
id: endpoint.id,
accountName: `${endpoint.assignedAgentName} · ${row.name}`,
providerName: row.name,
remainingConnectionCount: 0,
})}
>
<Trash2 />
Remove connection
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
@@ -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<typeof ConnectorCard>;
export default meta;
type Story = StoryObj<typeof meta>;
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}");
},
};