mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## 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>
82 lines
4.4 KiB
TypeScript
82 lines
4.4 KiB
TypeScript
// @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");
|
||
});
|
||
});
|