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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-21 09:38:48 -05:00
1 parent fd071748ee
commit 57fd8b70d2
16 files changed
+767 -30

No files matched your search

+15
View File
@@ -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.
@@ -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(
+1 -1
View File
@@ -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) {
@@ -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<HTMLAnchorElement>('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<ChatEndpoint>(["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<HTMLAnchorElement>('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<ChatEndpoint>(["chat-endpoint-setup-resume", "endpoint-a"])!;
mocks.get.mockResolvedValue(saved);
flushSync(() => root.render(<QueryClientProvider client={client}><TooltipProvider><ChatSetupSidebarProvider><ChatSetupSidebar /><ChatEndpointSetup /></ChatSetupSidebarProvider></TooltipProvider></QueryClientProvider>));
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");
});
@@ -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({
</div>
</div>
)}
{endpoint.provider === "slack" && (
avatarAgent.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agent avatar…</p>
: 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>
: <SlackAvatarSettings
agentName={avatarAgent.data?.name ?? endpoint.assignedAgentName}
appName={endpoint.setup?.slackApp?.appName ?? defaultSlackAppName(avatarAgent.data?.name ?? endpoint.assignedAgentName)}
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatarAgent.data?.appearance, endpoint.assignedAgentId), 512, 1, "rest")}
/>
)}
{endpoint.provider === "telegram" && (
<div className="space-y-2">
<h2 className="text-lg font-semibold">Telegram group command</h2>
+37 -18
View File
@@ -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 (
<div className="max-w-2xl space-y-6">
<ChatSetupNavigation labels={provider === "slack" ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"] : undefined} step={0} availableStep={0} onSelect={() => setPurpose("chat")} />
<ChatSetupNavigation labels={provider === "slack" ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Add avatar", "Connect your Slack account", "Try it"] : undefined} step={0} availableStep={0} onSelect={() => setPurpose("chat")} />
<div>
<h1 className="text-xl font-bold">Choose how to connect</h1>
<p className="mt-1 text-sm text-muted-foreground">
@@ -446,11 +449,11 @@ function ChatSdkEndpointSetup() {
);
}
const selectedAgent = activeAgents.find((agent) => agent.id === agentId);
const selectedAgent = agentsQuery.data?.find((agent) => agent.id === agentId);
return (
<div className="max-w-2xl space-y-6">
<ChatSetupNavigation
labels={isSlack ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"] : undefined}
labels={isSlack ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Add avatar", "Connect your Slack account", "Try it"] : undefined}
step={step}
availableStep={availableStep}
disabled={createEndpoint.isPending || setupAction.isPending || generateSetupSecret.isPending || testConnection.isPending}
@@ -533,6 +536,22 @@ function ChatSdkEndpointSetup() {
</div>
)}
{endpoint && isSlack && step === 4 && (
<div className="space-y-4">
{avatarAgent.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agent avatar…</p>
: 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>
: <SlackAvatarStep
agentName={avatarAgent.data?.name ?? endpoint.assignedAgentName}
appName={endpoint.setup?.slackApp?.appName ?? defaultSlackAppName(avatarAgent.data?.name ?? endpoint.assignedAgentName)}
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatarAgent.data?.appearance, endpoint.assignedAgentId), 512, 1, "rest")}
uploaded={avatarProgress.progress === "uploaded"}
onUploaded={() => { avatarProgress.save("uploaded"); setViewedStep(5); }}
onSkip={() => { if (!avatarProgress.progress) avatarProgress.save("skipped"); setViewedStep(5); }}
onSaveExit={() => navigate("/apps")}
/>}
{(avatarAgent.isPending || avatarAgent.isError) && <SetupWizardFooter onSaveExit={() => navigate("/apps")}><Button onClick={() => { avatarProgress.save("skipped"); setViewedStep(5); }}>Skip for now</Button></SetupWizardFooter>}
</div>
)}
{endpoint && isSlack && step === 5 && (
<SlackIdentityStep
endpointId={endpoint.id}
command={endpoint.setup?.slackApp?.command ?? endpoint.setup?.command ?? "/paperclip"}
@@ -540,7 +559,7 @@ function ChatSdkEndpointSetup() {
onSaveExit={() => 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)) && <div className="flex justify-start">
{step !== 0 && !(isSlack && (step === 1 || step === 2 || step === 3 || step === 4 || step === 5 || step === 6)) && <div className="flex justify-start">
<Button className="text-muted-foreground" variant="ghost" onClick={() => navigate("/apps")}>
Save &amp; exit
</Button>
@@ -741,7 +760,7 @@ function ProviderConnectStep({
const defaultSlackBotName = slackBotNameForAgent(agentName);
const [slackApp, setSlackApp] = useState<SlackAppConfiguration>(() =>
endpoint.setup?.slackApp ?? {
appName: `${defaultSlackBotName.slice(0, 25)}-paperclip`,
appName: defaultSlackAppName(agentName),
botName: defaultSlackBotName,
command: defaultSlackCommand,
},
@@ -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<typeof createRoot>; 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(<SlackAvatarContent agentName="Maya" appName="Maya / App" avatarUrl="/api/agent-avatars/cap-v1/cherry-pop/rest.png?size=512&scale=1" />);
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(<SlackAvatarContent agentName="Maya" appName="Maya" avatarUrl="/avatar.png" />);
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 <button onClick={() => save("skipped")}>{progress ?? "pending"}</button>;
}
describe("optional avatar progress", () => {
it("keeps progress scoped to the company and endpoint", () => {
const { root, node } = render(<Progress company="one" endpoint="a" />);
flushSync(() => node.querySelector("button")!.click());
expect(node.textContent).toBe("skipped");
flushSync(() => root.render(<Progress company="one" endpoint="b" />));
expect(node.textContent).toBe("pending");
flushSync(() => root.render(<Progress company="two" endpoint="a" />));
expect(node.textContent).toBe("pending");
flushSync(() => root.render(<Progress company="one" endpoint="a" />));
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(<Progress company="one" endpoint="a" />);
flushSync(() => node.querySelector("button")!.click());
expect(node.textContent).toBe("skipped");
});
});
+218
View File
@@ -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 (
<div className="space-y-8">
<section
aria-labelledby={`${id}-download`}
className="flex flex-col items-start gap-6 sm:flex-row sm:items-center"
>
<img
src={avatarUrl}
width={512}
height={512}
alt={`${agentName}’s Cliptoon avatar`}
className="size-40 shrink-0 rounded-lg bg-muted object-contain"
/>
<div className="space-y-3">
<div className="space-y-1">
<h2 id={`${id}-download`} className="text-sm font-semibold">
{compact
? "Download your agent’s avatar"
: "1. Download your agent’s avatar"}
</h2>
<p className="text-xs text-muted-foreground">
PNG · 512 × 512 · Ready for Slack
</p>
</div>
<Button variant="outline" asChild>
<a
href={avatarUrl}
download={filename}
aria-disabled={downloading}
onClick={(event) => {
event.preventDefault();
void download();
}}
>
{downloading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
Download avatar
</a>
</Button>
{downloadError && (
<p role="alert" className="text-sm text-destructive">
Couldn’t download the avatar. Try downloading it again.
</p>
)}
</div>
</section>
<details open={compact ? undefined : true} className="space-y-4">
<summary
className={
compact
? "cursor-pointer text-sm underline underline-offset-4"
: "hidden"
}
>
How to upload in Slack
</summary>
<section aria-labelledby={`${id}-upload`} className="space-y-4">
<div className="space-y-1">
<h2 id={`${id}-upload`} className="text-sm font-semibold">
{compact ? "Upload it in Slack" : "2. Upload it in Slack"}
</h2>
<p className="text-sm text-muted-foreground">
You’ll upload the downloaded image directly in Slack’s app
settings.
</p>
</div>
<ol className="list-decimal space-y-3 pl-5 text-sm">
<li>
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
Open Slack app Settings{" "}
<ExternalLink className="inline size-3" />
</a>{" "}
and choose <strong>{appName}</strong>.
</li>
<li>
Choose <strong>Basic Information</strong>, then scroll to{" "}
<strong>Display Information</strong>.
</li>
<li>
Under <strong>App icon &amp; Preview</strong>, click the app icon
and upload{" "}
<span className="break-all font-mono text-xs">{filename}</span>.
</li>
<li>
Confirm the crop, then click <strong>Save Changes</strong> in
Slack.
</li>
</ol>
</section>
</details>
</div>
);
}
export function SlackAvatarStep({
uploaded,
onUploaded,
onSkip,
onSaveExit,
...props
}: SlackAvatarProps & {
uploaded: boolean;
onUploaded: () => void;
onSkip: () => void;
onSaveExit: () => void;
}) {
return (
<div className="space-y-8">
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">
Give {props.agentName} a face in Slack
</h1>
<span className="text-xs text-muted-foreground">Optional</span>
</div>
<p className="text-sm text-muted-foreground">
Use {props.agentName}’s avatar so your team recognizes the agent
</p>
</div>
<SlackAvatarContent {...props} />
{uploaded && (
<p
role="status"
className="flex items-center gap-2 rounded-lg bg-(--status-task-done)/10 p-3 text-sm"
>
<Check className="size-4 text-(--status-task-done)" />
You marked the avatar as uploaded in Slack.
</p>
)}
<SetupWizardFooter onSaveExit={onSaveExit}>
<Button variant="ghost" onClick={onSkip}>
Skip for now
</Button>
<Button onClick={onUploaded}>
{uploaded ? "Continue" : "I’ve uploaded the avatar"}
<ArrowRight className="size-4" />
</Button>
</SetupWizardFooter>
</div>
);
}
export function SlackAvatarSettings(props: SlackAvatarProps) {
return (
<section className="space-y-4" aria-label="Slack avatar">
<div className="space-y-1">
<h2 className="text-lg font-semibold">Agent avatar</h2>
<p className="text-sm text-muted-foreground">
Use {props.agentName}’s avatar so your team recognizes the agent
</p>
</div>
<SlackAvatarContent {...props} compact />
</section>
);
}
+13
View File
@@ -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`;
}
@@ -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 };
}
@@ -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.
@@ -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 (
<div className="min-h-screen bg-background text-foreground">
<header className="flex gap-3 border-b border-border px-6 py-4 text-sm">
<span className="text-muted-foreground">Connectors /</span>CEO · Slack
</header>
<div className="flex flex-col md:flex-row">
<aside className="flex shrink-0 gap-2 border-b border-border p-4 md:w-60 md:flex-col md:border-r md:border-b-0">
{["Settings", "Access", "Conversations", "Activity"].map((tab) => (
<span
key={tab}
className={`rounded-md px-3 py-2 text-sm ${tab === "Settings" ? "bg-accent font-medium" : "text-muted-foreground"}`}
>
{tab}
</span>
))}
</aside>
<main className="min-w-0 flex-1 p-6 md:p-8">
<div className="max-w-2xl space-y-8">
<h1 className="text-xl font-bold">CEO in Slack</h1>
<section className="space-y-2 text-sm">
<h2 className="text-lg font-semibold">Chat in Slack</h2>
<p>
Invite the bot to a channel, then mention it to start a
conversation.
</p>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border p-3">
<code>@ceo you there?</code>
<Button
variant="ghost"
size="icon"
aria-label="Copy message"
onClick={() =>
void navigator.clipboard.writeText("@ceo you there?")
}
>
<Copy className="size-4" />
</Button>
</div>
</section>
<SlackAvatarSettings
agentName="CEO"
appName="ceo-paperclip"
avatarUrl={avatar}
/>
<section className="space-y-4">
<h2 className="text-lg font-semibold">
Where this agent can work
</h2>
<h3 className="text-sm font-semibold">Allowed Channels</h3>
<div className="border-y border-border py-3 text-sm">
#general{" "}
<ExternalLink className="inline size-3 text-muted-foreground" />
</div>
</section>
</div>
</main>
</div>
</div>
);
}
@@ -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 (
<div className="min-h-screen bg-background text-foreground">
<header className="flex items-center gap-3 border-b border-border px-6 py-4 text-sm">
<span className="text-muted-foreground">Connectors</span>
<span aria-hidden="true" className="text-muted-foreground">
/
</span>
<span>Connect Slack</span>
</header>
<div className="flex flex-col md:flex-row">
<details className="border-b border-border p-4 md:hidden">
<summary className="cursor-pointer text-sm font-medium">
Step {step + 1} of {labels.length} · {labels[step]}
</summary>
<div className="pt-4">
<SetupWizardNavigation
inline
labels={labels}
step={step}
availableStep={Math.max(4, step)}
onSelect={(next) => {
setStep(next);
setExited(false);
}}
/>
</div>
</details>
<aside className="hidden shrink-0 border-r border-border p-6 md:block md:w-64">
<SetupWizardNavigation
inline
labels={labels}
step={step}
availableStep={Math.max(4, step)}
onSelect={(next) => {
setStep(next);
setExited(false);
}}
/>
</aside>
<main className="min-w-0 flex-1 p-6 md:p-8">
<div className="max-w-2xl space-y-8">
{step === 4 && !exited ? (
<AvatarStep
agentName={agentName}
appName={appName}
avatarUrl={avatar}
uploaded={uploaded}
onUploaded={() => {
setUploaded(true);
setStep(5);
}}
onSkip={() => setStep(5)}
onSaveExit={() => setExited(true)}
/>
) : (
<>
<h1 className="text-xl font-bold">
{exited ? "Setup paused" : labels[step]}
</h1>
<p role="status" className="text-sm text-muted-foreground">
{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."}
</p>
<Button
variant="outline"
onClick={() => {
setStep(4);
setExited(false);
}}
>
Back to avatar step
</Button>
</>
)}
</div>
</main>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview";
const meta = {
title: "Connections/Slack/Avatar in Settings",
component: SlackAvatarSettingsPreview,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"The production avatar section in a Settings layout fixture. Download remains available after onboarding; upload instructions expand in place.",
},
},
},
} satisfies Meta<typeof SlackAvatarSettingsPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Settings: Story = {};
@@ -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) => <SlackAvatarStep key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof SlackAvatarStep>;
export default meta;
type Story = StoryObj<typeof meta>;
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");
},
};