Skip to content

Commit 57fd8b7

Browse files
feat: add agent avatar download to Slack setup and settings (#13740)
## 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 <noreply@paperclip.ing>
1 parent fd07174 commit 57fd8b7

16 files changed

Lines changed: 767 additions & 30 deletions

doc/connections/CHAT-CONNECTOR-UX.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,18 @@ email integration's actual mechanisms.
300300

301301
The intended result is consistent interaction and permission semantics across
302302
providers, with instructions and step count tailored to each real workflow.
303+
304+
## Agent avatars in provider setup
305+
306+
Offer the selected agent's existing avatar as a downloadable image when the
307+
provider supports a custom bot image. Keep manual upload instructions next to
308+
the download, use the saved provider app name, and distinguish user confirmation
309+
from provider verification. Keep a download in connector Settings so skipping
310+
the optional setup step does not hide it permanently.
311+
312+
Slack uses a 512 × 512 PNG from the existing avatar renderer. Its optional step
313+
comes after connection verification and before personal account linking. Upload
314+
the file through Basic Information → Display Information → App icon & Preview,
315+
confirm the crop, then save in Slack. Confirmation/skipping is browser-local,
316+
scoped to the company and endpoint; it is not proof of a Slack configuration
317+
change. No new Slack scope or API mutation is needed.

tests/e2e/chat-adapters-ui-providers.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFile } from "node:fs/promises";
12
import { expect, test, type Page } from "@playwright/test";
23

