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: (