From ed5e3b6ab59e2ec555c96a0bcb83f3f3bde9f8fb Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 19:53:27 -0500 Subject: [PATCH] Show custom GitHub App mentions and branding controls --- doc/connections/GITHUB-REVIEW-BOT.md | 17 +++ .../2026-10-07-github-app-live-test-drive.md | 31 +++++ ui/src/components/AgentAvatarDownload.tsx | 71 ++++++++++++ ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 5 +- ui/src/pages/apps/chat/GitHubAppIdentity.tsx | 106 ++++++++++++++++++ .../apps/chat/GitHubBotManagement.test.tsx | 51 +++++++++ .../pages/apps/chat/GitHubBotManagement.tsx | 4 + .../pages/apps/chat/GitHubChatSetup.test.tsx | 1 + ui/src/pages/apps/chat/GitHubChatSetup.tsx | 11 +- ui/src/pages/apps/chat/SlackAvatarStep.tsx | 68 ++--------- .../stories/github-bot-management/fixtures.ts | 10 +- .../management.stories.tsx | 25 +++++ 12 files changed, 337 insertions(+), 63 deletions(-) create mode 100644 ui/src/components/AgentAvatarDownload.tsx create mode 100644 ui/src/pages/apps/chat/GitHubAppIdentity.tsx diff --git a/doc/connections/GITHUB-REVIEW-BOT.md b/doc/connections/GITHUB-REVIEW-BOT.md index bc12883319..3c4cfbd80b 100644 --- a/doc/connections/GITHUB-REVIEW-BOT.md +++ b/doc/connections/GITHUB-REVIEW-BOT.md @@ -40,6 +40,23 @@ behavior, and narrower saved repository restrictions remain intact. Later repository additions require enablement in Access. Advanced review rules and prompts live in Settings. +The App belongs to the selected account or organization and uses its own bot +identity. The editable App name determines GitHub's slug and `@mention`; there +is no separate editable bot username. Paperclip shows and copies the verified +mention in the connected bot's header. Type `@app-slug`, without the `[bot]` +suffix shown on GitHub's API author records. + +**GitHub App name and logo** is optional on the connected page and in Settings. +Download the agent's avatar as a PNG, then follow the link to this App's GitHub +settings to upload it under **Display information**. GitHub's +[manifest parameters](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) +do not include an avatar; the +[logo upload](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/creating-a-custom-badge-for-your-github-app) +remains a GitHub settings action. After renaming the App on GitHub, use the +existing **reconnect this App** flow to refresh its verified identity using its +stored credentials. Legacy manual connections without recorded ownership link +to the App settings list instead of assuming an owner. + Local instances receive public callbacks and signed events through an enrolled Paperclip Cloud connector, using outbound requests instead of a public tunnel. The Cloud gateway capability must be deployed before localhost onboarding. diff --git a/doc/plans/2026-10-07-github-app-live-test-drive.md b/doc/plans/2026-10-07-github-app-live-test-drive.md index 9c47090cb6..a45d303bd9 100644 --- a/doc/plans/2026-10-07-github-app-live-test-drive.md +++ b/doc/plans/2026-10-07-github-app-live-test-drive.md @@ -325,3 +325,34 @@ copy edit and passes. Repository typecheck/build, production Storybook build, and token gates pass; the final discovery change also received focused server typecheck/build. These checks do not qualify a new provider or model run. The earlier full-suite failure and outstanding current-head CI/review still apply. + +### Custom App identity and branding qualification + +The live bot is the dedicated **Animal Bot E2E 20261007** App owned by +`paperclipai`, not the shared Paperclip App. Its verified GitHub author is +`animal-bot-e2e-20261007[bot]`; the human mention is +`@animal-bot-e2e-20261007`. The published review's GitHub author and App fields +agree with the saved endpoint identity. + +The setup name field now explains GitHub's derived mention. Connected bots show +the verified, copyable mention in the header. Settings and wizard completion +share an optional App name/logo disclosure, the agent's PNG download, and the +owning App's GitHub settings link. Personal accounts use their corresponding App +settings path. Unknown legacy ownership uses the settings list, never an assumed +organization or the installation settings page. A renamed App can refresh its +identity through existing-App reconnect with stored credentials. + +The preserved test drive was restarted after an interruption. The live header +showed the correct mention; copying and pasting it into an unsaved field proved +the exact text, then the original field was restored without saving. Downloaded +avatar is a 512×512 PNG, 30,616 bytes. Desktop and 390px branding layouts were +inspected. GitHub's App settings link reached its signed-in re-authentication +gate; no name or logo was changed, and App rename/reconnect was not tested live. +GitHub's manifest has no logo field, so provider upload remains an optional +GitHub settings action. No configuration, credentials or permissions changed in +this walkthrough. + +All 59 focused management, setup, avatar-download and clipboard tests pass, +including Slack's existing download/error/retry coverage for the shared +downloader. UI typecheck/build, production Storybook build and token gates pass. +The earlier full-suite failure and current-head CI/review limits still apply. diff --git a/ui/src/components/AgentAvatarDownload.tsx b/ui/src/components/AgentAvatarDownload.tsx new file mode 100644 index 0000000000..acc0b93b0a --- /dev/null +++ b/ui/src/components/AgentAvatarDownload.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { Download, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function agentAvatarFilename(name: string) { + return `${name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "agent"}-avatar.png`; +} + +/** Downloads the instance's generated PNG; provider upload remains explicit. */ +export function AgentAvatarDownload({ + avatarUrl, + name, +}: { + avatarUrl: string; + name: string; +}) { + const [downloading, setDownloading] = useState(false); + const [failed, setFailed] = useState(false); + const download = async () => { + if (downloading) return; + setDownloading(true); + setFailed(false); + try { + const response = await fetch(avatarUrl); + if ( + !response.ok || + !response.headers.get("content-type")?.startsWith("image/png") + ) + throw new Error("Avatar unavailable"); + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = url; + link.download = agentAvatarFilename(name); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1_000); + } catch { + setFailed(true); + } finally { + setDownloading(false); + } + }; + return ( +
+ + {failed && ( +

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

+ )} +
+ ); +} diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index 651d1e77da..8f2f318bbb 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -8,6 +8,7 @@ import { resolveAgentAppearance } from "@paperclipai/shared"; import { AgentAvatar } from "@/components/AgentAvatar"; import { ChatConversationList } from "./ChatConversationList"; import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement"; +import { GitHubBotMention } from "./GitHubAppIdentity"; import { EmailEndpointSettings } from "./EmailEndpointSetup"; import { EmailConnectionAccess } from "@/components/EmailConnectionAccess"; import { emailApi } from "@/api/email"; @@ -309,6 +310,7 @@ export function ChatEndpointDetail() {