34
import {
@@ -237,6 +238,16 @@ test.describe.serial("native chat adapter UI", () => {
237238
await expect(page.getByRole("heading", { name: "Verify Slack connection" })).toBeVisible();
238239
await expect(page.getByText("Slack needs to confirm that it can reach your Paperclip instance.")).toBeVisible();
239240
mock.setWebhookVerified();
241+
await expect(page.getByRole("heading", { name: "Give Maya a face in Slack" })).toBeVisible();
242+
const downloadEvent = page.waitForEvent("download");
243+
await page.getByRole("link", { name: "Download avatar" }).click();
244+
const download = await downloadEvent;
245+
expect(download.suggestedFilename()).toBe("maya-paperclip-avatar.png");
246+
const png = await readFile((await download.path())!);
247+
expect(png.subarray(1, 4).toString()).toBe("PNG");
248+
expect(png.readUInt32BE(16)).toBe(512);
249+
expect(png.readUInt32BE(20)).toBe(512);
250+
await page.getByRole("button", { name: "I’ve uploaded the avatar" }).click();
240251
await expect(page.getByRole("heading", { name: "Connect your Slack account" })).toBeVisible();
241252
await expect(page.getByText("/maya-public connect", { exact: true })).toBeVisible();
242253
await page.getByRole("button", { name: "Link Test operator to my Paperclip account" }).click();
@@ -318,6 +329,13 @@ test.describe.serial("native chat adapter UI", () => {
318329
await expect(page.getByText("@maya-paperclip you there?", { exact: true })).toBeVisible();
319330
await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible();
320331
await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible();
332+
const avatarSection = page.getByRole("region", { name: "Slack avatar" });
333+
await expect(avatarSection.getByRole("link", { name: "Download avatar" })).toBeVisible();
334+
await avatarSection.getByText("How to upload in Slack", { exact: true }).click();
335+
await expect(avatarSection.getByRole("link", { name: "Open Slack app Settings" })).toBeVisible();
336+
const settingsDownloadEvent = page.waitForEvent("download");
337+
await avatarSection.getByRole("link", { name: "Download avatar" }).click();
338+
expect((await settingsDownloadEvent).suggestedFilename()).toBe("maya-paperclip-avatar.png");
321339
}
322340

323341
await expect(

tests/e2e/chat-adapters-ui.shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -911,7 +911,7 @@ export async function expectSetupRail(page: Page) {
911911
const rail = page.getByRole("navigation", { name: "Connection setup progress" });
912912
await expect(rail).toBeVisible();
913913
const labels = new URL(page.url()).searchParams.get("provider") === "slack"
914-
? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"]
914+
? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Add avatar", "Connect your Slack account", "Try it"]
915915
: ["Choose agent", "Connect provider", "Try it"];
916916
await expect(rail.getByRole("listitem")).toHaveCount(labels.length);
917917
for (const label of labels) {

ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { ChatEndpointDetail } from "./ChatEndpointDetail";
1414

1515
const mocks = vi.hoisted(() => ({
1616
get: vi.fn(),
17+
getAgent: vi.fn(),
18+
listAgents: vi.fn(),
19+
listResources: vi.fn(),
1720
tab: "access",
1821
listActivityPage: vi.fn(),
1922
create: vi.fn(),
@@ -33,7 +36,7 @@ const mocks = vi.hoisted(() => ({
3336
vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: mocks }));
3437
vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: "owner-user", name: "Owner" } }) } }));
3538
vi.mock("@/api/health", () => ({ healthApi: { get: async () => ({ deploymentMode: "authenticated" }) } }));
36-
vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } }));
39+
vi.mock("@/api/agents", () => ({ agentsApi: { list: mocks.listAgents, get: mocks.getAgent } }));
3740
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: async () => ({ enableIsolatedWorkspaces: true }) } }));
3841
vi.mock("@/context/CompanyContext", () => ({
3942
useCompany: () => ({ selectedCompanyId: "company-a" }),
@@ -67,6 +70,10 @@ describe("chat setup and identity-link clipboard actions", () => {
6770
const secret = "synthetic-one-time-webhook-secret";
6871

6972
beforeEach(() => {
73+
localStorage.clear();
74+
mocks.listAgents.mockResolvedValue([{ id: "agent-a", name: "Maya", status: "idle" }]);
75+
mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop" } });
76+
mocks.listResources.mockResolvedValue([]);
7077
mocks.tab = "access";
7178
mocks.listActivityPage.mockReset();
7279
container = document.createElement("div");
@@ -363,13 +370,13 @@ describe("chat setup and identity-link clipboard actions", () => {
363370
await client.invalidateQueries({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint.id] });
364371
await settle();
365372
expect(mocks.setup).toHaveBeenLastCalledWith(endpoint.id, { action: "verify", credentials: undefined });
366-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account");
367-
expect(container.textContent).toContain("/maya-test connect");
373+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar");
374+
expect(container.textContent).toContain("Give Maya a face in Slack");
368375
await click("4Verify Slack connection");
369376
expect(container.querySelector("h1")?.textContent).toBe("Verify Slack connection");
370377
expect(container.textContent).toContain("Slack verified your connection.");
371378
await click("Continue");
372-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account");
379+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar");
373380
expect(mocks.setup).toHaveBeenCalledTimes(2);
374381
await click("3Add credentials");
375382
expect(container.querySelector("h1")?.textContent).toBe("Add Slack credentials");
@@ -386,7 +393,7 @@ describe("chat setup and identity-link clipboard actions", () => {
386393
await settle();
387394
expect(mocks.setup).toHaveBeenCalledTimes(1);
388395
expect(mocks.setup).toHaveBeenCalledWith(endpoint.id, { action: "verify", credentials: undefined });
389-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account");
396+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar");
390397
});
391398

392399
it("picks up verification completed in another tab", async () => {
@@ -400,7 +407,7 @@ describe("chat setup and identity-link clipboard actions", () => {
400407
flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", endpoint.id], verified));
401408
await settle();
402409
expect(mocks.setup).toHaveBeenCalledTimes(1);
403-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account");
410+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar");
404411
expect(container.textContent).not.toContain("Connection failed");
405412
});
406413

@@ -418,7 +425,7 @@ describe("chat setup and identity-link clipboard actions", () => {
418425
mocks.setup.mockResolvedValueOnce({ ...verified, setup: { ...verified.setup, step: "test" } });
419426
await click("Continue");
420427
expect(mocks.setup).toHaveBeenCalledTimes(2);
421-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account");
428+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Add avatar");
422429
});
423430

424431
async function renderSlackIdentityStep() {
@@ -428,9 +435,56 @@ describe("chat setup and identity-link clipboard actions", () => {
428435
setup: { ...endpoint.setup, step: "test", command: "/maya-test", testStartedAt: "2026-09-18T20:00:00Z" },
429436
}));
430437
await settle();
438+
await click("Skip for now");
431439
return endpoint;
432440
}
433441

442+
it("uses the persisted agent appearance and remembers avatar confirmation on resume", async () => {
443+
await renderSlackIdentityStep();
444+
await click("5Add avatar");
445+
const download = container.querySelector<HTMLAnchorElement>('a[download]')!;
446+
expect(download.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/cherry-pop/rest.png?size=512&scale=1");
447+
expect(download.download).toBe("maya-paperclip-avatar.png");
448+
const endpoint = client.getQueryData<ChatEndpoint>(["chat-endpoint-setup-resume", "endpoint-a"])!;
449+
flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", "endpoint-a"], {
450+
...endpoint, setup: { ...endpoint.setup, slackApp: { appName: "Custom Slack App", botName: "custom", command: "/custom" } },
451+
}));
452+
await settle();
453+
expect(container.querySelector<HTMLAnchorElement>('a[download]')!.download).toBe("Custom-Slack-App-avatar.png");
454+
expect(container.textContent).toContain("Custom Slack App");
455+
await click("I’ve uploaded the avatar");
456+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Connect your Slack account");
457+
await click("5Add avatar");
458+
expect(container.textContent).toContain("You marked the avatar as uploaded in Slack.");
459+
expect(localStorage.getItem("paperclip:slack-avatar:v1:company-a:endpoint-a")).toBe("uploaded");
460+
flushSync(() => root.unmount());
461+
root = createRoot(container);
462+
const saved = client.getQueryData<ChatEndpoint>(["chat-endpoint-setup-resume", "endpoint-a"])!;
463+
mocks.get.mockResolvedValue(saved);
464+
flushSync(() => root.render(<QueryClientProvider client={client}><TooltipProvider><ChatSetupSidebarProvider><ChatSetupSidebar /><ChatEndpointSetup /></ChatSetupSidebarProvider></TooltipProvider></QueryClientProvider>));
465+
await settle();
466+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Connect your Slack account");
467+
});
468+
469+
it("keeps a terminated agent’s saved avatar when the company list omits it", async () => {
470+
mocks.listAgents.mockResolvedValue([]);
471+
mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } });
472+
await renderSlackIdentityStep();
473+
await click("5Add avatar");
474+
expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1");
475+
expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a");
476+
});
477+
478+
it("keeps avatar download available in connector settings", async () => {
479+
mocks.tab = "settings";
480+
await render("slack", true);
481+
const section = container.querySelector('section[aria-label="Slack avatar"]')!;
482+
expect(section.textContent).toContain("Download avatar");
483+
expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1");
484+
expect(section.querySelector("details")?.open).toBe(false);
485+
expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a");
486+
});
487+
434488
it("waits for a fresh connect command and links the selected identity inside the wizard", async () => {
435489
mocks.listPrincipals.mockResolvedValue([
436490
{ 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", () => {
459513
expect(mocks.createLinkIntent).toHaveBeenCalledWith("endpoint-a", "principal-a");
460514
expect(mocks.confirmIdentityLink).toHaveBeenCalledWith("synthetic-private-confirmation-token");
461515
expect(container.textContent).toContain("Linked to you");
462-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("5Connect your Slack account");
516+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Connect your Slack account");
463517
await click("Continue to message test");
464-
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("6Try it");
518+
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("7Try it");
465519
expect(container.textContent).toContain("@Maya you there?");
466520
await click("Copy message");
467521
expect(copied).toContain("@Maya you there?");
@@ -478,10 +532,10 @@ describe("chat setup and identity-link clipboard actions", () => {
478532
client.setQueryData(["chat-endpoint-setup-test-status", "endpoint-a"], { messageReceivedAt: "2026-09-18T20:02:00Z" });
479533
await settle();
480534
expect(container.textContent).toContain("Received your Slack message.");
481-
await click("5Connect your Slack account");
535+
await click("6Connect your Slack account");
482536
expect([...container.querySelectorAll("h1")].find((heading) => !heading.closest("[hidden]"))?.textContent).toBe("Connect your Slack account");
483537
expect(container.textContent).toContain("Linked to you");
484-
await click("6Try it");
538+
await click("7Try it");
485539
expect([...container.querySelectorAll("h1")].find((heading) => !heading.closest("[hidden]"))?.textContent).toBe("Try Maya in Slack");
486540
});
487541

ui/src/pages/apps/chat/ChatEndpointDetail.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { defaultSlackAppName } from "./slack-app-name";
2+
import { SlackAvatarSettings } from "./SlackAvatarStep";
3+
import { agentsApi } from "@/api/agents";
4+
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
5+
import { resolveAgentAppearance } from "@paperclipai/shared";
16
import { EmailEndpointSettings } from "./EmailEndpointSetup";
27
import { useEffect, useMemo, useState } from "react";
38
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -343,6 +348,11 @@ function Settings({
343348
const queryClient = useQueryClient();
344349
const { pushToast } = useToast();
345350
const [messageCopied, setMessageCopied] = useState(false);
351+
const avatarAgent = useQuery({
352+
queryKey: queryKeys.agents.detail(endpoint.assignedAgentId),
353+
queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId),
354+
enabled: endpoint.provider === "slack",
355+
});
346356
const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`;
347357
const resourcesQuery = useQuery({
348358
queryKey: queryKeys.chatEndpoints.resources(endpointId),
@@ -399,6 +409,15 @@ function Settings({
399409
</div>
400410
</div>
401411
)}
412+
{endpoint.provider === "slack" && (
413+
avatarAgent.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agent avatar…</p>
414+
: avatarAgent.isError ? <p role="alert" className="text-sm text-destructive">Couldn’t load the agent’s avatar. <button className="underline" onClick={() => void avatarAgent.refetch()}>Try again</button></p>
415+
: <SlackAvatarSettings
416+
agentName={avatarAgent.data?.name ?? endpoint.assignedAgentName}
417+
appName={endpoint.setup?.slackApp?.appName ?? defaultSlackAppName(avatarAgent.data?.name ?? endpoint.assignedAgentName)}
418+
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatarAgent.data?.appearance, endpoint.assignedAgentId), 512, 1, "rest")}
419+
/>
420+
)}
402421
{endpoint.provider === "telegram" && (
403422
<div className="space-y-2">
404423
<h2 className="text-lg font-semibold">Telegram group command</h2>

0 commit comments

Comments
 (0)