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:
gsxdsm
2026-08-23 16:56:40 -07:00
committed by GitHub
parent 00b7078f79
commit 6fca424852
11 changed files with 498 additions and 19 deletions

View 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.

View File

@@ -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);

View File

@@ -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,
};

View File

@@ -233,6 +233,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
defaultProjectId: undefined,
setupComplete: undefined,
cliOnboardingCompletedAt: undefined,
githubStarPromptDismissedAt: undefined,
favoriteProviders: undefined,
favoriteModels: undefined,
openrouterModelSync: true,

View File

@@ -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[];

View File

@@ -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({

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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 {