mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
Add chat connection management and removal menus
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
911f0ff34c
commit
5f48df4e0b
4 files changed
+176
-21
No files matched your search
@@ -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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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}");
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user