feat: ask for a GitHub star once onboarding finishes (#3516)
## 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](b67e3aa8bc) 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) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/github-star-ask-after-onboarding.md
Normal file
7
.changeset/github-star-ask-after-onboarding.md
Normal file
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -233,6 +233,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
defaultProjectId: undefined,
|
||||
setupComplete: undefined,
|
||||
cliOnboardingCompletedAt: undefined,
|
||||
githubStarPromptDismissedAt: undefined,
|
||||
favoriteProviders: undefined,
|
||||
favoriteModels: undefined,
|
||||
openrouterModelSync: true,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
const mockUpdateGlobalSettings = vi.fn(async () => ({}) as Record<string, unknown>);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
handleResumeProject: (project: ProjectInfo) => Promise<void>;
|
||||
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user