{endpoint.provider === "github" ? <>{endpoint.botLabel ?? endpoint.botUsername ?? "GitHub"}{endpoint.providerAccountLabel && · {endpoint.providerAccountLabel}} : endpoint.providerAccountLabel ?? (endpoint.provider === "agentmail" ? endpoint.botExternalId ?? "Email connection" : "Chat connection")}

+ {endpoint.provider === "github" &&
} {endpoint.provider === "imessage-photon" && endpoint.botExternalId && endpoint.photonAllocation !== "shared" && (
{endpoint.botExternalId} @@ -338,7 +340,8 @@ export function ChatEndpointDetail() {
{endpoint.provider === "github" && } {activeTab === "settings" && endpoint.provider !== "github" && } {activeTab === "reviews" && endpoint.provider === "github" && } diff --git a/ui/src/pages/apps/chat/GitHubAppIdentity.tsx b/ui/src/pages/apps/chat/GitHubAppIdentity.tsx new file mode 100644 index 0000000000..a2b0d1f203 --- /dev/null +++ b/ui/src/pages/apps/chat/GitHubAppIdentity.tsx @@ -0,0 +1,106 @@ +import { ExternalLink } from "lucide-react"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { AgentAvatarDownload } from "@/components/AgentAvatarDownload"; +import { CopyText } from "@/components/CopyText"; +import { Link } from "@/lib/router"; + +export function gitHubBotMention(endpoint: Pick) { + const slug = endpoint.botUsername?.replace(/^@/, "").replace(/\[bot\]$/, ""); + return slug ? `@${slug}` : null; +} + +export function gitHubAppSettingsUrl(endpoint: ChatEndpoint) { + const github = endpoint.setup?.github; + const slug = gitHubBotMention(endpoint)?.slice(1); + if (!slug) return "https://github.com/settings/apps"; + if (github?.ownerType === "organization" && github.ownerLogin) + return `https://github.com/organizations/${encodeURIComponent(github.ownerLogin)}/settings/apps/${encodeURIComponent(slug)}`; + if (github?.ownerType === "personal") + return `https://github.com/settings/apps/${encodeURIComponent(slug)}`; + // Legacy manual connections may not record ownership type. Never assume it. + return "https://github.com/settings/apps"; +} + +export function GitHubBotMention({ endpoint }: { endpoint: ChatEndpoint }) { + const mention = gitHubBotMention(endpoint); + return mention ? ( + + {mention} + + ) : null; +} + +/** Optional provider-owned branding, shared by completion and management. */ +export function GitHubAppBranding({ + endpoint, + avatarUrl, +}: { + endpoint: ChatEndpoint; + avatarUrl?: string; +}) { + return ( +
+ + GitHub App name and logo + +
+

+ This is your custom GitHub App + {endpoint.botLabel ? ( + <> + : {endpoint.botLabel}. + + ) : ( + "." + )} + {endpoint.providerAccountLabel && ( + <> + {" "} + Owned by {endpoint.providerAccountLabel}. + + )} +

+

+ GitHub derives the @mention from the App name. Upload your logo in the + App’s Display information settings. +

+ {avatarUrl && ( +
+ {`${endpoint.assignedAgentName}’s + +
+ )} + + Edit App name and logo on GitHub + +

+ After renaming the App,{" "} + + reconnect this App + {" "} + to refresh its mention in Paperclip. +

+
+
+ ); +} diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx index c154bd4ced..8027b6447a 100644 --- a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx +++ b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx @@ -14,6 +14,8 @@ import { GitHubPolicyEditor } from "./GitHubBotConfiguration"; import { conversationDestination } from "./ChatConversationList"; import { queryKeys } from "@/lib/queryKeys"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { gitHubAppSettingsUrl, gitHubBotMention } from "./GitHubAppIdentity"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; const mocks = vi.hoisted(() => ({ tab: "settings", @@ -217,6 +219,55 @@ describe("GitHub bot management", () => { ); }); } + it("shows the verified custom App mention and its own organization branding settings", async () => { + const branded: ChatEndpoint = { + ...endpoint, + provider: "github", + status: "active", + botLabel: "Maya Reviews", + botUsername: "maya-reviews[bot]", + providerAccountLabel: "acme", + setup: { + step: "complete", + github: { + stage: "verify", + ownerType: "organization", + ownerLogin: "acme", + appSlug: "old-draft-name", + }, + }, + }; + mocks.get.mockResolvedValue(branded); + await render(); + expect( + container.querySelector('button[aria-label="Copy GitHub mention"]') + ?.textContent, + ).toBe("@maya-reviews"); + expect(container.textContent).toContain("This is your custom GitHub App"); + const branding = [...container.querySelectorAll("a")].find((a) => + a.textContent?.includes("Edit App name and logo"), + ); + expect(branding?.href).toBe( + "https://github.com/organizations/acme/settings/apps/maya-reviews", + ); + expect( + container.querySelector("a[download]")?.getAttribute("download"), + ).toBe("Maya-Reviews-avatar.png"); + expect(mocks.save).not.toHaveBeenCalled(); + expect( + gitHubAppSettingsUrl({ + ...branded, + setup: { + step: "complete", + github: { stage: "verify", ownerType: "personal" }, + }, + }), + ).toBe("https://github.com/settings/apps/maya-reviews"); + expect( + gitHubAppSettingsUrl({ ...branded, setup: { step: "complete" } }), + ).toBe("https://github.com/settings/apps"); + expect(gitHubBotMention({ botUsername: null })).toBeNull(); + }); it("keeps one draft across Settings, Access, and read-only tabs, then saves with the original revision", async () => { await render(); await input(container.querySelector("textarea")!, "Edited instructions"); diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.tsx index 19f0985d5e..36d413a5bb 100644 --- a/ui/src/pages/apps/chat/GitHubBotManagement.tsx +++ b/ui/src/pages/apps/chat/GitHubBotManagement.tsx @@ -36,6 +36,7 @@ import { GitHubToggle, githubSelectClass, } from "./GitHubBotConfiguration"; +import { GitHubAppBranding } from "./GitHubAppIdentity"; export function GitHubRepositoryAccess({ resources, @@ -115,9 +116,11 @@ export function GitHubRepositoryAccess({ export function GitHubBotManagement({ endpoint, view, + avatarUrl, }: { endpoint: ChatEndpoint; view: "settings" | "access"; + avatarUrl?: string; }) { const client = useQueryClient(); const query = useQuery({ @@ -314,6 +317,7 @@ export function GitHubBotManagement({ )} + )} diff --git a/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx b/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx index ac096d77ba..67ba24a539 100644 --- a/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx +++ b/ui/src/pages/apps/chat/GitHubChatSetup.test.tsx @@ -528,6 +528,7 @@ describe("GitHub App wizard", () => { await render("resume=draft-1"); expect(container.querySelector("h1")?.textContent).toBe("GitHub connected"); expect(container.textContent).toContain("Actual GitHub Name"); + expect(container.textContent).toContain("GitHub App name and logo"); expect(container.textContent).toContain("acme/repo"); expect(container.textContent).toContain("Configure a runtime"); expect(container.textContent).not.toContain("Finish"); diff --git a/ui/src/pages/apps/chat/GitHubChatSetup.tsx b/ui/src/pages/apps/chat/GitHubChatSetup.tsx index 36a901177a..a954686946 100644 --- a/ui/src/pages/apps/chat/GitHubChatSetup.tsx +++ b/ui/src/pages/apps/chat/GitHubChatSetup.tsx @@ -22,6 +22,9 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useNavigate, useSearchParams, Link } from "@/lib/router"; import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui"; import { copyTextToClipboard } from "@/lib/clipboard"; +import { resolveAgentAppearance } from "@paperclipai/shared"; +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { GitHubAppBranding, gitHubBotMention } from "./GitHubAppIdentity"; /** Restrict native manifest submission to GitHub registration endpoints. */ export function gitHubAppManifestAction( @@ -233,7 +236,7 @@ export function GitHubChatSetup() { })} ); - const mention = `@${bot?.botUsername?.replace(/\[bot\]$/, "") ?? "your-bot"} review this pull request`; + const mention = `${(bot && gitHubBotMention(bot)) || "@your-bot"} review this pull request`; return (
{!identityOnly && !connected && ( @@ -356,6 +359,8 @@ export function GitHubChatSetup() { {copied ? "Copied" : "Copy mention"}
+ App name setName(event.target.value)} /> +

+ Creates your own GitHub App. Its name determines the @mention; GitHub confirms the final handle. +

GitHub will ask you to approve creation and select repositories. diff --git a/ui/src/pages/apps/chat/SlackAvatarStep.tsx b/ui/src/pages/apps/chat/SlackAvatarStep.tsx index 5b6f2612a4..1dc7941d0a 100644 --- a/ui/src/pages/apps/chat/SlackAvatarStep.tsx +++ b/ui/src/pages/apps/chat/SlackAvatarStep.tsx @@ -1,13 +1,11 @@ -import { useId, useState } from "react"; -import { - ArrowRight, - Check, - Download, - ExternalLink, - Loader2, -} from "lucide-react"; +import { useId } from "react"; +import { ArrowRight, Check, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SetupWizardFooter } from "@/components/SetupWizard"; +import { + AgentAvatarDownload, + agentAvatarFilename, +} from "@/components/AgentAvatarDownload"; export interface SlackAvatarProps { agentName: string; @@ -23,35 +21,7 @@ export function SlackAvatarContent({ 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); - } - }; + const filename = agentAvatarFilename(appName); return (

- - {downloadError && ( -

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

- )} + diff --git a/ui/storybook/stories/github-bot-management/fixtures.ts b/ui/storybook/stories/github-bot-management/fixtures.ts index 4d7dfd0803..af35e3e079 100644 --- a/ui/storybook/stories/github-bot-management/fixtures.ts +++ b/ui/storybook/stories/github-bot-management/fixtures.ts @@ -25,7 +25,15 @@ export const endpoint: ChatEndpoint = { botUsername: "maya-reviews[bot]", providerAccountLabel: "acme", allowUnlinkedPeople: false, - setup: { step: "complete" }, + setup: { + step: "complete", + github: { + stage: "verify", + appSlug: "maya-reviews", + ownerType: "organization", + ownerLogin: "acme", + }, + }, }; export const configuration: GitHubChatConfiguration = { version: 1, diff --git a/ui/storybook/stories/github-bot-management/management.stories.tsx b/ui/storybook/stories/github-bot-management/management.stories.tsx index f0d583dfd0..d9e5bfae1f 100644 --- a/ui/storybook/stories/github-bot-management/management.stories.tsx +++ b/ui/storybook/stories/github-bot-management/management.stories.tsx @@ -14,8 +14,15 @@ import { GitHubReviewDetail, } from "@/pages/apps/chat/GitHubBotManagement"; import { ChatConversationList } from "@/pages/apps/chat/ChatConversationList"; +import { + GitHubAppBranding, + GitHubBotMention, +} from "@/pages/apps/chat/GitHubAppIdentity"; +import { resolveAgentAppearance } from "@paperclipai/shared"; +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { endpoint, + agent, configuration, resources, reviews, @@ -157,6 +164,24 @@ export const RepositoryAccess: Story = { name: "03 Components / Repository access", render: () => component(), }; +export const AppIdentityAndLogo: Story = { + name: "03 Components / App identity and logo", + render: () => + component( +
+ + +
, + ), +}; export const ReviewHistory: Story = { name: "03 Components / Review rows", render: () =>