mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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:
1 parent
fd071748ee
commit
57fd8b70d2
16 files changed
+767
-30
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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");
|
||||
});
|
||||
});
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user