Files
PaperClipAI/ui/src/components/BoardDialogs.test.tsx
T
a5e01634f6 feat(ui): ask self-hosted users to star Paperclip on GitHub after 5 done tasks (#20453)
## 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 <squadbot@squadbots-Mac-mini.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 21:11:29 -08:00

56 lines
2.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { BoardDialogs } from "./BoardDialogs";
const state = vi.hoisted(() => ({ newIssueOpen: false, newProjectOpen: false, newGoalOpen: false, newAgentOpen: false }));
vi.mock("../context/DialogContext", () => ({ useDialogState: () => state, useDialogActions: () => ({ closeNewIssue: vi.fn(), closeNewProject: vi.fn(), closeNewGoal: vi.fn(), closeNewAgent: vi.fn() }) }));
vi.mock("./NewIssueDialog", () => ({ NewIssueDialog: () => <input aria-label="Task draft" /> }));
vi.mock("./NewProjectDialog", () => ({ NewProjectDialog: () => <div>Project dialog</div> }));
vi.mock("./NewGoalDialog", () => ({ NewGoalDialog: () => <div>Goal dialog</div> }));
vi.mock("./NewAgentDialog", () => ({ NewAgentDialog: () => <div>Agent dialog</div> }));
vi.mock("./GitHubStarPrompt", () => ({ GitHubStarPrompt: () => null }));
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
Object.assign(state, { newIssueOpen: false, newProjectOpen: false, newGoalOpen: false, newAgentOpen: false });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
async function render() { await act(async () => { root.render(<BoardDialogs />); }); }
it("renders the task form on the first open without an asynchronous loading frame", () => {
act(() => flushSync(() => root.render(<BoardDialogs />)));
state.newIssueOpen = true;
act(() => {
flushSync(() => root.render(<BoardDialogs />));
expect(container.querySelector('[aria-label="Task draft"]')).not.toBeNull();
expect(document.body.textContent).not.toContain("Loading new task");
});
});
it("leaves unopened dialog trees unmounted", async () => {
await render();
expect(container.innerHTML).toBe("");
});
it("loads a requested dialog and retains its draft across close and reopen", async () => {
await render();
state.newIssueOpen = true;
await render();
const draft = container.querySelector("input")!;
expect(draft).not.toBeNull();
draft.value = "Keep my draft";
expect(container.textContent).not.toContain("Project dialog");
state.newIssueOpen = false;
await render();
state.newIssueOpen = true;
await render();
expect(container.querySelector("input")).toBe(draft);
expect(container.querySelector("input")?.value).toBe("Keep my draft");
});