From 6fca424852c4d6bd575b155ce9453cbbf30bca0b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 16:56:40 -0700 Subject: [PATCH] feat: ask for a GitHub star once onboarding finishes (#3516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What After an operator **finishes** onboarding, Fusion asks once whether they want to star the repo. If they dismiss it, nothing asks again — on any surface. ## Why Nothing asked at the right moment. The dashboard already had a `GitHubStarPrompt` banner, but it only fired when a task first reached *done*, so someone who completed setup and stopped there was never asked. The CLI (`fn onboard`) had no ask at all. ## How **CLI — `fn onboard`** - The ask runs *after* the completion marker is stamped, so declining (or Ctrl-C on the question) can never cost the operator the setup work they just did. - It prints `https://github.com/Runfusion/Fusion`; it never opens a browser on their behalf. - The non-interactive auto-launch path asks nothing — that flow fires while someone is starting a dev server, and a prompt there is exactly the ambush [b67e3aa](https://github.com/Runfusion/Fusion/commit/b67e3aa8bce4f23c3eab5b9a4f0eb982120c91af) removed. **Dashboard** - `ModelOnboardingModal.onComplete` now reports an outcome, and `useProjectActions` fires the star prompt only for a *finished* onboarding. Dismissing the flow does not ask: closing it is the operator saying to leave them alone. **One ask per operator, not per surface** - New global setting `githubStarPromptDismissedAt`. localStorage stays the fast local record (suppresses the prompt without waiting on a request); the setting is the durable, cross-surface one. Both surfaces read and write it, so answering in either retires the ask in both, and a CLI dismissal is honoured by a dashboard opened later. The settings write is best-effort — losing it costs at most one repeat ask on another browser, never a broken dismissal locally. ## Verification - `pnpm test:gate` — green, 716 tests / 29 files - CLI `onboard` + `onboard-autolaunch` — 37 passed (new cases: asks and stamps on accept; never asks again after dismissal, including `--force`; silent on the non-interactive path) - Dashboard `useGitHubStarPrompt`, `useProjectActions`, `DashboardBanners`, `AppModals` — 72 passed (new cases: dismissal recorded globally; a dismissal from another surface adopted; no re-read once the local record is set; local dismissal survives a failed settings write; finished-vs-dismissed routing) - Typechecks clean for `@fusion/core`, dashboard `tsconfig.app.json`, `@runfusion/fusion` - `pnpm lint` — 0 errors (2 pre-existing warnings) Changeset included (`@runfusion/fusion`: minor). 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Added a one-time GitHub star prompt after onboarding. * Supports accepting, dismissing, or cancelling the prompt, with responses remembered across sessions and interfaces. * Skips the prompt during non-interactive onboarding or after a previous response. * Dashboard onboarding now distinguishes completed and dismissed outcomes. * **Bug Fixes** * Improved prompt synchronization and loading behavior to prevent duplicate displays. * Preserved successful onboarding when settings cannot be saved or retrieved. --------- Co-authored-by: Claude Opus 5 --- .../github-star-ask-after-onboarding.md | 7 + .../src/commands/__tests__/onboard.test.ts | 49 +++++ packages/cli/src/commands/onboard.ts | 67 +++++++ packages/core/src/config/settings-schema.ts | 1 + .../core/src/types/settings/settings-scope.ts | 9 + packages/dashboard/app/App.tsx | 30 ++- .../app/components/ModelOnboardingModal.tsx | 15 +- .../__tests__/useGitHubStarPrompt.test.ts | 173 +++++++++++++++++- .../hooks/__tests__/useProjectActions.test.ts | 35 ++++ .../app/hooks/useGitHubStarPrompt.ts | 114 +++++++++++- .../dashboard/app/hooks/useProjectActions.ts | 17 +- 11 files changed, 498 insertions(+), 19 deletions(-) create mode 100644 .changeset/github-star-ask-after-onboarding.md diff --git a/.changeset/github-star-ask-after-onboarding.md b/.changeset/github-star-ask-after-onboarding.md new file mode 100644 index 0000000000..4a37cb1eaf --- /dev/null +++ b/.changeset/github-star-ask-after-onboarding.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Ask once to star Fusion on GitHub after onboarding finishes, and never again if dismissed. +category: feature +dev: New global setting `githubStarPromptDismissedAt` is stamped on either answer; the ask is skipped on the non-interactive auto-launch path and on `fn onboard --force` once answered. diff --git a/packages/cli/src/commands/__tests__/onboard.test.ts b/packages/cli/src/commands/__tests__/onboard.test.ts index bf5e9d2ec4..fb7d7edf69 100644 --- a/packages/cli/src/commands/__tests__/onboard.test.ts +++ b/packages/cli/src/commands/__tests__/onboard.test.ts @@ -344,6 +344,55 @@ describe("onboard", () => { expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string"); }); + /* + FNXC:GithubStarAsk 2026-08-19-03:59: + The star ask runs after onboarding is complete, is one-shot, and must never be able to cost the + operator their setup: declining, or ending input on the question, still leaves onboarding stamped. + */ + it("asks for a GitHub star once onboarding completes and stamps the answer", async () => { + mockProviderAuthFactory.mockReturnValue(makeProviderAuth()); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runOnboard({ input: inputFrom(["n", "n", "n", "n", "y"]) }); + + const printed = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + // The question itself goes to the readline prompt stream, so assert on the answer's output. + expect(printed).toMatch(/Star it here/i); + expect(printed).toContain("https://github.com/Runfusion/Fusion"); + expect(typeof globalSettingsState.githubStarPromptDismissedAt).toBe("string"); + expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string"); + }); + + it("never asks for a star again once it has been dismissed", async () => { + mockProviderAuthFactory.mockReturnValue(makeProviderAuth()); + const firstLog = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runOnboard({ input: inputFrom(["n", "n", "n", "n", "n"]) }); + expect(firstLog.mock.calls.map((call) => String(call[0])).join("\n")).toMatch(/won't ask again/i); + const dismissedAt = globalSettingsState.githubStarPromptDismissedAt; + expect(typeof dismissedAt).toBe("string"); + firstLog.mockRestore(); + + const secondLog = vi.spyOn(console, "log").mockImplementation(() => {}); + await runOnboard({ force: true, input: inputFrom(["n", "n", "n", "n"]) }); + + expect(secondLog.mock.calls.map((call) => String(call[0])).join("\n")).not.toMatch(/GitHub/i); + expect(globalSettingsState.githubStarPromptDismissedAt).toBe(dismissedAt); + expect(__testUtils.shouldAskGithubStar(globalSettingsState)).toBe(false); + expect(__testUtils.shouldAskGithubStar({})).toBe(true); + expect(__testUtils.shouldAskGithubStar({ githubStarPromptDismissedAt: " " })).toBe(true); + }); + + it("does not ask for a star on the non-interactive auto-launch path", async () => { + mockProviderAuthFactory.mockReturnValue(makeProviderAuth()); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runOnboard({ interactive: false }); + + expect(logSpy.mock.calls.map((call) => String(call[0])).join("\n")).not.toMatch(/GitHub/i); + expect(globalSettingsState.githubStarPromptDismissedAt).toBeUndefined(); + }); + it("treats cancellation distinctly from skip and does not persist completion", async () => { const providerAuth = makeProviderAuth(); mockProviderAuthFactory.mockReturnValue(providerAuth); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 34d79c87a6..a7337c5714 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -266,6 +266,70 @@ async function runSkippableStep( return true; } +/* +FNXC:GithubStarAsk 2026-08-19-03:59: +Canonical repository for the star ask. Kept next to the ask itself rather than read from package.json +so the printed link cannot silently become a workspace-local path in a bundled CLI. +*/ +export const GITHUB_REPO_URL = "https://github.com/Runfusion/Fusion"; + +/** + * FNXC:GithubStarAsk 2026-08-19-03:59: + * The star ask is one-shot for the lifetime of the install: once the operator has answered it — + * dismissed it, or been handed the link — `githubStarPromptDismissedAt` is stamped and no surface + * asks again. That includes `fn onboard --force`, which replays every other step. + */ +export function shouldAskGithubStar(settings: { githubStarPromptDismissedAt?: string }): boolean { + return !( + typeof settings.githubStarPromptDismissedAt === "string" && + settings.githubStarPromptDismissedAt.trim().length > 0 + ); +} + +/* +FNXC:GithubStarAsk 2026-08-19-03:59: +Asked only AFTER onboarding is complete and stamped, so a declined star — or a Ctrl-C on this +prompt — can never cost the operator the setup work they just did. The ask never opens a browser on +the operator's behalf; it prints the URL and they choose. A cancel is treated as a dismissal because +walking away from the question is an answer, and re-asking it would be exactly the nag we promised +not to be. +*/ +async function askToStarOnGithub( + prompts: PromptSession, + globalSettingsStore: GlobalSettingsStore, + settings: { githubStarPromptDismissedAt?: string }, +): Promise { + if (!shouldAskGithubStar(settings)) return; + + console.log("\nOne last thing:"); + let starred = false; + try { + starred = await prompts.promptYesNo("Fusion is open source. Star it on GitHub?", true); + } catch (error) { + if (!(error instanceof Error && error.message === PROMPT_CANCELLED_ERROR)) throw error; + } + + console.log( + starred + ? `★ Thank you! Star it here: ${GITHUB_REPO_URL}` + : "No problem — we won't ask again.", + ); + /* + FNXC:GithubStarAsk 2026-08-23-23:20: + Recording the answer is best-effort and must never fail the command. Onboarding is already complete + and stamped by this point, so letting a rejected settings write propagate would exit a successful + `fn onboard` non-zero over a cosmetic ask. The cost of the failed write is that this one ask can + return on a later run — strictly better than reporting a working install as a failed one. + */ + try { + await globalSettingsStore.updateSettings({ + githubStarPromptDismissedAt: new Date().toISOString(), + }); + } catch { + // Ignore: onboarding succeeded, and the ask returning once beats failing the command. + } +} + export function isCliOnboardingComplete(settings: { cliOnboardingCompletedAt?: string }): boolean { return ( typeof settings.cliOnboardingCompletedAt === "string" && @@ -424,6 +488,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { cliOnboardingCompletedAt: new Date().toISOString(), }); console.log("\n✓ Onboarding complete"); + await askToStarOnGithub(prompts, globalSettingsStore, settings); } catch (error) { if (error instanceof Error && error.message === PROMPT_CANCELLED_ERROR) { throw new Error("Onboarding cancelled."); @@ -443,5 +508,7 @@ export const __testUtils = { persistLocalProviderRegistry, runSkippableStep, isCliOnboardingComplete, + shouldAskGithubStar, + askToStarOnGithub, PROMPT_CANCELLED_ERROR, }; diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index c1d0ceb3b5..443dafb501 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -233,6 +233,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { defaultProjectId: undefined, setupComplete: undefined, cliOnboardingCompletedAt: undefined, + githubStarPromptDismissedAt: undefined, favoriteProviders: undefined, favoriteModels: undefined, openrouterModelSync: true, diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index dda22657fa..b4bb1b3480 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -522,6 +522,15 @@ export interface GlobalSettings { * Distinct from dashboard `setupComplete` first-run flow state. * Undefined means CLI onboarding has not completed yet. */ cliOnboardingCompletedAt?: string; + /* + FNXC:GithubStarAsk 2026-08-19-03:59: + Fusion asks the operator to star the GitHub repo once, right after onboarding finishes. The ask is + one-shot: this ISO stamp is written the moment the operator answers EITHER way (dismissed, or took + the link), because both answers mean the same thing operationally — never ask this person again. + Any surface that shows the ask must check this field first, so the CLI and the dashboard cannot + each get their own free nag. + */ + githubStarPromptDismissedAt?: string; /** List of favorite provider names. Favorite providers appear at the top of * model selection dropdowns. Order is preserved - earlier entries appear higher. */ favoriteProviders?: string[]; diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index fbae8e209e..dab43a7b14 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -27,7 +27,7 @@ import { useRightDockController } from "./components/useRightDockController"; import { QuickChatFAB } from "./components/QuickChatFAB"; import { ToastContainer } from "./components/ToastContainer"; import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; -import { useGitHubStarPromptShown, markGitHubStarPromptShown } from "./hooks/useGitHubStarPrompt"; +import { useGitHubStarPromptState, markGitHubStarPromptShown, refreshGitHubStarPromptDismissal } from "./hooks/useGitHubStarPrompt"; import { useSessionBannersHidden } from "./hooks/useSessionBannerPref"; import { mergeTaskSnapshot, useTasks } from "./hooks/useTasks"; import { useBoardWorkflows } from "./hooks/useBoardWorkflows"; @@ -822,12 +822,32 @@ function AppInner() { const { chatHasUnreadResponse } = useChatUnreadBadge(currentProject?.id, { taskView, quickChatOpen }); const { stashOrphanCount } = useStashOrphanCount(currentProject?.id); const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false); - const gitHubStarPromptShown = useGitHubStarPromptShown(); - const handleStarPrompt = useCallback(() => setShowGitHubStarPrompt(true), []); + /* + FNXC:GithubStarAsk 2026-08-23-23:35: + The banner gate hides the ask while the durable answer is unknown, but the done-transition TRIGGER + below reads the durable answer alone. That transition is one-shot: gating it on the unknown state + would drop it for good, so a fresh browser profile would never show the ask even when nobody had + dismissed it. + */ + const { dismissed: gitHubStarPromptDismissed, resolved: gitHubStarPromptResolved } = useGitHubStarPromptState(); + const gitHubStarPromptShown = gitHubStarPromptDismissed || !gitHubStarPromptResolved; + /* + FNXC:GithubStarAsk 2026-08-23-23:43: + Every trigger that would SHOW the ask re-reads the durable answer first — both of them route through + here: a task first reaching done, and onboarding completing. The mount-time lookup can be stale by + then (first-run setup routinely has this tab open while the operator answers `fn onboard` in a + terminal), and showing an ask the operator already dismissed elsewhere is the exact duplicate the + shared record exists to prevent. + */ + const handleStarPrompt = useCallback(() => { + void refreshGitHubStarPromptDismissal().then((alreadyAnswered) => { + if (!alreadyAnswered) setShowGitHubStarPrompt(true); + }); + }, []); const { candidate: approvalBannerCandidate, dismissApproval } = useApprovalBanner({ tasks, currentProjectId: currentProject?.id, - gitHubStarPromptShown, + gitHubStarPromptShown: gitHubStarPromptDismissed, onStarPrompt: handleStarPrompt, }); @@ -1113,6 +1133,8 @@ function AppInner() { closeSetupWizard: modalManager.closeSetupWizard, closeModelOnboarding: modalManager.closeModelOnboarding, closeProjectScopedModals: closeProjectScopedUi, + // FNXC:GithubStarAsk 2026-08-19-03:59: finishing onboarding is the first moment we ask for a GitHub star. + onOnboardingCompleted: handleStarPrompt, }); const { handleDetailClose } = useDeepLink({ diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 017d788336..d5302cda89 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -596,9 +596,18 @@ import { } from "./model-onboarding-state"; import { trackOnboardingEvent } from "./onboarding-events"; +/** FNXC:GithubStarAsk 2026-08-19-03:59: "dismissed" means the operator closed onboarding rather than finishing it. */ +export type OnboardingCompletionOutcome = "completed" | "dismissed"; + export interface ModelOnboardingModalProps { - /** Called when onboarding is complete or dismissed */ - onComplete: () => void; + /* + FNXC:GithubStarAsk 2026-08-19-03:59: + The outcome distinguishes finishing onboarding from walking out of it, because the post-onboarding + "star us on GitHub" ask is only earned by the former. Someone who dismissed the setup flow has + already said they want to be left alone; asking them for a favour on the way out is the nag. + Optional so existing callers that do not care about the distinction keep compiling. + */ + onComplete: (outcome?: OnboardingCompletionOutcome) => void; /** Toast helper */ addToast: (message: string, type?: ToastType) => void; /** Currently selected project ID (required for first-task actions) */ @@ -2130,7 +2139,7 @@ export function ModelOnboardingModal({ // Best-effort: still close even if save fails } setIsOpen(false); - onComplete(); + onComplete("dismissed"); }, [step, completedSteps, skippedSteps, onComplete]); // Close from the completion step diff --git a/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts b/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts index 3389d4932a..65512a7bbd 100644 --- a/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts @@ -1,16 +1,50 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { markGitHubStarPromptShown, useGitHubStarPromptShown } from "../useGitHubStarPrompt"; + +/* +FNXC:GithubStarAsk 2026-08-19-03:59: +The star ask is one per operator: the hook mirrors its local record into the global +`githubStarPromptDismissedAt` setting shared with the `fn onboard` ask, and adopts a dismissal +recorded elsewhere. Both directions are mocked here so the tests cover the wiring, not the network. +*/ +const mockFetchGlobalSettings = vi.fn(async () => ({}) as Record); +const mockUpdateGlobalSettings = vi.fn(async () => ({}) as Record); + +vi.mock("../../api", () => ({ + fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...(args as [])), + updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...(args as [])), +})); + +const { markGitHubStarPromptShown, useGitHubStarPromptShown, useGitHubStarPromptState, refreshGitHubStarPromptDismissal } = await import("../useGitHubStarPrompt"); describe("useGitHubStarPromptShown", () => { beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); + mockFetchGlobalSettings.mockReset(); + mockFetchGlobalSettings.mockResolvedValue({}); + mockUpdateGlobalSettings.mockReset(); + mockUpdateGlobalSettings.mockResolvedValue({}); }); - it("returns false by default", () => { + /* + FNXC:GithubStarAsk 2026-08-23-23:20: + Reports "shown" until the durable lookup settles, so an unknown answer can never render a duplicate + ask; only after it settles does the real local record govern. + */ + it("suppresses the ask until the durable lookup settles, then reports false", async () => { const { result } = renderHook(() => useGitHubStarPromptShown()); - expect(result.current).toBe(false); + expect(result.current).toBe(true); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("stops suppressing once the durable lookup fails, so an unreachable server still asks", async () => { + mockFetchGlobalSettings.mockRejectedValue(new Error("offline")); + + const { result } = renderHook(() => useGitHubStarPromptShown()); + + await waitFor(() => expect(result.current).toBe(false)); }); it("marks the prompt shown and persists the flag", () => { @@ -37,14 +71,14 @@ describe("useGitHubStarPromptShown", () => { expect(result.current).toBe(true); }); - it("returns false when localStorage reads fail", () => { + it("returns false when localStorage reads fail", async () => { const getItemSpy = vi.spyOn(window.localStorage, "getItem").mockImplementation(() => { throw new Error("get failed"); }); const { result } = renderHook(() => useGitHubStarPromptShown()); - expect(result.current).toBe(false); + await waitFor(() => expect(result.current).toBe(false)); expect(getItemSpy).toHaveBeenCalled(); }); @@ -61,4 +95,131 @@ describe("useGitHubStarPromptShown", () => { expect(setItemSpy).toHaveBeenCalled(); }); + + it("records the dismissal in global settings so other surfaces stop asking", async () => { + const { result } = renderHook(() => useGitHubStarPromptShown()); + + act(() => { + markGitHubStarPromptShown(); + }); + + expect(result.current).toBe(true); + await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalledTimes(1)); + const [patch] = mockUpdateGlobalSettings.mock.calls[0] as [{ githubStarPromptDismissedAt?: string }]; + expect(typeof patch.githubStarPromptDismissedAt).toBe("string"); + }); + + it("adopts a dismissal recorded by another surface, such as the CLI onboarding ask", async () => { + mockFetchGlobalSettings.mockResolvedValue({ githubStarPromptDismissedAt: "2026-08-19T00:00:00.000Z" }); + + const { result } = renderHook(() => useGitHubStarPromptShown()); + // Never reports "not yet asked" on the way there — that gap is what would render a duplicate ask. + expect(result.current).toBe(true); + + await waitFor(() => expect(localStorage.getItem("fusion:github-star-prompt-shown")).toBe("1")); + expect(result.current).toBe(true); + }); + + it("keeps asking when no surface has recorded a dismissal", async () => { + const { result } = renderHook(() => useGitHubStarPromptShown()); + + await waitFor(() => expect(mockFetchGlobalSettings).toHaveBeenCalled()); + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("does not re-read global settings once the local record is set", async () => { + localStorage.setItem("fusion:github-star-prompt-shown", "1"); + + const { result } = renderHook(() => useGitHubStarPromptShown()); + + expect(result.current).toBe(true); + expect(mockFetchGlobalSettings).not.toHaveBeenCalled(); + }); + + it("stays dismissed locally when the settings write fails", async () => { + mockUpdateGlobalSettings.mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => useGitHubStarPromptShown()); + + act(() => { + markGitHubStarPromptShown(); + }); + + await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled()); + expect(result.current).toBe(true); + }); + + /* + FNXC:GithubStarAsk 2026-08-23-23:35: + The durable answer and the display gate must stay separable. A one-shot trigger that fires while the + lookup is in flight reads `dismissed` (still false — nobody has dismissed anything), so it is + recorded; the gate stays suppressed until `resolved`, so nothing renders in the meantime. + */ + it("reports the durable answer as not-dismissed while the lookup is still in flight", async () => { + const { result } = renderHook(() => useGitHubStarPromptState()); + + expect(result.current.dismissed).toBe(false); + expect(result.current.resolved).toBe(false); + + await waitFor(() => expect(result.current.resolved).toBe(true)); + expect(result.current.dismissed).toBe(false); + }); + + it("reports dismissed without a lookup when the local record is already set", () => { + localStorage.setItem("fusion:github-star-prompt-shown", "1"); + + const { result } = renderHook(() => useGitHubStarPromptState()); + + expect(result.current.dismissed).toBe(true); + expect(mockFetchGlobalSettings).not.toHaveBeenCalled(); + }); + + it("resolves even when the lookup fails, so the gate stops suppressing", async () => { + mockFetchGlobalSettings.mockRejectedValue(new Error("offline")); + + const { result } = renderHook(() => useGitHubStarPromptState()); + + await waitFor(() => expect(result.current.resolved).toBe(true)); + expect(result.current.dismissed).toBe(false); + }); + + /* + FNXC:GithubStarAsk 2026-08-23-23:43: + Cross-surface regression: the mount-time lookup can be stale by the time a trigger fires. First-run + setup routinely has a dashboard tab open while the operator answers `fn onboard` in a terminal, so + the stamp lands AFTER this tab looked. Both show-triggers (a task reaching done, and onboarding + completing) revalidate through this one seam, so covering it covers both surfaces. + */ + it("sees a dismissal recorded after the mount-time lookup, so a later trigger stays hidden", async () => { + const { result } = renderHook(() => useGitHubStarPromptState()); + await waitFor(() => expect(result.current.resolved).toBe(true)); + expect(result.current.dismissed).toBe(false); + + // The CLI ask is answered in a terminal, after this tab already looked. + mockFetchGlobalSettings.mockResolvedValue({ githubStarPromptDismissedAt: "2026-08-23T23:00:00.000Z" }); + + await expect(refreshGitHubStarPromptDismissal()).resolves.toBe(true); + expect(localStorage.getItem("fusion:github-star-prompt-shown")).toBe("1"); + await waitFor(() => expect(result.current.dismissed).toBe(true)); + }); + + it("still asks when revalidation finds no dismissal", async () => { + await expect(refreshGitHubStarPromptDismissal()).resolves.toBe(false); + expect(localStorage.getItem("fusion:github-star-prompt-shown")).toBeNull(); + }); + + it("falls back to the local record when revalidation cannot reach the server", async () => { + mockFetchGlobalSettings.mockRejectedValue(new Error("offline")); + + await expect(refreshGitHubStarPromptDismissal()).resolves.toBe(false); + + localStorage.setItem("fusion:github-star-prompt-shown", "1"); + await expect(refreshGitHubStarPromptDismissal()).resolves.toBe(true); + }); + + it("answers from the local record without a request when already dismissed", async () => { + localStorage.setItem("fusion:github-star-prompt-shown", "1"); + + await expect(refreshGitHubStarPromptDismissal()).resolves.toBe(true); + expect(mockFetchGlobalSettings).not.toHaveBeenCalled(); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts b/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts index 9514c808e4..e68f07f2d7 100644 --- a/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts @@ -267,4 +267,39 @@ describe("useProjectActions", () => { expect(options.toggleFavoriteModel).toHaveBeenCalledWith("claude-sonnet-4-5"); expect(options.addToast).toHaveBeenCalledWith("Failed to update model favorites", "error"); }); + + /* + FNXC:GithubStarAsk 2026-08-19-03:59: + The post-onboarding GitHub star ask is earned by FINISHING onboarding. Closing the flow is the + operator saying "leave me alone", so the dismissal path must close the modal and ask nothing. + */ + it("reports a finished onboarding so the star ask can fire, but not a dismissed one", () => { + const onOnboardingCompleted = vi.fn(); + const options = createOptions({ onOnboardingCompleted }); + const { result } = renderHook(() => useProjectActions(options)); + + act(() => { + result.current.handleModelOnboardingComplete(); + }); + expect(options.closeModelOnboarding).toHaveBeenCalledTimes(1); + expect(onOnboardingCompleted).toHaveBeenCalledTimes(1); + + act(() => { + result.current.handleModelOnboardingComplete("dismissed"); + }); + expect(options.closeModelOnboarding).toHaveBeenCalledTimes(2); + expect(onOnboardingCompleted).toHaveBeenCalledTimes(1); + }); + + it("closes onboarding without a completion listener wired", () => { + const options = createOptions(); + const { result } = renderHook(() => useProjectActions(options)); + + expect(() => { + act(() => { + result.current.handleModelOnboardingComplete("completed"); + }); + }).not.toThrow(); + expect(options.closeModelOnboarding).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/dashboard/app/hooks/useGitHubStarPrompt.ts b/packages/dashboard/app/hooks/useGitHubStarPrompt.ts index 488a2c5f8a..88b99e15ca 100644 --- a/packages/dashboard/app/hooks/useGitHubStarPrompt.ts +++ b/packages/dashboard/app/hooks/useGitHubStarPrompt.ts @@ -1,4 +1,5 @@ -import { useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { fetchGlobalSettings, updateGlobalSettings } from "../api"; const STORAGE_KEY = "fusion:github-star-prompt-shown"; const EVENT_NAME = "fusion:github-star-prompt-changed"; @@ -31,7 +32,43 @@ function subscribe(onChange: () => void): () => void { }; } +/* +FNXC:GithubStarAsk 2026-08-19-03:59: +The ask is one per operator, not one per browser profile. localStorage stays the fast local record — +it suppresses the prompt on this render without waiting on a request — while `githubStarPromptDismissedAt` +in global settings is the durable, cross-surface one: the CLI's post-onboarding ask reads and writes +the same field, so answering in either place retires the ask in both. The settings write is +best-effort; losing it costs at most one repeat ask on another browser, never a broken dismissal here. +*/ export function markGitHubStarPromptShown(): void { + if (typeof window === "undefined") return; + adoptDismissalLocally(); + void updateGlobalSettings({ githubStarPromptDismissedAt: new Date().toISOString() }).catch(() => { + // Best-effort: the local record above already suppresses the prompt on this machine. + }); +} + +/* +FNXC:GithubStarAsk 2026-08-23-23:35: +Two different facts, deliberately kept apart. `dismissed` is the durable answer — this operator already +answered, on some surface. `resolved` says only whether the durable lookup has finished, so callers can +tell "not asked yet" from "we have not looked yet". + +Collapsing them into one boolean breaks one caller or the other. Reporting "shown" while unresolved is +right for the DISPLAY gate (an unknown must never render a duplicate ask) but wrong for the TRIGGER +that records a completed task: that transition is one-shot, so a trigger suppressed during the lookup +window is dropped for good and the prompt never appears even when nobody had dismissed it. So the +display gate consumes `dismissed || !resolved`, while the trigger consumes `dismissed` alone. +*/ +export interface GitHubStarPromptState { + /** The durable answer: this operator already answered on some surface. */ + dismissed: boolean; + /** False only while the durable lookup is still in flight; a FAILED lookup resolves too. */ + resolved: boolean; +} + +/** Records the durable answer in this profile so the store and every mounted hook see it at once. */ +function adoptDismissalLocally(): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(STORAGE_KEY, "1"); @@ -41,6 +78,77 @@ export function markGitHubStarPromptShown(): void { } } -export function useGitHubStarPromptShown(): boolean { - return useSyncExternalStore(subscribe, read, () => false); +/* +FNXC:GithubStarAsk 2026-08-23-23:43: +Re-reads the durable answer at the moment a trigger wants to SHOW the ask, and returns whether it is +already answered. The mount-time lookup alone is not enough: first-run setup routinely has a dashboard +tab already open while the operator answers `fn onboard` in a terminal, so a stamp can land after that +lookup and this tab would otherwise ask a second time — exactly the duplicate the shared record exists +to prevent. A stamp found here is adopted locally, so the banner gate closes even if a trigger races it. +An unreachable server falls back to the local record and asks, rather than suppressing the ask forever. +*/ +export async function refreshGitHubStarPromptDismissal(): Promise { + if (read()) return true; + try { + const settings = await fetchGlobalSettings(); + const dismissedAt = settings.githubStarPromptDismissedAt; + if (typeof dismissedAt === "string" && dismissedAt.trim().length > 0) { + adoptDismissalLocally(); + return true; + } + } catch { + // Unreachable settings mean the local record stays the answer. + } + return false; +} + +export function useGitHubStarPromptState(): GitHubStarPromptState { + const dismissed = useSyncExternalStore(subscribe, read, () => false); + const [resolved, setResolved] = useState(false); + + /* + FNXC:GithubStarAsk 2026-08-19-03:59: + Adopt a dismissal recorded elsewhere (the `fn onboard` ask, or another browser) into this profile's + local record, so a fresh dashboard on an already-answered install never re-asks. Runs only while the + local record is unset, so it is a single request on the machines that still might ask. + */ + useEffect(() => { + if (dismissed || typeof window === "undefined") return; + let cancelled = false; + void fetchGlobalSettings() + .then((settings) => { + if (cancelled) return; + const dismissedAt = settings.githubStarPromptDismissedAt; + if (typeof dismissedAt === "string" && dismissedAt.trim().length > 0) { + adoptDismissalLocally(); + } + }) + .catch(() => { + // Unreachable settings mean we simply keep the local record as-is. + }) + .finally(() => { + /* + FNXC:GithubStarAsk 2026-08-23-23:20: + A failed lookup resolves as well: an unreachable server must leave the local record as the + answer rather than suppressing the ask forever. + */ + if (!cancelled) setResolved(true); + }); + return () => { + cancelled = true; + }; + }, [dismissed]); + + return { dismissed, resolved }; +} + +/** + * FNXC:GithubStarAsk 2026-08-23-23:35: + * The DISPLAY gate: true when the ask must stay hidden — either it was already answered, or we do not + * yet know. Trigger sites must use `useGitHubStarPromptState().dismissed` instead, or they will drop a + * one-shot trigger that arrives during the lookup window. + */ +export function useGitHubStarPromptShown(): boolean { + const { dismissed, resolved } = useGitHubStarPromptState(); + return dismissed || !resolved; } diff --git a/packages/dashboard/app/hooks/useProjectActions.ts b/packages/dashboard/app/hooks/useProjectActions.ts index a3d1d882ae..18ef5247ac 100644 --- a/packages/dashboard/app/hooks/useProjectActions.ts +++ b/packages/dashboard/app/hooks/useProjectActions.ts @@ -5,6 +5,7 @@ import type { ProjectInfo } from "../api"; import { replaceProjectIdInUrl } from "../utils/projectUrlState"; import type { ViewMode, TaskView } from "./useViewState"; import type { ToastType } from "./useToast"; +import type { OnboardingCompletionOutcome } from "../components/ModelOnboardingModal"; interface UseProjectActionsOptions { setCurrentProject: (project: ProjectInfo) => void; @@ -21,6 +22,13 @@ interface UseProjectActionsOptions { closeSetupWizard: () => void; closeModelOnboarding: () => void; /* + FNXC:GithubStarAsk 2026-08-19-03:59: + Fired once onboarding is FINISHED (not dismissed) so the dashboard can make its one post-onboarding + ask — currently the GitHub star prompt. The prompt owns its own "already asked" state; this hook + only reports the moment. + */ + onOnboardingCompleted?: () => void; + /* FNXC:ProjectSwitchModalReset 2026-07-23-00:00: Every project-switch entry point (select, view-all, setup-complete) must dismiss modals scoped to the previous project so its task detail / planning payloads do not @@ -35,7 +43,7 @@ export interface UseProjectActionsResult { handleOpenSettings: () => void; handleAddProject: () => void; handleSetupComplete: (project: ProjectInfo) => void; - handleModelOnboardingComplete: () => void; + handleModelOnboardingComplete: (outcome?: OnboardingCompletionOutcome) => void; handlePauseProject: (project: ProjectInfo) => Promise; handleResumeProject: (project: ProjectInfo) => Promise; handleRemoveProject: (project: ProjectInfo) => Promise; @@ -60,6 +68,7 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject closeSetupWizard, closeModelOnboarding, closeProjectScopedModals, + onOnboardingCompleted, } = options; const handleSelectProject = useCallback((project: ProjectInfo) => { @@ -105,9 +114,11 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject void refreshProjects(); }, [closeSetupWizard, closeProjectScopedModals, currentProject?.id, setCurrentProject, setViewMode, addToast, refreshProjects, t]); - const handleModelOnboardingComplete = useCallback(() => { + const handleModelOnboardingComplete = useCallback((outcome?: OnboardingCompletionOutcome) => { closeModelOnboarding(); - }, [closeModelOnboarding]); + // FNXC:GithubStarAsk 2026-08-19-03:59: only a finished onboarding earns the star ask; a dismissal does not. + if (outcome !== "dismissed") onOnboardingCompleted?.(); + }, [closeModelOnboarding, onOnboardingCompleted]); const handlePauseProject = useCallback(async (project: ProjectInfo) => { try {