From a5e01634f69f18dfe10a0bf709df02d9e6a2135b Mon Sep 17 00:00:00 2001 From: scotttong Date: Mon, 9 Nov 2026 21:11:29 -0800 Subject: [PATCH] feat(ui): ask self-hosted users to star Paperclip on GitHub after 5 done tasks (#20453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Many teams run Paperclip self-hosted, so the project grows when those users tell others about it > - A GitHub star is the cheapest way for a user to do that, but the app never asks > - Asking too early is noise. A user who has finished a few tasks knows whether Paperclip helps them > - This pull request adds a one-time, dismissable modal that asks self-hosted users for a star after their company has 5 done tasks > - The benefit is more visibility for the project, and no repeated prompt for the user ## Linked Issues or Issue Description **Subsystem affected** Board UI (dialogs shown in the main app shell). **Problem or motivation** Self-hosted users have no prompt to star the repository. Users who like Paperclip often do not think of it. A prompt at the right moment helps other teams find the project. **Proposed solution** Show a small modal once the selected company has 5 tasks with status "done". The modal shows the Paperclip mark, the headline "You completed 5 tasks!", a short body, a "Star on GitHub" button and a "Not now" button. It shows only once per browser. Closing it in any way counts as seen. **Alternatives considered** A permanent banner or sidebar link. Both stay on screen after the user decides, so they cost attention every day. A one-time modal costs attention once. **Roadmap alignment** This is a small UI addition. It does not overlap with planned core work in ROADMAP.md. No related public issues or PRs found (searched for "star", "github star"). ## What Changed - Added `GitHubStarPrompt` (`ui/src/components/GitHubStarPrompt.tsx`). It reads the health endpoint and shows only on self-hosted instances (never on Cloud), never during onboarding, and only after 5 done tasks in the selected company. It waits for any other open dialog to close. It stores a "seen" flag in `localStorage` when it opens, so a reload does not show it again. - Mounted the prompt in `BoardDialogs`. - Exported the modal-detection helpers (`MODAL_SELECTOR`, `useModalOpen`) from `AnnouncementWell` so both components use the same rule. - Added `PaperclipMark`, the mark alone from the existing lockup geometry. The lockup and the mark now share one path constant. - Added the `issues.starPromptDone` query key. - Added tests for when the prompt opens and when it stays hidden. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/GitHubStarPrompt.test.tsx src/components/AnnouncementWell.test.tsx` - `pnpm check:token-gates` (all gates clean) - `pnpm typecheck` - Manual: on a self-hosted dev instance, mark 5 tasks in one company as done. The modal opens. "Star on GitHub" opens github.com/paperclipai/paperclip in a new tab. Reload: the modal does not open again. To see it again, delete the `paperclip.github-star-prompt.seen` key from `localStorage`. ## Risks - Low risk. The change is UI only and additive. - The prompt runs one small issues query (limit 5) while it is still eligible. After it is seen, the query is disabled. - If `localStorage` is not available, the prompt treats itself as already seen and does not show. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (Anthropic), model id `claude-opus-5-5`, run through Claude Code with tool use (shell, file edits). Standard context window; extended thinking at low effort. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: squadbot Co-authored-by: Claude Opus 5.5 --- ui/src/components/AnnouncementWell.tsx | 12 +-- ui/src/components/BoardDialogs.test.tsx | 1 + ui/src/components/BoardDialogs.tsx | 2 + ui/src/components/GitHubStarPrompt.test.tsx | 109 ++++++++++++++++++++ ui/src/components/GitHubStarPrompt.tsx | 94 +++++++++++++++++ ui/src/components/Layout.test.tsx | 4 + ui/src/components/PaperclipLockup.tsx | 28 ++++- ui/src/lib/queryKeys.ts | 2 + 8 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/GitHubStarPrompt.test.tsx create mode 100644 ui/src/components/GitHubStarPrompt.tsx diff --git a/ui/src/components/AnnouncementWell.tsx b/ui/src/components/AnnouncementWell.tsx index ef5aae502e7..c6212de9fb7 100644 --- a/ui/src/components/AnnouncementWell.tsx +++ b/ui/src/components/AnnouncementWell.tsx @@ -13,8 +13,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { AnnouncementCard } from "./AnnouncementCard"; // Includes the command palette, sheets and dialogs created outside DialogContext. -const MODAL_SELECTOR = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [aria-modal="true"]:not([data-state="closed"]), dialog[open]'; -function useModalOpen(enabled: boolean) { +export const MODAL_SELECTOR = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [aria-modal="true"]:not([data-state="closed"]), dialog[open]'; +export function useModalOpen(enabled: boolean) { const [open, setOpen] = useState(false); useEffect(() => { if (!enabled) return; @@ -30,11 +30,11 @@ function useModalOpen(enabled: boolean) { const FINISHED_ONBOARDING_STATUSES = new Set(["done", "cancelled"]); /** - * A new user's first session is the onboarding first task. Announcements wait - * until that task is finished so they cannot cover its questions. Unknown - * state (loading or a failed read) keeps the optional card hidden. + * A new user's first session is the onboarding first task. Announcements and + * other optional prompts wait until that task is finished so they cannot cover + * its questions. Unknown state (loading or a failed read) keeps them hidden. */ -function useOnboardingInProgress(companyId: string | null) { +export function useOnboardingInProgress(companyId: string | null) { const query = useQuery({ queryKey: queryKeys.issues.onboardingFirstTask(companyId ?? "__none__"), queryFn: ({ signal }) => issuesApi.list(companyId!, { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND, limit: 1 }, { signal }), diff --git a/ui/src/components/BoardDialogs.test.tsx b/ui/src/components/BoardDialogs.test.tsx index 564aedd42aa..0eed1ec349c 100644 --- a/ui/src/components/BoardDialogs.test.tsx +++ b/ui/src/components/BoardDialogs.test.tsx @@ -11,6 +11,7 @@ vi.mock("./NewIssueDialog", () => ({ NewIssueDialog: () => ({ NewProjectDialog: () =>
Project dialog
})); vi.mock("./NewGoalDialog", () => ({ NewGoalDialog: () =>
Goal dialog
})); vi.mock("./NewAgentDialog", () => ({ NewAgentDialog: () =>
Agent dialog
})); +vi.mock("./GitHubStarPrompt", () => ({ GitHubStarPrompt: () => null })); let container: HTMLDivElement; let root: ReturnType; beforeEach(() => { diff --git a/ui/src/components/BoardDialogs.tsx b/ui/src/components/BoardDialogs.tsx index eda574e646b..a28018b3d22 100644 --- a/ui/src/components/BoardDialogs.tsx +++ b/ui/src/components/BoardDialogs.tsx @@ -1,6 +1,7 @@ import { lazy } from "react"; import { useDialogActions, useDialogState } from "../context/DialogContext"; import { DeferredOverlay } from "./DeferredOverlay"; +import { GitHubStarPrompt } from "./GitHubStarPrompt"; import { NewIssueDialog } from "./NewIssueDialog"; const NewProjectDialog = lazy(() => import("./NewProjectDialog").then((module) => ({ default: module.NewProjectDialog }))); @@ -15,5 +16,6 @@ export function BoardDialogs() { + ; } diff --git a/ui/src/components/GitHubStarPrompt.test.tsx b/ui/src/components/GitHubStarPrompt.test.tsx new file mode 100644 index 00000000000..8da3c90fa54 --- /dev/null +++ b/ui/src/components/GitHubStarPrompt.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; +import type { HealthStatus } from "@/api/health"; +import { GitHubStarPrompt, STAR_PROMPT_SEEN_KEY } from "./GitHubStarPrompt"; + +const state = vi.hoisted(() => ({ companyId: "company", loading: false, onboardingOpen: false, onboardingStatus: null as string | null, done: 5, listCompact: vi.fn() })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId, loading: state.loading }) })); +vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboardingOpen }) })); +vi.mock("@/api/issues", () => ({ issuesApi: { + list: () => Promise.resolve(state.onboardingStatus ? [{ id: "onboarding", originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND, status: state.onboardingStatus }] : []), + listCompact: (...args: unknown[]) => { + state.listCompact(...args); + return Promise.resolve(Array.from({ length: state.done }, (_, i) => ({ id: `issue-${i}` }))); + }, +} })); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("GitHub star prompt", () => { + let container: HTMLDivElement; + let root: Root; + const render = async (health: Partial | null = { deploymentMode: "local_trusted" }) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (health) client.setQueryData(["health"], health); + await act(async () => root.render()); + // Let the onboarding and done-count queries settle, however slow the run. + for (let idle = 0, tick = 0; idle < 2 && tick < 100; tick++) { + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); + idle = client.isFetching() === 0 ? idle + 1 : 0; + } + }; + const visible = () => Boolean(document.querySelector('[data-testid="github-star-prompt"]')); + beforeEach(() => { + Object.assign(state, { companyId: "company", loading: false, onboardingOpen: false, onboardingStatus: null, done: 5 }); + state.listCompact.mockClear(); + window.localStorage.clear(); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + }); + afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + + it("opens once a self-hosted company has five done tasks, and only once", async () => { + await render(); + expect(visible()).toBe(true); + expect(state.listCompact).toHaveBeenCalledWith("company", { status: "done", limit: 5 }, expect.anything()); + expect(window.localStorage.getItem(STAR_PROMPT_SEEN_KEY)).not.toBeNull(); + const star = document.querySelector('a[href="https://github.com/paperclipai/paperclip"]'); + expect(star?.target).toBe("_blank"); + + await act(async () => root.unmount()); + root = createRoot(container); + await render(); + expect(visible()).toBe(false); + }); + + it("waits for another open dialog to close", async () => { + const other = document.createElement("div"); + other.setAttribute("role", "dialog"); other.setAttribute("data-state", "open"); + document.body.append(other); + await render(); + expect(visible()).toBe(false); + await act(async () => { other.remove(); }); + expect(visible()).toBe(true); + }); + + it("does not open after another tab has shown it", async () => { + const other = document.createElement("div"); + other.setAttribute("role", "dialog"); other.setAttribute("data-state", "open"); + document.body.append(other); + await render(); + window.localStorage.setItem(STAR_PROMPT_SEEN_KEY, new Date().toISOString()); + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: STAR_PROMPT_SEEN_KEY })); }); + await act(async () => { other.remove(); }); + expect(visible()).toBe(false); + }); + + it("waits until the onboarding task is finished", async () => { + state.onboardingStatus = "in_progress"; + await render(); + expect(visible()).toBe(false); + expect(state.listCompact).not.toHaveBeenCalled(); + await act(async () => root.unmount()); + root = createRoot(container); + state.onboardingStatus = "done"; + await render(); + expect(visible()).toBe(true); + }); + + it("stays hidden below five done tasks", async () => { + state.done = 4; + await render(); + expect(visible()).toBe(false); + expect(window.localStorage.getItem(STAR_PROMPT_SEEN_KEY)).toBeNull(); + }); + + it("stays hidden on cloud, during onboarding, and before health is known", async () => { + await render({ deploymentMode: "authenticated", cloud: {} as HealthStatus["cloud"] }); + expect(visible()).toBe(false); + state.onboardingOpen = true; + await render(); + expect(visible()).toBe(false); + state.onboardingOpen = false; + await render(null); + expect(visible()).toBe(false); + expect(state.listCompact).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/GitHubStarPrompt.tsx b/ui/src/components/GitHubStarPrompt.tsx new file mode 100644 index 00000000000..598804a29d8 --- /dev/null +++ b/ui/src/components/GitHubStarPrompt.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Star } from "lucide-react"; +import { healthApi } from "@/api/health"; +import { issuesApi } from "@/api/issues"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogState } from "@/context/DialogContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { MODAL_SELECTOR, useModalOpen, useOnboardingInProgress } from "./AnnouncementWell"; +import { PaperclipMark } from "./PaperclipLockup"; + +export const STAR_PROMPT_COMPLETED_TASKS = 5; +export const STAR_PROMPT_SEEN_KEY = "paperclip.github-star-prompt.seen"; +export const PAPERCLIP_GITHUB_URL = "https://github.com/paperclipai/paperclip"; + +function readSeen() { + try { return window.localStorage.getItem(STAR_PROMPT_SEEN_KEY) !== null; } catch { return true; } +} + +function markSeen() { + try { window.localStorage.setItem(STAR_PROMPT_SEEN_KEY, new Date().toISOString()); } catch { /* shown once per visit instead */ } +} + +/** + * Asks self-hosted users to star the repository once they have finished + * enough tasks to know whether Paperclip helps them. It shows at most once + * per browser: showing it counts as seen, so a reload or another open tab + * never repeats it. It waits for any other dialog to close. Cloud instances, + * unfinished onboarding and unknown state (loading or a failed read) keep it + * hidden. + */ +export function GitHubStarPrompt() { + const { selectedCompanyId, loading } = useCompany(); + const { onboardingOpen } = useDialogState(); + const [seen, setSeen] = useState(readSeen); + const [open, setOpen] = useState(false); + const onboardingInProgress = useOnboardingInProgress(selectedCompanyId); + const health = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), enabled: false }); + const selfHosted = health.isSuccess && !health.data.cloud; + const eligible = selfHosted && !seen && !loading && Boolean(selectedCompanyId) && !onboardingOpen && !onboardingInProgress; + const done = useQuery({ + queryKey: queryKeys.issues.starPromptDone(selectedCompanyId ?? "__none__"), + queryFn: ({ signal }) => issuesApi.listCompact(selectedCompanyId!, { status: "done", limit: STAR_PROMPT_COMPLETED_TASKS }, { signal }), + enabled: eligible, + staleTime: 60_000, + }); + const reached = eligible && done.isSuccess && done.data.length >= STAR_PROMPT_COMPLETED_TASKS; + const otherModalOpen = useModalOpen(reached); + + useEffect(() => { + if (seen) return; + const onStorage = (event: StorageEvent) => { + if (event.key === STAR_PROMPT_SEEN_KEY || event.key === null) setSeen(readSeen()); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [seen]); + + useEffect(() => { + if (!reached || otherModalOpen || document.querySelector(MODAL_SELECTOR)) return; + // Another tab may have shown it after this one mounted. + if (readSeen()) { setSeen(true); return; } + markSeen(); + setSeen(true); + setOpen(true); + }, [reached, otherModalOpen]); + + return ( + + + +
+ +
+ You completed {STAR_PROMPT_COMPLETED_TASKS} tasks! + + If you're enjoying Paperclip, consider giving us a star on GitHub! + +
+
+ + +
+
+
+ ); +} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 1a7bac884e9..322f001d4d4 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -164,6 +164,10 @@ vi.mock("./AnnouncementWell", () => ({ AnnouncementWell: () =>
, })); +vi.mock("./GitHubStarPrompt", () => ({ + GitHubStarPrompt: () => null, +})); + vi.mock("./MobileBottomNav", () => ({ MobileBottomNav: () => null, })); diff --git a/ui/src/components/PaperclipLockup.tsx b/ui/src/components/PaperclipLockup.tsx index 80b5d0689ab..6d5a481f7de 100644 --- a/ui/src/components/PaperclipLockup.tsx +++ b/ui/src/components/PaperclipLockup.tsx @@ -5,6 +5,9 @@ interface PaperclipLockupProps extends Omit, "children"> title?: string; } +const PAPERCLIP_MARK_PATH = + "M46.2611 33.6556L34.7307 44.6408C32.7445 46.5331 29.5205 46.5323 27.5303 44.6361C25.5404 42.74 25.54 39.669 27.5261 37.7768L39.0565 26.7916C40.2474 25.657 42.1811 25.6567 43.3752 26.7943C44.5693 27.9319 44.5689 29.7742 43.378 30.9088L31.849 41.8954C31.4528 42.2728 30.8077 42.273 30.4092 41.8933C30.011 41.5136 30.0117 40.8996 30.4078 40.5223L41.9382 29.5371C42.336 29.1581 42.336 28.5437 41.9382 28.1647C41.5404 27.786 40.8954 27.7858 40.4977 28.1647L28.9673 39.1499C27.7742 40.2866 27.7764 42.1295 28.9687 43.2657C30.1613 44.402 32.0963 44.4046 33.2895 43.2678L44.8192 32.2805C46.8068 30.3865 46.8038 27.3159 44.8157 25.4219C42.8275 23.5277 39.6039 23.5253 37.616 25.4192L26.0856 36.4044C23.3024 39.056 23.3058 43.3558 26.0898 46.0085C28.8741 48.6612 33.388 48.6649 36.1712 46.0132L47.7017 35.028C48.0994 34.649 48.0994 34.0346 47.7017 33.6556C47.3039 33.2766 46.6589 33.2766 46.2611 33.6556Z"; + /** * The full Paperclip lockup — mark plus wordmark — from the brand asset * (`paperclip_lockup_dark_mode alt.svg`). The export is white-filled for dark @@ -41,7 +44,30 @@ export function PaperclipLockup({ - + + + ); +} + +/** The mark alone, cropped square from the same geometry as the lockup. */ +export function PaperclipMark({ + decorative = false, + title = "Paperclip", + className, + ...rest +}: PaperclipLockupProps) { + return ( + + ); } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 757f2ae0e8f..5efb20c886f 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -314,6 +314,8 @@ export const queryKeys = { list: (companyId: string) => ["issues", companyId] as const, onboardingFirstTask: (companyId: string) => ["issues", companyId, "onboarding-first-task"] as const, + starPromptDone: (companyId: string) => + ["issues", companyId, "star-prompt-done"] as const, mentionPool: (companyId: string) => ["issues", companyId, "mention-pool"] as const, search: (