From 57fd8b70d2f525390ab51b1e10dd6e1fe22c0427 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:38:48 -0500 Subject: [PATCH] feat: add agent avatar download to Slack setup and settings (#13740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents for work. > - Slack connections let a team talk to those agents in Slack. > - Agents now have a saved avatar, but Slack setup did not offer that image. > - A matching avatar helps a team recognize its agent. > - This pull request adds an optional avatar step and a download in connector Settings. > - Users download a PNG and upload it directly in Slack with clear instructions. ## Linked Issues or Issue Description **What existing behavior does this improve?** Slack connector onboarding and its Settings page. **Current behavior** Setup does not offer the assigned agent's avatar or explain how to upload it in Slack. **Proposed behavior** After Slack connection verification, users can download a 512 × 512 PNG of their agent's saved avatar. They can upload it in Slack, confirm, or skip. Settings keeps the download and upload instructions available after onboarding. **Reason and benefit** The same avatar helps people recognize the agent across Paperclip and Slack. Users who skip the optional step can return to it in Settings. **Breaking changes** None. No schema, authentication, Slack scope, or provider API change. Completed connections keep their existing completion state. Searched existing Slack avatar and Cliptoon PRs; no matching implementation was found. ## What Changed - Add an optional avatar step before personal Slack account linking. Keep the numbered sidebar and shared footer. - Resolve the selected agent's saved appearance for the preview and PNG download. - Add the same download and expandable upload instructions to connector Settings. - Remember uploaded or skipped per company and endpoint in browser storage. Treat uploaded as user confirmation, not provider verification. - Reject failed or non-PNG download responses and allow retry. - Reuse the production avatar components in onboarding and Settings stories. - Test wizard progression, resume, Settings, download recovery, storage isolation, and terminated assigned agents. - Exercise real PNG downloads in the Slack browser flow and keep default app names consistent with app creation. - Fetch the assigned agent directly so its saved avatar remains available after termination. ## Verification - Focused chat suites: 48 passed; the two affected suites passed again after the final naming fix (32 tests). - Slack browser E2E passed through setup, avatar download, account linking, and Settings download. PNG signature and 512 × 512 dimensions verified. - UI token gates passed. - Browser: downloaded the real 512 × 512 PNG; checked confirmation, return, mobile layout, and Settings instructions. - Full workspace typecheck, application build, and production Storybook build passed. - All latest-head CI checks passed (54 passed, 2 skipped), including all browser, chat, general, and serialized test groups. The unrelated Sentry test failed once and passed on the single CI rerun; its suite also passed locally. - Local full-suite attempt encountered a rapid Slack callback ordering failure under concurrent build load; that test passed in isolation, and all three chat shards passed in CI. The remaining local run was not used as the merge gate. - Review the Connections / Slack / Add avatar and Avatar in Settings stories. ## Risks - Slack upload is manual. Confirmation does not claim to verify the Slack icon. - Optional step progress is browser-local. Clearing storage or changing browsers can show it again. Setup still works when storage is unavailable. - The existing avatar API remains the image source. Download failures show a retry message. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact deployment model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/CHAT-CONNECTOR-UX.md | 15 ++ tests/e2e/chat-adapters-ui-providers.spec.ts | 18 ++ tests/e2e/chat-adapters-ui.shared.ts | 2 +- .../apps/chat/ChatEndpoint.clipboard.test.tsx | 76 +++++- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 19 ++ ui/src/pages/apps/chat/ChatEndpointSetup.tsx | 55 +++-- .../pages/apps/chat/SlackAvatarStep.test.tsx | 81 +++++++ ui/src/pages/apps/chat/SlackAvatarStep.tsx | 218 ++++++++++++++++++ ui/src/pages/apps/chat/slack-app-name.ts | 13 ++ .../pages/apps/chat/slack-avatar-progress.ts | 42 ++++ .../prototypes/slack-avatar/README.md | 32 +++ .../SlackAvatarSettingsPreview.tsx | 66 ++++++ .../slack-avatar/SlackAvatarStep.tsx | 113 +++++++++ .../prototypes/slack-avatar/ceo-cliptoon.png | Bin 0 -> 26212 bytes .../stories/slack-avatar-settings.stories.tsx | 18 ++ .../stories/slack-avatar-setup.stories.tsx | 29 +++ 16 files changed, 767 insertions(+), 30 deletions(-) create mode 100644 ui/src/pages/apps/chat/SlackAvatarStep.test.tsx create mode 100644 ui/src/pages/apps/chat/SlackAvatarStep.tsx create mode 100644 ui/src/pages/apps/chat/slack-app-name.ts create mode 100644 ui/src/pages/apps/chat/slack-avatar-progress.ts create mode 100644 ui/storybook/prototypes/slack-avatar/README.md create mode 100644 ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx create mode 100644 ui/storybook/prototypes/slack-avatar/SlackAvatarStep.tsx create mode 100644 ui/storybook/prototypes/slack-avatar/ceo-cliptoon.png create mode 100644 ui/storybook/stories/slack-avatar-settings.stories.tsx create mode 100644 ui/storybook/stories/slack-avatar-setup.stories.tsx diff --git a/doc/connections/CHAT-CONNECTOR-UX.md b/doc/connections/CHAT-CONNECTOR-UX.md index 33da582280b..6432a64c0d8 100644 --- a/doc/connections/CHAT-CONNECTOR-UX.md +++ b/doc/connections/CHAT-CONNECTOR-UX.md @@ -300,3 +300,18 @@ email integration's actual mechanisms. The intended result is consistent interaction and permission semantics across providers, with instructions and step count tailored to each real workflow. + +## Agent avatars in provider setup + +Offer the selected agent's existing avatar as a downloadable image when the +provider supports a custom bot image. Keep manual upload instructions next to +the download, use the saved provider app name, and distinguish user confirmation +from provider verification. Keep a download in connector Settings so skipping +the optional setup step does not hide it permanently. + +Slack uses a 512 × 512 PNG from the existing avatar renderer. Its optional step +comes after connection verification and before personal account linking. Upload +the file through Basic Information → Display Information → App icon & Preview, +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. diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index 67bd60ace7a..d4690c841e8 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { expect, test, type Page } from "@playwright/test"; import { @@ -237,6 +238,16 @@ test.describe.serial("native chat adapter UI", () => { await expect(page.getByRole("heading", { name: "Verify Slack connection" })).toBeVisible(); await expect(page.getByText("Slack needs to confirm that it can reach your Paperclip instance.")).toBeVisible(); mock.setWebhookVerified(); + await expect(page.getByRole("heading", { name: "Give Maya a face in Slack" })).toBeVisible(); + const downloadEvent = page.waitForEvent("download"); + await page.getByRole("link", { name: "Download avatar" }).click(); + const download = await downloadEvent; + expect(download.suggestedFilename()).toBe("maya-paperclip-avatar.png"); + const png = await readFile((await download.path())!); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + expect(png.readUInt32BE(16)).toBe(512); + expect(png.readUInt32BE(20)).toBe(512); + await page.getByRole("button", { name: "I’ve uploaded the avatar" }).click(); await expect(page.getByRole("heading", { name: "Connect your Slack account" })).toBeVisible(); await expect(page.getByText("/maya-public connect", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Link Test operator to my Paperclip account" }).click(); @@ -318,6 +329,13 @@ test.describe.serial("native chat adapter UI", () => { await expect(page.getByText("@maya-paperclip you there?", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible(); + const avatarSection = page.getByRole("region", { name: "Slack avatar" }); + await expect(avatarSection.getByRole("link", { name: "Download avatar" })).toBeVisible(); + await avatarSection.getByText("How to upload in Slack", { exact: true }).click(); + await expect(avatarSection.getByRole("link", { name: "Open Slack app Settings" })).toBeVisible(); + const settingsDownloadEvent = page.waitForEvent("download"); + await avatarSection.getByRole("link", { name: "Download avatar" }).click(); + expect((await settingsDownloadEvent).suggestedFilename()).toBe("maya-paperclip-avatar.png"); } await expect( diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts index 8ff15104f57..f3626d188c5 100644 --- a/tests/e2e/chat-adapters-ui.shared.ts +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -911,7 +911,7 @@ export async function expectSetupRail(page: Page) { const rail = page.getByRole("navigation", { name: "Connection setup progress" }); await expect(rail).toBeVisible(); const labels = new URL(page.url()).searchParams.get("provider") === "slack" - ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"] + ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Add avatar", "Connect your Slack account", "Try it"] : ["Choose agent", "Connect provider", "Try it"]; await expect(rail.getByRole("listitem")).toHaveCount(labels.length); for (const label of labels) { diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index 03b07000493..4e1c8d53a73 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -14,6 +14,9 @@ import { ChatEndpointDetail } from "./ChatEndpointDetail"; const mocks = vi.hoisted(() => ({ get: vi.fn(), + getAgent: vi.fn(), + listAgents: vi.fn(), + listResources: vi.fn(), tab: "access", listActivityPage: vi.fn(), create: vi.fn(), @@ -33,7 +36,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: mocks })); vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: "owner-user", name: "Owner" } }) } })); vi.mock("@/api/health", () => ({ healthApi: { get: async () => ({ deploymentMode: "authenticated" }) } })); -vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: mocks.listAgents, get: mocks.getAgent } })); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: async () => ({ enableIsolatedWorkspaces: true }) } })); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-a" }), @@ -67,6 +70,10 @@ describe("chat setup and identity-link clipboard actions", () => { const secret = "synthetic-one-time-webhook-secret"; beforeEach(() => { + localStorage.clear(); + mocks.listAgents.mockResolvedValue([{ id: "agent-a", name: "Maya", status: "idle" }]); + mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop" } }); + mocks.listResources.mockResolvedValue([]); mocks.tab = "access"; mocks.listActivityPage.mockReset(); container = document.createElement("div"); @@ -363,13 +370,13 @@ describe("chat setup and identity-link clipboard actions", () => { await client.invalidateQueries({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint.id] }); await settle(); expect(mocks.setup).toHaveBeenLastCalledWith(endpoint.id, { action: "verify", credentials: undefined }); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account"); - expect(container.textContent).toContain("/maya-test connect"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar"); + expect(container.textContent).toContain("Give Maya a face in Slack"); await click("4Verify Slack connection"); expect(container.querySelector("h1")?.textContent).toBe("Verify Slack connection"); expect(container.textContent).toContain("Slack verified your connection."); await click("Continue"); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar"); expect(mocks.setup).toHaveBeenCalledTimes(2); await click("3Add credentials"); expect(container.querySelector("h1")?.textContent).toBe("Add Slack credentials"); @@ -386,7 +393,7 @@ describe("chat setup and identity-link clipboard actions", () => { await settle(); expect(mocks.setup).toHaveBeenCalledTimes(1); expect(mocks.setup).toHaveBeenCalledWith(endpoint.id, { action: "verify", credentials: undefined }); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar"); }); it("picks up verification completed in another tab", async () => { @@ -400,7 +407,7 @@ describe("chat setup and identity-link clipboard actions", () => { flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", endpoint.id], verified)); await settle(); expect(mocks.setup).toHaveBeenCalledTimes(1); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar"); expect(container.textContent).not.toContain("Connection failed"); }); @@ -418,7 +425,7 @@ describe("chat setup and identity-link clipboard actions", () => { mocks.setup.mockResolvedValueOnce({ ...verified, setup: { ...verified.setup, step: "test" } }); await click("Continue"); expect(mocks.setup).toHaveBeenCalledTimes(2); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar"); }); async function renderSlackIdentityStep() { @@ -428,9 +435,56 @@ describe("chat setup and identity-link clipboard actions", () => { setup: { ...endpoint.setup, step: "test", command: "/maya-test", testStartedAt: "2026-09-18T20:00:00Z" }, })); await settle(); + await click("Skip for now"); return endpoint; } + it("uses the persisted agent appearance and remembers avatar confirmation on resume", async () => { + await renderSlackIdentityStep(); + await click("5Add avatar"); + const download = container.querySelector('a[download]')!; + expect(download.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/cherry-pop/rest.png?size=512&scale=1"); + expect(download.download).toBe("maya-paperclip-avatar.png"); + const endpoint = client.getQueryData(["chat-endpoint-setup-resume", "endpoint-a"])!; + flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", "endpoint-a"], { + ...endpoint, setup: { ...endpoint.setup, slackApp: { appName: "Custom Slack App", botName: "custom", command: "/custom" } }, + })); + await settle(); + expect(container.querySelector('a[download]')!.download).toBe("Custom-Slack-App-avatar.png"); + expect(container.textContent).toContain("Custom Slack App"); + await click("I’ve uploaded the avatar"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Connect your Slack account"); + await click("5Add avatar"); + expect(container.textContent).toContain("You marked the avatar as uploaded in Slack."); + expect(localStorage.getItem("paperclip:slack-avatar:v1:company-a:endpoint-a")).toBe("uploaded"); + flushSync(() => root.unmount()); + root = createRoot(container); + const saved = client.getQueryData(["chat-endpoint-setup-resume", "endpoint-a"])!; + mocks.get.mockResolvedValue(saved); + flushSync(() => root.render()); + await settle(); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Connect your Slack account"); + }); + + it("keeps a terminated agent’s saved avatar when the company list omits it", async () => { + mocks.listAgents.mockResolvedValue([]); + mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } }); + await renderSlackIdentityStep(); + await click("5Add avatar"); + expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1"); + expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); + }); + + it("keeps avatar download available in connector settings", async () => { + mocks.tab = "settings"; + await render("slack", true); + const section = container.querySelector('section[aria-label="Slack avatar"]')!; + expect(section.textContent).toContain("Download avatar"); + expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1"); + expect(section.querySelector("details")?.open).toBe(false); + expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); + }); + it("waits for a fresh connect command and links the selected identity inside the wizard", async () => { mocks.listPrincipals.mockResolvedValue([ { id: "old", principalId: "old", externalLabel: "Old workspace member", status: "pending", lastConnectAt: "2026-09-17T20:00:00Z" }, @@ -459,9 +513,9 @@ describe("chat setup and identity-link clipboard actions", () => { expect(mocks.createLinkIntent).toHaveBeenCalledWith("endpoint-a", "principal-a"); expect(mocks.confirmIdentityLink).toHaveBeenCalledWith("synthetic-private-confirmation-token"); expect(container.textContent).toContain("Linked to you"); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Connect your Slack account"); await click("Continue to message test"); - expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Try it"); + expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("7Try it"); expect(container.textContent).toContain("@Maya you there?"); await click("Copy message"); expect(copied).toContain("@Maya you there?"); @@ -478,10 +532,10 @@ describe("chat setup and identity-link clipboard actions", () => { client.setQueryData(["chat-endpoint-setup-test-status", "endpoint-a"], { messageReceivedAt: "2026-09-18T20:02:00Z" }); await settle(); expect(container.textContent).toContain("Received your Slack message."); - await click("5Connect your Slack account"); + await click("6Connect your Slack account"); expect([...container.querySelectorAll("h1")].find((heading) => !heading.closest("[hidden]"))?.textContent).toBe("Connect your Slack account"); expect(container.textContent).toContain("Linked to you"); - await click("6Try it"); + await click("7Try it"); expect([...container.querySelectorAll("h1")].find((heading) => !heading.closest("[hidden]"))?.textContent).toBe("Try Maya in Slack"); }); diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index c5d2ee946d4..3f3c424227a 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -1,3 +1,8 @@ +import { defaultSlackAppName } from "./slack-app-name"; +import { SlackAvatarSettings } from "./SlackAvatarStep"; +import { agentsApi } from "@/api/agents"; +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; import { EmailEndpointSettings } from "./EmailEndpointSetup"; import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -343,6 +348,11 @@ function Settings({ const queryClient = useQueryClient(); const { pushToast } = useToast(); const [messageCopied, setMessageCopied] = useState(false); + const avatarAgent = useQuery({ + queryKey: queryKeys.agents.detail(endpoint.assignedAgentId), + queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId), + enabled: endpoint.provider === "slack", + }); const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`; const resourcesQuery = useQuery({ queryKey: queryKeys.chatEndpoints.resources(endpointId), @@ -399,6 +409,15 @@ function Settings({ )} + {endpoint.provider === "slack" && ( + avatarAgent.isPending ?

Loading agent avatar…

+ : avatarAgent.isError ?

Couldn’t load the agent’s avatar.

+ : + )} {endpoint.provider === "telegram" && (

Telegram group command

diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index 7444d2819f8..27c1cd3e6a5 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -1,5 +1,10 @@ +import { defaultSlackAppName, slackBotNameForAgent } from "./slack-app-name"; import { SetupWizardFooter } from "@/components/SetupWizard"; import { ChatSetupNavigation } from "@/components/chat/ChatSetupNavigation"; +import { SlackAvatarStep } from "./SlackAvatarStep"; +import { useSlackAvatarProgress } from "./slack-avatar-progress"; +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; import { SlackIdentityStep } from "./SlackIdentityStep"; import { PhotonConnectStep } from "./PhotonConnectStep"; import { EmailEndpointSetup } from "./EmailEndpointSetup"; @@ -58,16 +63,6 @@ function isProvider(value: string | null): value is ChatProvider { return value !== null && knownProviders.has(value); } -function slackBotNameForAgent(agentName: string): string { - const safeName = agentName - .normalize("NFKD") - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 24); - return safeName || "paperclip-agent"; -} - function publicOrigin(value: string | null | undefined): string | null { if (!value) return null; try { @@ -358,15 +353,23 @@ function ChatSdkEndpointSetup() { reconnectRequested, ); const isSlack = provider === "slack"; - const tryStep = isSlack ? 5 : 2; + const avatarProgress = useSlackAvatarProgress(selectedCompanyId, endpoint?.id); + const tryStep = isSlack ? 6 : 2; const availableStep = endpoint ? !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") - ? isSlack && !slackIdentityReady && endpoint.setup?.step !== "complete" ? 4 : tryStep + ? isSlack && endpoint.setup?.step !== "complete" + ? !avatarProgress.progress ? 4 : !slackIdentityReady ? 5 : tryStep + : tryStep : isSlack && endpoint.providerAccountId && !repairing ? 3 : isSlack && (slackCredentialsReady || repairing) ? 2 : 1 : 0; const step = Math.min(viewedStep ?? availableStep, availableStep); + const avatarAgent = useQuery({ + queryKey: queryKeys.agents.detail(endpoint?.assignedAgentId ?? ""), + queryFn: () => agentsApi.get(endpoint!.assignedAgentId, endpoint!.companyId), + enabled: Boolean(isSlack && endpoint && step === 4), + }); const slackVerificationQuery = useQuery({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), @@ -407,7 +410,7 @@ function ChatSdkEndpointSetup() { if (purpose === "choice") { return (
- setPurpose("chat")} /> + setPurpose("chat")} />

Choose how to connect

@@ -446,11 +449,11 @@ function ChatSdkEndpointSetup() { ); } - const selectedAgent = activeAgents.find((agent) => agent.id === agentId); + const selectedAgent = agentsQuery.data?.find((agent) => agent.id === agentId); return (

)} {endpoint && isSlack && step === 4 && ( +
+ {avatarAgent.isPending ?

Loading agent avatar…

+ : avatarAgent.isError ?

Couldn’t load the agent’s avatar.

+ : { avatarProgress.save("uploaded"); setViewedStep(5); }} + onSkip={() => { if (!avatarProgress.progress) avatarProgress.save("skipped"); setViewedStep(5); }} + onSaveExit={() => navigate("/apps")} + />} + {(avatarAgent.isPending || avatarAgent.isError) && navigate("/apps")}>} +
+ )} + {endpoint && isSlack && step === 5 && ( navigate("/apps")} onConnected={() => { setSlackIdentityReady(true); - setViewedStep(5); + setViewedStep(6); }} /> )} @@ -569,7 +588,7 @@ function ChatSdkEndpointSetup() { onSaveExit={() => navigate("/apps")} /> )} - {step !== 0 && !(isSlack && (step === 1 || step === 2 || step === 3 || step === 4 || step === 5)) &&
+ {step !== 0 && !(isSlack && (step === 1 || step === 2 || step === 3 || step === 4 || step === 5 || step === 6)) &&
@@ -741,7 +760,7 @@ function ProviderConnectStep({ const defaultSlackBotName = slackBotNameForAgent(agentName); const [slackApp, setSlackApp] = useState(() => endpoint.setup?.slackApp ?? { - appName: `${defaultSlackBotName.slice(0, 25)}-paperclip`, + appName: defaultSlackAppName(agentName), botName: defaultSlackBotName, command: defaultSlackCommand, }, diff --git a/ui/src/pages/apps/chat/SlackAvatarStep.test.tsx b/ui/src/pages/apps/chat/SlackAvatarStep.test.tsx new file mode 100644 index 00000000000..32583c5723c --- /dev/null +++ b/ui/src/pages/apps/chat/SlackAvatarStep.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SlackAvatarContent } from "./SlackAvatarStep"; +import { useSlackAvatarProgress } from "./slack-avatar-progress"; + +const containers: Array<{ root: ReturnType; node: HTMLDivElement }> = []; +function render(element: React.ReactNode) { + const node = document.createElement("div"); + document.body.append(node); + const root = createRoot(node); + containers.push({ root, node }); + flushSync(() => root.render(element)); + return { node, root }; +} +afterEach(() => { + for (const { root, node } of containers.splice(0)) { flushSync(() => root.unmount()); node.remove(); } + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +async function settle() { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } + +describe("Slack avatar download", () => { + it("rejects error responses and downloads the PNG on retry", async () => { + const fetch = vi.fn().mockResolvedValueOnce({ ok: false, headers: new Headers() }) + .mockResolvedValueOnce({ ok: true, headers: new Headers({ "content-type": "image/png" }), blob: async () => new Blob(["png"], { type: "image/png" }) }); + vi.stubGlobal("fetch", fetch); + const createObjectURL = vi.fn(() => "blob:avatar"); + vi.stubGlobal("URL", class extends URL { static createObjectURL = createObjectURL; static revokeObjectURL = vi.fn(); }); + const downloaded: string[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { downloaded.push(this.download); }); + const { node } = render(); + const clickDownload = async () => { + flushSync(() => node.querySelector('a[download]')!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))); + await settle(); + }; + await clickDownload(); + expect(node.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t download"); + expect(downloaded).toEqual([]); + await clickDownload(); + expect(downloaded).toEqual(["Maya-App-avatar.png"]); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(node.querySelector('[role="alert"]')).toBeNull(); + await new Promise(resolve => setTimeout(resolve, 1_100)); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:avatar"); + }); + it("does not download a successful HTML login response as an image", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, headers: new Headers({ "content-type": "text/html" }) })); + const { node } = render(); + flushSync(() => node.querySelector('a[download]')!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))); + await settle(); + expect(node.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t download"); + }); +}); + +function Progress({ company, endpoint }: { company: string; endpoint: string }) { + const { progress, save } = useSlackAvatarProgress(company, endpoint); + return ; +} +describe("optional avatar progress", () => { + it("keeps progress scoped to the company and endpoint", () => { + const { root, node } = render(); + flushSync(() => node.querySelector("button")!.click()); + expect(node.textContent).toBe("skipped"); + flushSync(() => root.render()); + expect(node.textContent).toBe("pending"); + flushSync(() => root.render()); + expect(node.textContent).toBe("pending"); + flushSync(() => root.render()); + expect(node.textContent).toBe("skipped"); + }); + it("allows skipping even if browser storage is unavailable", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Unavailable"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Unavailable"); }); + const { node } = render(); + flushSync(() => node.querySelector("button")!.click()); + expect(node.textContent).toBe("skipped"); + }); +}); diff --git a/ui/src/pages/apps/chat/SlackAvatarStep.tsx b/ui/src/pages/apps/chat/SlackAvatarStep.tsx new file mode 100644 index 00000000000..5b6f2612a4d --- /dev/null +++ b/ui/src/pages/apps/chat/SlackAvatarStep.tsx @@ -0,0 +1,218 @@ +import { useId, useState } from "react"; +import { + ArrowRight, + Check, + Download, + ExternalLink, + Loader2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SetupWizardFooter } from "@/components/SetupWizard"; + +export interface SlackAvatarProps { + agentName: string; + appName: string; + avatarUrl: string; +} + +/** Shared by Slack onboarding and its Settings page. Slack upload is manual. */ +export function SlackAvatarContent({ + agentName, + appName, + avatarUrl, + compact = false, +}: SlackAvatarProps & { compact?: boolean }) { + const id = useId(); + const filename = `${appName.replace(/[^a-zA-Z0-9_-]+/g, "-") || "agent"}-avatar.png`; + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(false); + const download = async () => { + if (downloading) return; + setDownloading(true); + setDownloadError(false); + try { + const response = await fetch(avatarUrl); + if ( + !response.ok || + !response.headers.get("content-type")?.startsWith("image/png") + ) + throw new Error("Avatar unavailable"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1_000); + } catch { + setDownloadError(true); + } finally { + setDownloading(false); + } + }; + return ( +
+
+ {`${agentName}’s +
+
+

+ {compact + ? "Download your agent’s avatar" + : "1. Download your agent’s avatar"} +

+

+ PNG · 512 × 512 · Ready for Slack +

+
+ + {downloadError && ( +

+ Couldn’t download the avatar. Try downloading it again. +

+ )} +
+
+ +
+ + How to upload in Slack + +
+
+

+ {compact ? "Upload it in Slack" : "2. Upload it in Slack"} +

+

+ You’ll upload the downloaded image directly in Slack’s app + settings. +

+
+
    +
  1. + + Open Slack app Settings{" "} + + {" "} + and choose {appName}. +
  2. +
  3. + Choose Basic Information, then scroll to{" "} + Display Information. +
  4. +
  5. + Under App icon & Preview, click the app icon + and upload{" "} + {filename}. +
  6. +
  7. + Confirm the crop, then click Save Changes in + Slack. +
  8. +
+
+
+
+ ); +} + +export function SlackAvatarStep({ + uploaded, + onUploaded, + onSkip, + onSaveExit, + ...props +}: SlackAvatarProps & { + uploaded: boolean; + onUploaded: () => void; + onSkip: () => void; + onSaveExit: () => void; +}) { + return ( +
+
+
+

+ Give {props.agentName} a face in Slack +

+ Optional +
+

+ Use {props.agentName}’s avatar so your team recognizes the agent +

+
+ + {uploaded && ( +

+ + You marked the avatar as uploaded in Slack. +

+ )} + + + + +
+ ); +} + +export function SlackAvatarSettings(props: SlackAvatarProps) { + return ( +
+
+

Agent avatar

+

+ Use {props.agentName}’s avatar so your team recognizes the agent +

+
+ +
+ ); +} diff --git a/ui/src/pages/apps/chat/slack-app-name.ts b/ui/src/pages/apps/chat/slack-app-name.ts new file mode 100644 index 00000000000..259f626f182 --- /dev/null +++ b/ui/src/pages/apps/chat/slack-app-name.ts @@ -0,0 +1,13 @@ +export function slackBotNameForAgent(agentName: string): string { + const safeName = agentName + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 24); + return safeName || "paperclip-agent"; +} + +export function defaultSlackAppName(agentName: string): string { + return `${slackBotNameForAgent(agentName).slice(0, 25)}-paperclip`; +} diff --git a/ui/src/pages/apps/chat/slack-avatar-progress.ts b/ui/src/pages/apps/chat/slack-avatar-progress.ts new file mode 100644 index 00000000000..eae6c8d3a9a --- /dev/null +++ b/ui/src/pages/apps/chat/slack-avatar-progress.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; + +export type SlackAvatarProgress = "uploaded" | "skipped"; +function storageKey(companyId: string, endpointId: string) { + return `paperclip:slack-avatar:v1:${companyId}:${endpointId}`; +} +function read(key: string): SlackAvatarProgress | null { + try { + const value = localStorage.getItem(key); + return value === "uploaded" || value === "skipped" ? value : null; + } catch { + return null; + } +} + +/** A browser-local convenience, never proof of provider configuration. */ +export function useSlackAvatarProgress( + companyId: string | null, + endpointId: string | undefined, +) { + const key = + companyId && endpointId ? storageKey(companyId, endpointId) : null; + const [current, setCurrent] = useState<{ + key: string; + value: SlackAvatarProgress; + } | null>(null); + const progress = key + ? current?.key === key + ? current.value + : read(key) + : null; + const save = (value: SlackAvatarProgress) => { + if (!key) return; + setCurrent({ key, value }); + try { + localStorage.setItem(key, value); + } catch { + /* Optional step still works without browser storage. */ + } + }; + return { progress, save }; +} diff --git a/ui/storybook/prototypes/slack-avatar/README.md b/ui/storybook/prototypes/slack-avatar/README.md new file mode 100644 index 00000000000..4affa21478e --- /dev/null +++ b/ui/storybook/prototypes/slack-avatar/README.md @@ -0,0 +1,32 @@ +# Slack avatar setup preview + +Storybook: **Connections / Slack / Add avatar**. + +Covers the production optional numbered step after connection verification and before +personal account linking. Download the agent's Cliptoon PNG, upload it directly +in Slack, then confirm or skip. Save & exit shares the footer with the primary +action. Stories use local wizard state and do not save anything to Slack. The production +wizard remembers uploaded/skipped in browser storage per company and endpoint; +clearing storage or switching browsers may show the optional step again. Completed +connections are not sent back through this step. Settings always offers download +and collapsible upload instructions. + +`ceo-cliptoon.png` is a 512 × 512 PNG rendered by the production +`createAgentAvatarPool` using `appearanceForPalette("arctic-blue")`, pose `rest`, +scale 1, muted false. It is a real downloadable fixture, not a screenshot or +placeholder. Production resolves the selected agent's persisted +appearance and uses `agentAvatarUrl(appearance, 512, 1, "rest")` for its image and +download. Do not select a new palette at render time. Uploaded status is only the +user's confirmation; Paperclip has not verified the Slack icon. + +Provider instructions checked against Slack's official documentation: +- https://docs.slack.dev/surfaces/app-home/#using-the-about-tab +- https://docs.slack.dev/concepts/app-design/#logo-assets +- https://docs.slack.dev/reference/methods/apps.icon.set (512–2000px size limits) + +Review Download and upload, Uploaded, Mobile, and Confirm and return. The last +story exercises confirmation, handoff, and returning without losing local state. + +**Connections / Slack / Avatar in Settings** shows the permanent download after +onboarding, including the optional upload instructions. Both previews render the +same avatar components as the real wizard and Settings page. diff --git a/ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx b/ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx new file mode 100644 index 00000000000..78bc9c9bf34 --- /dev/null +++ b/ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx @@ -0,0 +1,66 @@ +import { Copy, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SlackAvatarSettings } from "@/pages/apps/chat/SlackAvatarStep"; +import avatar from "./ceo-cliptoon.png"; + +export function SlackAvatarSettingsPreview() { + return ( +
+
+ Connectors /CEO · Slack +
+
+ +
+
+

CEO in Slack

+
+

Chat in Slack

+

+ Invite the bot to a channel, then mention it to start a + conversation. +

+
+ @ceo you there? + +
+
+ +
+

+ Where this agent can work +

+

Allowed Channels

+
+ #general{" "} + +
+
+
+
+
+
+ ); +} diff --git a/ui/storybook/prototypes/slack-avatar/SlackAvatarStep.tsx b/ui/storybook/prototypes/slack-avatar/SlackAvatarStep.tsx new file mode 100644 index 00000000000..fb7f6b54b5c --- /dev/null +++ b/ui/storybook/prototypes/slack-avatar/SlackAvatarStep.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { SlackAvatarStep as AvatarStep } from "@/pages/apps/chat/SlackAvatarStep"; +import { Button } from "@/components/ui/button"; +import { SetupWizardNavigation } from "@/components/SetupWizard"; +import avatar from "./ceo-cliptoon.png"; + +const labels = [ + "Choose agent", + "Create Slack app", + "Add credentials", + "Verify Slack connection", + "Add avatar", + "Connect your Slack account", + "Try it", +]; + +/** Design preview: the PNG uses the production Cliptoon renderer; wizard state is local. */ +export function SlackAvatarStep({ + agentName = "CEO", + appName = "ceo-paperclip", + initialUploaded = false, +}: { + agentName?: string; + appName?: string; + initialUploaded?: boolean; +}) { + const [step, setStep] = useState(4); + const [uploaded, setUploaded] = useState(initialUploaded); + const [exited, setExited] = useState(false); + + return ( +
+
+ Connectors + + Connect Slack +
+
+
+ + Step {step + 1} of {labels.length} · {labels[step]} + +
+ { + setStep(next); + setExited(false); + }} + /> +
+
+ +
+
+ {step === 4 && !exited ? ( + { + setUploaded(true); + setStep(5); + }} + onSkip={() => setStep(5)} + onSaveExit={() => setExited(true)} + /> + ) : ( + <> +

+ {exited ? "Setup paused" : labels[step]} +

+

+ {exited + ? "Preview only: Save & exit would save your place and return to Connectors." + : step === 5 + ? "Next, link your personal Slack account to Paperclip. This preview stops at the handoff to that step." + : "This preview focuses on the new avatar step. Existing setup steps keep their current behavior."} +

+ + + )} +
+
+
+
+ ); +} diff --git a/ui/storybook/prototypes/slack-avatar/ceo-cliptoon.png b/ui/storybook/prototypes/slack-avatar/ceo-cliptoon.png new file mode 100644 index 0000000000000000000000000000000000000000..debcdff588d229f065ebd6ebf548c0caed5ada6a GIT binary patch literal 26212 zcmeEu`8$+t{PsO-6N(|mP)tL}Hky>|qsH1|NvJRqCCL)mml-`}U$Uf4WJyY>$j-DV zOSV=!g$&urzP#71@Av%|-e2D5I6CThGWUJWXFbpJ^SO#JIibIeON0x8plyZ*bTbG- zfo&rOaLrs??O4IckMA2v9A>fx*T zho2mbJAzsi_b(KGuWDYZ2?^@^8NGj{$~P^b#w}G1y#D|H_y5`g@exg~+Ci0<12R4G ze{CNKoOdm&{-yuX?1B=kLrXt&#L*QvfhN2t3mU+$3Co#9sRg6 zepF&((mJI6Vp;IY>NNNn>BaFIE`jr*)4?n4)9VQc0c365@1?Pxnbq1|AGVaJ_*ZoY ztF5yT;A@K{GRlW`+;99w?HVXOpZ8fOb~{aXAX_EPVq`3NEPH3F-9*0&;78Y-DV>*$&wGuy(&+Q5+MU1jUL6z*

!Tqr=^Y)asoVxDSOkU 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 +export default meta; +type Story = StoryObj; +export const Settings: Story = {}; diff --git a/ui/storybook/stories/slack-avatar-setup.stories.tsx b/ui/storybook/stories/slack-avatar-setup.stories.tsx new file mode 100644 index 00000000000..e16dedc9b2f --- /dev/null +++ b/ui/storybook/stories/slack-avatar-setup.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { SlackAvatarStep } from "../prototypes/slack-avatar/SlackAvatarStep"; + +const meta = { + title: "Connections/Slack/Add avatar", + component: SlackAvatarStep, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Proposed optional step after verifying Slack, before linking your account. Downloads a real 512px Cliptoon PNG. Upload is manual in Slack; confirmation is user-reported. Navigation and Save & exit are local preview state, not production persistence." } }, + }, + args: { agentName: "CEO", appName: "ceo-paperclip" }, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const DownloadAndUpload: Story = {}; +export const Uploaded: Story = { args: { initialUploaded: true } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const ConfirmAndReturn: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("link", { name: "Download avatar" })).toHaveAttribute("download", "ceo-paperclip-avatar.png"); + await userEvent.click(canvas.getByRole("button", { name: "I’ve uploaded the avatar" })); + await expect(canvas.getByRole("heading", { name: "Connect your Slack account" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Back to avatar step" })); + await expect(canvas.getByRole("status")).toHaveTextContent("You marked the avatar as uploaded"); + }, +};