mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +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,
|
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
|
scoped to the company and endpoint; it is not proof of a Slack configuration
|
||||||
change. No new Slack scope or API mutation is needed.
|
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 navigateMock = vi.hoisted(() => vi.fn());
|
||||||
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
|
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
|
||||||
const experimentalMock = vi.hoisted(() => vi.fn());
|
const experimentalMock = vi.hoisted(() => vi.fn());
|
||||||
|
const chatSetupMock = vi.hoisted(() => vi.fn());
|
||||||
const chatListMock = vi.hoisted(() => vi.fn());
|
const chatListMock = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } }));
|
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", () => ({
|
vi.mock("@/api/tools", () => ({
|
||||||
toolsApi: {
|
toolsApi: {
|
||||||
@@ -135,6 +136,7 @@ describe("Connectors landing page", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
experimentalMock.mockResolvedValue({ enableChatConnectors: true });
|
experimentalMock.mockResolvedValue({ enableChatConnectors: true });
|
||||||
chatListMock.mockResolvedValue([]);
|
chatListMock.mockResolvedValue([]);
|
||||||
|
chatSetupMock.mockReset().mockResolvedValue({ status: "archived" });
|
||||||
listGalleryMock.mockResolvedValue({
|
listGalleryMock.mockResolvedValue({
|
||||||
apps: [
|
apps: [
|
||||||
galleryEntry({
|
galleryEntry({
|
||||||
@@ -231,7 +233,7 @@ describe("Connectors landing page", () => {
|
|||||||
}
|
}
|
||||||
expect(container.textContent).not.toContain("Private bot");
|
expect(container.textContent).not.toContain("Private bot");
|
||||||
expect(container.textContent).not.toContain("Chat with agents");
|
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");
|
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,14 +243,14 @@ describe("Connectors landing page", () => {
|
|||||||
const client = await renderBrowse();
|
const client = await renderBrowse();
|
||||||
expect(chatListMock).toHaveBeenCalledWith("company-1");
|
expect(chatListMock).toHaveBeenCalledWith("company-1");
|
||||||
expect(container.querySelector('[data-app-slug="telegram"]')).not.toBeNull();
|
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");
|
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&toolHref=%2Fapps%2Fconnect%3Fsource%3Dgithub");
|
||||||
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false }); });
|
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false }); });
|
||||||
await flushReact();
|
await flushReact();
|
||||||
expect(container.querySelector('[data-app-slug="telegram"]')).toBeNull();
|
expect(container.querySelector('[data-app-slug="telegram"]')).toBeNull();
|
||||||
expect(container.textContent).not.toContain("Chat agent");
|
expect(container.textContent).not.toContain("Chat agent");
|
||||||
expect(container.querySelector('a[href*="/apps/chat/"]')).toBeNull();
|
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");
|
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 () => {
|
it("keeps an interrupted account visible and resumes setup from its account row", async () => {
|
||||||
listApplicationsMock.mockResolvedValue({ applications: [application()] });
|
listApplicationsMock.mockResolvedValue({ applications: [application()] });
|
||||||
listConnectionsMock.mockResolvedValue({
|
listConnectionsMock.mockResolvedValue({
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ type ConnectionState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ConnectionRemovalTarget = {
|
type ConnectionRemovalTarget = {
|
||||||
|
kind?: "chat";
|
||||||
id: string;
|
id: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
@@ -320,9 +321,17 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
const removeConnection = useMutation({
|
const removeConnection = useMutation({
|
||||||
mutationFn: (target: ConnectionRemovalTarget) =>
|
mutationFn: async (target: ConnectionRemovalTarget) => {
|
||||||
toolsApi.archiveConnection(target.id),
|
if (target.kind === "chat") {
|
||||||
|
await chatEndpointsApi.setup(target.id, { action: "remove" });
|
||||||
|
} else {
|
||||||
|
await toolsApi.archiveConnection(target.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess: (_connection, target) => {
|
onSuccess: (_connection, target) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.chatEndpoints.list(selectedCompanyId!),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.tools.connections(selectedCompanyId!),
|
queryKey: queryKeys.tools.connections(selectedCompanyId!),
|
||||||
});
|
});
|
||||||
@@ -335,7 +344,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
|||||||
pushToast({
|
pushToast({
|
||||||
title: "Connection removed",
|
title: "Connection removed",
|
||||||
body:
|
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} 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.`,
|
: `${target.providerName} is no longer available to agents through this connection. Its saved credentials were deleted.`,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
@@ -532,6 +543,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
|||||||
for (const endpoint of chatConnectorsEnabled
|
for (const endpoint of chatConnectorsEnabled
|
||||||
? (chatEndpointsQuery.data ?? [])
|
? (chatEndpointsQuery.data ?? [])
|
||||||
: []) {
|
: []) {
|
||||||
|
if (endpoint.status === "archived") continue;
|
||||||
let target = [...rowsBySlug.values()].find(
|
let target = [...rowsBySlug.values()].find(
|
||||||
(row) => chatProviderForSlug(row.slug) === endpoint.provider,
|
(row) => chatProviderForSlug(row.slug) === endpoint.provider,
|
||||||
);
|
);
|
||||||
@@ -711,7 +723,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
|||||||
Remove {connectionToRemove?.accountName ?? "this"} connection?
|
Remove {connectionToRemove?.accountName ?? "this"} connection?
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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
|
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"}.`
|
? `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."}
|
: "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">
|
<span className="text-xs text-muted-foreground">
|
||||||
{endpoint.status.replace(/_/g, " ")}
|
{endpoint.status.replace(/_/g, " ")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
size="sm"
|
{endpoint.status === "draft" ? (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() =>
|
size="sm"
|
||||||
onNavigate(
|
variant="outline"
|
||||||
endpoint.status === "draft"
|
onClick={() => onNavigate(`/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`)}
|
||||||
? `/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`
|
>
|
||||||
: `/apps/chat/${endpoint.id}/settings`,
|
Finish setup
|
||||||
)
|
</Button>
|
||||||
}
|
) : null}
|
||||||
>
|
<DropdownMenu>
|
||||||
{endpoint.status === "draft" ? "Finish setup" : "Manage"}
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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